From 46a18998a400e04044605867403ecc56b9b0c4bd Mon Sep 17 00:00:00 2001 From: Gilead Cosman Date: Wed, 24 Jun 2026 09:11:53 -0700 Subject: [PATCH 01/11] Added ES6 support --- js2py/es6/__init__.py | 21 ++++++++ js2py/evaljs.py | 33 +++++++------ js2py/translators/translating_nodes.py | 59 ++++++++++++++++++---- js2py/translators/translator.py | 29 ++++++++++- js2py/utils/injector.py | 20 +++++++- tests/test_es6.py | 68 ++++++++++++++++++++++++++ 6 files changed, 203 insertions(+), 27 deletions(-) create mode 100644 tests/test_es6.py diff --git a/js2py/es6/__init__.py b/js2py/es6/__init__.py index 4a58d3f0..569ef47c 100644 --- a/js2py/es6/__init__.py +++ b/js2py/es6/__init__.py @@ -2,6 +2,27 @@ babel = None babelPresetEs2015 = None +import re + +# Patterns for ES6+ syntax that pyjsparser rejects or that needs Babel downleveling. +_ES6_SYNTAX_RE = re.compile( + r'(?:' + r'=>|' # arrow functions + r'\bclass\b|' # classes + r'`|' # template literals + r'\.\.\.|' # spread/rest + r'\bfor\s*\([^)]*\bof\b|' # for...of + r'\bimport\b|\bexport\b|' # modules + r'\basync\b|\bawait\b|' # async/await + r'\bfunction\s*\*' # generators + r')', + re.MULTILINE) + + +def looks_like_es6(code): + """Return True if source likely contains ES6+ syntax needing transpilation.""" + return bool(_ES6_SYNTAX_RE.search(code)) + def js6_to_js5(code): global INITIALISED, babel, babelPresetEs2015 diff --git a/js2py/evaljs.py b/js2py/evaljs.py index f4649c4d..1f16a2a9 100644 --- a/js2py/evaljs.py +++ b/js2py/evaljs.py @@ -1,6 +1,5 @@ # coding=utf-8 from .translators import translate_js, DEFAULT_HEADER -from .es6 import js6_to_js5 import sys import time import json @@ -57,11 +56,13 @@ def write_file_contents(path_or_file, contents): f.write(contents) -def translate_file(input_path, output_path): +def translate_file(input_path, output_path, es6=False): ''' Translates input JS file to python and saves the it to the output path. It appends some convenience code at the end so that it is easy to import JS objects. + es6: False, True, or 'auto' — transpile ES6 via Babel before translation. + For example we have a file 'example.js' with: var a = function(x) {return x} translate_file('example.js', 'example.py') @@ -72,7 +73,7 @@ def translate_file(input_path, output_path): ''' js = get_file_contents(input_path) - py_code = translate_js(js) + py_code = translate_js(js, es6=es6) lib_name = os.path.basename(output_path).split('.')[0] head = '__all__ = [%s]\n\n# Don\'t look below, you will not understand this Python code :) I don\'t.\n\n' % repr( lib_name) @@ -92,11 +93,13 @@ def run_file(path_or_file, context=None): return eval_value, context -def eval_js(js): +def eval_js(js, es6=False): """Just like javascript eval. Translates javascript to python, executes and returns python object. js is javascript source code + es6: False, True, or 'auto' — see translate_js. + EXAMPLE: >>> import js2py >>> add = js2py.eval_js('function add(a, b) {return a + b}') @@ -112,17 +115,17 @@ def eval_js(js): If you really want to convert object to python dict you can use to_dict method. """ e = EvalJs() - return e.eval(js) + return e.eval(js, es6=es6) def eval_js6(js): """Just like eval_js but with experimental support for js6 via babel.""" - return eval_js(js6_to_js5(js)) + return eval_js(js, es6=True) def translate_js6(js): """Just like translate_js but with experimental support for js6 via babel.""" - return translate_js(js6_to_js5(js)) + return translate_js(js, es6=True) class EvalJs(object): @@ -171,9 +174,11 @@ def _js_require_impl(npm_module_name): for k, v in six.iteritems(context): setattr(self._var, k, v) - def execute(self, js=None, use_compilation_plan=False): + def execute(self, js=None, use_compilation_plan=False, es6=False): """executes javascript js in current context + es6: False, True, or 'auto' — transpile ES6 via Babel before translation. + During initial execute() the converted js is cached for re-use. That means next time you run the same javascript snippet you save many instructions needed to parse and convert the js code to python code. @@ -188,20 +193,20 @@ def execute(self, js=None, use_compilation_plan=False): cache = self.__dict__['cache'] except KeyError: cache = self.__dict__['cache'] = {} - hashkey = hashlib.md5(js.encode('utf-8')).digest() + cache_key = (hashlib.md5(js.encode('utf-8')).digest(), es6) try: - compiled = cache[hashkey] + compiled = cache[cache_key] except KeyError: code = translate_js( - js, '', use_compilation_plan=use_compilation_plan) - compiled = cache[hashkey] = compile(code, '', + js, '', use_compilation_plan=use_compilation_plan, es6=es6) + compiled = cache[cache_key] = compile(code, '', 'exec') exec (compiled, self._context) - def eval(self, expression, use_compilation_plan=False): + def eval(self, expression, use_compilation_plan=False, es6=False): """evaluates expression in current context and returns its value""" code = 'PyJsEvalResult = eval(%s)' % json.dumps(expression) - self.execute(code, use_compilation_plan=use_compilation_plan) + self.execute(code, use_compilation_plan=use_compilation_plan, es6=es6) return self['PyJsEvalResult'] def execute_debug(self, js): diff --git a/js2py/translators/translating_nodes.py b/js2py/translators/translating_nodes.py index 4e2b5760..0dd07af4 100644 --- a/js2py/translators/translating_nodes.py +++ b/js2py/translators/translating_nodes.py @@ -239,9 +239,14 @@ def ObjectExpression(type, properties): name = None elems = [] after = '' + dynamic_inits = [] for p in properties: if p['kind'] == 'init': - elems.append('%s:%s' % Property(**p)) + key, val = Property(**p) + if key is None: + dynamic_inits.append(val) + else: + elems.append('%s:%s' % (key, val)) else: if name is None: name = inline_stack.require('Object') @@ -257,6 +262,18 @@ def ObjectExpression(type, properties): name, k, getter) else: raise RuntimeError('Unexpected object propery kind') + if dynamic_inits: + if name is None: + name = inline_stack.require('Object') + definition = 'Js({%s})' % ','.join(elems) if elems else 'Js({})' + body = '%s = %s\n' % (name, definition) + for init in dynamic_inits: + body += init.replace('__OBJ__', name) + '\n' + body += after + body += 'return %s\n' % name + code = 'def %s():\n%s' % (name, indent(body)) + inline_stack.define(name, code) + return name + '()' definition = 'Js({%s})' % ','.join(elems) if name is None: return definition @@ -269,9 +286,15 @@ def ObjectExpression(type, properties): def Property(type, kind, key, computed, value, method, shorthand): - if shorthand or computed: - raise NotImplementedError( - 'Shorthand and Computed properties not implemented!') + if shorthand: + k = to_key(key) + if k is None: + raise SyntaxError('Invalid key in dictionary! Or bug in Js2Py') + return repr(k), 'var.get(%s)' % repr(k) + if computed: + k_expr = trans(key) + v = trans(value) + return None, '%s.put(%s, %s)' % ('__OBJ__', k_expr, v) k = to_key(key) if k is None: raise SyntaxError('Invalid key in dictionary! Or bug in Js2Py') @@ -603,12 +626,28 @@ def Program(type, body): # ======== FUNCTIONS ============ +def _default_params_init(params, defaults, used_vars): + """Emit ES6 default-parameter initialization at function entry.""" + if not defaults: + return '' + num_defaults = len(defaults) + num_params = len(params) + code = '' + for i, (param, py_name) in enumerate(zip(params, used_vars)): + default_idx = i - (num_params - num_defaults) + if default_idx >= 0: + js_name = param['name'] + default_val = trans(defaults[default_idx]) + code += 'if %s.is_undefined():\n' % py_name + code += ' %s = %s\n' % (py_name, default_val) + code += ' var.put(%s, %s)\n' % (repr(js_name), py_name) + return code + + def FunctionDeclaration(type, id, params, defaults, body, generator, expression): if generator: raise NotImplementedError('Generators not supported') - if defaults: - raise NotImplementedError('Defaults not supported') if not id: return FunctionExpression(type, id, params, defaults, body, generator, expression) + '\n' @@ -643,10 +682,11 @@ def FunctionDeclaration(type, id, params, defaults, body, generator, arg_map.update({'this': 'this', 'arguments': 'arguments'}) arg_conv = 'var = Scope({%s}, var)\n' % ', '.join( repr(k) + ':' + v for k, v in six.iteritems(arg_map)) + default_init = _default_params_init(params, defaults or [], used_vars) # and finally set the name of the function to its real name: footer = '%s.func_name = %s\n' % (PyName, repr(JsName)) footer += 'var.put(%s, %s)\n' % (repr(JsName), PyName) - whole_code = header + indent(arg_conv + code) + footer + whole_code = header + indent(arg_conv + default_init + code) + footer # restore context Context = previous_context # define in upper context @@ -658,8 +698,6 @@ def FunctionExpression(type, id, params, defaults, body, generator, expression): if generator: raise NotImplementedError('Generators not supported') - if defaults: - raise NotImplementedError('Defaults not supported') JsName = id['name'] if id else 'anonymous' if not is_valid_py_name(JsName): ScriptName = 'InlineNonPyName' @@ -698,9 +736,10 @@ def FunctionExpression(type, id, params, defaults, body, generator, arg_map[id['name']] = PyName arg_conv = 'var = Scope({%s}, var)\n' % ', '.join( repr(k) + ':' + v for k, v in six.iteritems(arg_map)) + default_init = _default_params_init(params, defaults or [], used_vars) # and finally set the name of the function to its real name: footer = '%s._set_name(%s)\n' % (PyName, repr(JsName)) - whole_code = header + indent(arg_conv + code) + footer + whole_code = header + indent(arg_conv + default_init + code) + footer # restore context Context = previous_context # define in upper context diff --git a/js2py/translators/translator.py b/js2py/translators/translator.py index 5ec47002..dfa2d52d 100644 --- a/js2py/translators/translator.py +++ b/js2py/translators/translator.py @@ -5,6 +5,12 @@ import hashlib import re +try: + from ..es6 import js6_to_js5, looks_like_es6 +except ImportError: + js6_to_js5 = None + looks_like_es6 = None + # Enable Js2Py exceptions and pyimport in parser pyjsparser.parser.ENABLE_PYIMPORT = True @@ -61,9 +67,28 @@ def pyjsparser_parse_fn(code): parser = pyjsparser.PyJsParser() return parser.parse(code) -def translate_js(js, HEADER=DEFAULT_HEADER, use_compilation_plan=False, parse_fn=pyjsparser_parse_fn): +def _prepare_js_source(js, es6=False): + """Optionally downlevel ES6 source to ES5 before translation.""" + if es6 == 'auto': + if looks_like_es6 and looks_like_es6(js): + es6 = True + else: + es6 = False + if es6: + if js6_to_js5 is None: + raise RuntimeError('ES6 support is not available') + return js6_to_js5(js) + return js + + +def translate_js(js, HEADER=DEFAULT_HEADER, use_compilation_plan=False, + parse_fn=pyjsparser_parse_fn, es6=False): """js has to be a javascript source code. - returns equivalent python code.""" + returns equivalent python code. + + es6: False (ES5 only), True (always transpile via Babel), or 'auto' + (transpile when ES6 syntax is detected).""" + js = _prepare_js_source(js, es6) if use_compilation_plan and not '//' in js and not '/*' in js: return translate_js_with_compilation_plan(js, HEADER=HEADER) diff --git a/js2py/utils/injector.py b/js2py/utils/injector.py index 88e0d93e..96bbb08c 100644 --- a/js2py/utils/injector.py +++ b/js2py/utils/injector.py @@ -24,6 +24,8 @@ def fix_js_args(func): fargs = fcode.co_varnames[fcode.co_argcount - 2:fcode.co_argcount] if fargs == ('this', 'arguments') or fargs == ('arguments', 'var'): return func + if sys.version_info >= (3, 13): + return _wrap_js_args(func) code = append_arguments(six.get_function_code(func), ('this', 'arguments')) result = types.FunctionType( @@ -34,6 +36,20 @@ def fix_js_args(func): return result +def _wrap_js_args(func): + """Append this/arguments to a function signature without rewriting bytecode.""" + fcode = six.get_function_code(func) + param_names = list(fcode.co_varnames[:fcode.co_argcount]) + func_name = func.__name__ + sig = ', '.join(param_names + ['this', 'arguments']) + call = ', '.join(param_names) + src = 'def {name}({sig}):\n return _func({call})\n'.format( + name=func_name, sig=sig, call=call) + namespace = {'_func': func} + exec(compile(src, '', 'exec'), namespace) + return namespace[func_name] + + def append_arguments(code_obj, new_locals): co_varnames = code_obj.co_varnames # Old locals co_names = code_obj.co_names # Old globals @@ -261,7 +277,9 @@ def signature(func): code_obj.co_filename, code_obj.co_freevars, code_obj.co_cellvars) -check(six.get_function_code(check)) +# Bytecode layoutText changed in 3.13+ (inline caches); skip round-trip check there. +if sys.version_info < (3, 13): + check(six.get_function_code(check)) diff --git a/tests/test_es6.py b/tests/test_es6.py new file mode 100644 index 00000000..68095075 --- /dev/null +++ b/tests/test_es6.py @@ -0,0 +1,68 @@ +"""Tests for ES6 JavaScript translation support.""" +import js2py +from js2py.es6 import looks_like_es6 + + +def test_looks_like_es6(): + assert looks_like_es6('let a = () => 1') + assert looks_like_es6('class Foo {}') + assert not looks_like_es6('var a = 1; function f() { return a; }') + + +def test_native_default_parameters(): + f = js2py.eval_js('function f(x, y) { y = (y === undefined) ? 2 : y; return x + y; }') + assert f(1) == 3 + f2 = js2py.eval_js('function f(x, y=2) { return x + y; }') + assert f2(1) == 3 + assert f2(1, 5) == 6 + + +def test_native_object_shorthand(): + assert js2py.eval_js('var x = 10; ({x}).x') == 10 + + +def test_native_computed_property(): + assert js2py.eval_js('var k = "foo"; ({[k]: 42}).foo') == 42 + + +def test_eval_js_auto_es6(): + assert js2py.eval_js('(() => 11)()', es6=True) == 11 + + +def test_eval_js6_arrow_this(): + result = js2py.eval_js6(''' +const v = 11; +obj = {value: v}; +obj.x = function() { + return () => this +}; +obj.x()() +''') + assert result.value == 11 + + +def test_eval_js6_for_of(): + assert js2py.eval_js6(''' +var x; +for (let a of [1,2,3]) { + x = a +} +typeof a === 'undefined' && x === 3 +''') + + +def test_eval_js6_class(): + shape = js2py.eval_js6(''' +class Shape { + constructor (id, x, y) { + this.id = id + this.move(x, y) + } + move (x, y) { + this.x = x + this.y = y + } +}; +new Shape(1,2,3) +''') + assert shape.x == 2 From b9f90303c2cb74f8c3f40ebc5eb324aece8dd02b Mon Sep 17 00:00:00 2001 From: Gilead Cosman Date: Wed, 24 Jun 2026 11:26:54 -0700 Subject: [PATCH 02/11] Added a Makefile for testing and got the tests to pass --- Makefile | 30 +++++++ js2py/base.py | 44 +++++++++- js2py/evaljs.py | 8 +- js2py/host/jseval.py | 8 +- js2py/translators/translating_nodes.py | 19 ++--- js2py/utils/injector.py | 111 +++++++++++++++++++++---- tests/test_es6.py | 29 +++++-- 7 files changed, 212 insertions(+), 37 deletions(-) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..3d567254 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +PYTHON ?= python3 +# CURDIR is reliable with spaces; lastword(MAKEFILE_LIST) breaks on "Web Browser/..." +ROOT := $(CURDIR) + +export PYTHONPATH := $(ROOT) + +.PHONY: help test test-simple test-es6 test-language test-all + +help: + @echo "Js2Py test targets:" + @echo " make test Run quick integration tests (default)" + @echo " make test-simple Run simple_test.py (ES5 + ES6 smoke tests)" + @echo " make test-es6 Run tests/test_es6.py" + @echo " make test-language Run ES5.1 language suite (tests/run.py, slow)" + @echo " make test-all Run quick tests and the language suite" + +test: test-simple test-es6 + @: + +test-simple: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/simple_test.py" + +test-es6: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es6.py" + +test-language: + @test -f "$(ROOT)/tests/node_failed.txt" || touch "$(ROOT)/tests/node_failed.txt" + cd "$(ROOT)/tests" && PYTHONPATH="$(ROOT)" $(PYTHON) run.py + +test-all: test test-language diff --git a/js2py/base.py b/js2py/base.py index f4ee721c..a1c4501c 100644 --- a/js2py/base.py +++ b/js2py/base.py @@ -1150,7 +1150,10 @@ def get(self, prop, throw=True): # fast local scope cand = self.own.get(prop) if cand is None: - return self.prototype.get(prop, throw) + parent = self.prototype + if isinstance(parent, Scope): + return parent.get(prop, throw) + return parent.get(prop) return cand # slow, global scope if prop not in self.own: @@ -1369,15 +1372,35 @@ def __repr__(self): ObjectPrototype = PyJsObject() +def _js_argcount(fcode): + """Number of JavaScript parameters (excluding this, arguments, and var).""" + names = fcode.co_varnames[:fcode.co_argcount] + if (len(names) >= 3 and names[-1] == 'var' and names[-3] == 'this' and + names[-2] == 'arguments'): + return len(names) - 3 + if len(names) >= 2 and names[-2] == 'this' and names[-1] == 'arguments': + return len(names) - 2 + return fcode.co_argcount - 2 + + #Function class PyJsFunction(PyJs): Class = 'Function' def __init__(self, func, prototype=None, extensible=True, source=None): cand = fix_js_args(func) - has_scope = cand is func func = cand - self.argcount = six.get_function_code(func).co_argcount - 2 - has_scope + fcode = six.get_function_code(func) + fargs = fcode.co_varnames[fcode.co_argcount - 2:fcode.co_argcount] + if fargs == ('this', 'arguments') or fargs == ('arguments', 'var'): + self._js_global_this = False + self.argcount = _js_argcount(fcode) + elif cand is func: + self._js_global_this = True + self.argcount = fcode.co_argcount + else: + self._js_global_this = False + self.argcount = fcode.co_argcount - 2 self.code = func self.source = source if source else '{ [python code] }' self.func_name = func.__name__ if not func.__name__.startswith( @@ -1459,6 +1482,21 @@ def call(self, this, args=()): args = args[0:arglen] elif len(args) < arglen: args += (undefined, ) * (arglen - len(args)) + if self._js_global_this: + g = self.code.__globals__ + saved = {} + for name, val in (('this', this), ('arguments', arguments)): + if name in g: + saved[name] = g[name] + g[name] = val + try: + return Js(self.code(*args)) + finally: + for name, val in six.iteritems(saved): + g[name] = val + for name in ('this', 'arguments'): + if name not in saved: + g.pop(name, None) args += this, arguments #append extra params to the arg list try: return Js(self.code(*args)) diff --git a/js2py/evaljs.py b/js2py/evaljs.py index 1f16a2a9..06a37a48 100644 --- a/js2py/evaljs.py +++ b/js2py/evaljs.py @@ -1,5 +1,6 @@ # coding=utf-8 from .translators import translate_js, DEFAULT_HEADER +from .translators.translator import _prepare_js_source import sys import time import json @@ -120,12 +121,12 @@ def eval_js(js, es6=False): def eval_js6(js): """Just like eval_js but with experimental support for js6 via babel.""" - return eval_js(js, es6=True) + return eval_js(_prepare_js_source(js, True)) def translate_js6(js): """Just like translate_js but with experimental support for js6 via babel.""" - return translate_js(js, es6=True) + return translate_js(_prepare_js_source(js, True)) class EvalJs(object): @@ -205,8 +206,9 @@ def execute(self, js=None, use_compilation_plan=False, es6=False): def eval(self, expression, use_compilation_plan=False, es6=False): """evaluates expression in current context and returns its value""" + expression = _prepare_js_source(expression, es6) code = 'PyJsEvalResult = eval(%s)' % json.dumps(expression) - self.execute(code, use_compilation_plan=use_compilation_plan, es6=es6) + self.execute(code, use_compilation_plan=use_compilation_plan) return self['PyJsEvalResult'] def execute_debug(self, js): diff --git a/js2py/host/jseval.py b/js2py/host/jseval.py index 13140d2d..b3f36cae 100644 --- a/js2py/host/jseval.py +++ b/js2py/host/jseval.py @@ -8,7 +8,13 @@ @Js def Eval(code): - local_scope = inspect.stack()[3][0].f_locals['var'] + local_scope = None + for frame_info in inspect.stack(): + if 'var' in frame_info.frame.f_locals: + local_scope = frame_info.frame.f_locals['var'] + break + if local_scope is None: + raise MakeError('ReferenceError', 'eval scope not found') global_scope = this.GlobalObject # todo fix scope - we have to behave differently if called through variable other than eval # we will use local scope (default) diff --git a/js2py/translators/translating_nodes.py b/js2py/translators/translating_nodes.py index 0dd07af4..d4215036 100644 --- a/js2py/translators/translating_nodes.py +++ b/js2py/translators/translating_nodes.py @@ -566,7 +566,7 @@ def TryStatement(type, block, handler, handlers, guardedHandlers, finalizer): if handler: identifier = handler['param']['name'] holder = 'PyJsHolder_%s_%d' % (to_hex(identifier), - random.randrange(1e8)) + random.randrange(int(1e8))) identifier = repr(identifier) result += 'except PyJsException as PyJsTempException:\n' # fill in except ( catch ) block and remember to recover holder variable to its previous state @@ -630,17 +630,16 @@ def _default_params_init(params, defaults, used_vars): """Emit ES6 default-parameter initialization at function entry.""" if not defaults: return '' - num_defaults = len(defaults) - num_params = len(params) code = '' for i, (param, py_name) in enumerate(zip(params, used_vars)): - default_idx = i - (num_params - num_defaults) - if default_idx >= 0: - js_name = param['name'] - default_val = trans(defaults[default_idx]) - code += 'if %s.is_undefined():\n' % py_name - code += ' %s = %s\n' % (py_name, default_val) - code += ' var.put(%s, %s)\n' % (repr(js_name), py_name) + default = defaults[i] if i < len(defaults) else None + if default is None: + continue + js_name = param['name'] + default_val = trans(default) + code += 'if %s.is_undefined():\n' % py_name + code += ' %s = %s\n' % (py_name, default_val) + code += ' var.put(%s, %s)\n' % (repr(js_name), py_name) return code diff --git a/js2py/utils/injector.py b/js2py/utils/injector.py index 96bbb08c..977ab0d7 100644 --- a/js2py/utils/injector.py +++ b/js2py/utils/injector.py @@ -24,9 +24,12 @@ def fix_js_args(func): fargs = fcode.co_varnames[fcode.co_argcount - 2:fcode.co_argcount] if fargs == ('this', 'arguments') or fargs == ('arguments', 'var'): return func - if sys.version_info >= (3, 13): - return _wrap_js_args(func) - code = append_arguments(six.get_function_code(func), ('this', 'arguments')) + # Python 3.11+ bytecode layouts (especially 3.14 inline caches) are too + # fragile to rewrite safely. PyJsFunction.call injects this/arguments + # into the function globals instead. + if sys.version_info >= (3, 11): + return func + code = append_arguments(fcode, ('this', 'arguments')) result = types.FunctionType( code, @@ -36,18 +39,96 @@ def fix_js_args(func): return result -def _wrap_js_args(func): - """Append this/arguments to a function signature without rewriting bytecode.""" - fcode = six.get_function_code(func) - param_names = list(fcode.co_varnames[:fcode.co_argcount]) - func_name = func.__name__ - sig = ', '.join(param_names + ['this', 'arguments']) - call = ', '.join(param_names) - src = 'def {name}({sig}):\n return _func({call})\n'.format( - name=func_name, sig=sig, call=call) - namespace = {'_func': func} - exec(compile(src, '', 'exec'), namespace) - return namespace[func_name] +def _instruction_bytes(code_obj, inst, insts, index): + old_code = code_obj.co_code + end = len(old_code) + if index + 1 < len(insts): + end = insts[index + 1].offset + return old_code[inst.offset:end] + + +def _instruction_span_len(code_obj, insts, index): + inst = insts[index] + if index + 1 < len(insts): + return insts[index + 1].offset - inst.offset + return len(code_obj.co_code) - inst.offset + + +def _write_padded_instruction(op, arg, span_len): + """Write an instruction, padding with NOPs to fill span_len bytes.""" + nop = dis.opmap['NOP'] + out = bytearray(write_instruction(op, arg)) + while len(out) < span_len: + out.extend(write_instruction(nop, 0)) + return bytes(out[:span_len]) + + +_PACKED_FAST_OPS = frozenset( + name for name in opcode.opmap + if ('LOAD_FAST' in name or 'STORE_FAST' in name) and name.count('FAST') >= 2) + + +def _remap_local_arg(inst, translations): + """Remap a local slot index, including packed two-slot operands.""" + if inst.opname in _PACKED_FAST_OPS: + first = inst.arg & 0xF + second = (inst.arg >> 4) & 0xF + first = translations.get(first, first) + second = translations.get(second, second) + return (second << 4) | first + if inst.arg in translations: + return translations[inst.arg] + return inst.arg + + +def _patch_span_arg(span, new_arg): + """Patch the arg byte of a 2-byte wordcode instruction.""" + chunk = bytearray(span) + chunk[1] = new_arg & 0xFF + return bytes(chunk) + + +def _extend_js_args_modern(code_obj, new_locals): + """Extend a code object with this/arguments on Python 3.11+.""" + co_varnames = code_obj.co_varnames + co_argcount = code_obj.co_argcount + new_locals_len = len(new_locals) + varnames = ( + co_varnames[:co_argcount] + new_locals + co_varnames[co_argcount:]) + names_to_idx = {name: varnames.index(name) for name in new_locals} + varname_translations = dict((i, i) for i in range(co_argcount)) + varname_translations.update( + (i, i + new_locals_len) for i in range(co_argcount, len(co_varnames))) + insts = list(instructions(code_obj, show_cache=False)) + load_fast = opcode.opmap.get('LOAD_FAST_BORROW', LOAD_FAST) + modified = bytearray() + for index, inst in enumerate(insts): + span = _instruction_bytes(code_obj, inst, insts, index) + if (inst.opname == 'LOAD_GLOBAL' and + getattr(inst, 'argval', None) in names_to_idx): + modified.extend(_write_padded_instruction( + load_fast, names_to_idx[inst.argval], len(span))) + continue + if inst.opcode in opcode.haslocal: + new_arg = _remap_local_arg(inst, varname_translations) + if new_arg != inst.arg: + modified.extend(_patch_span_arg(span, new_arg)) + continue + if (sys.version_info >= (3, 11) and inst.opcode in opcode.hasfree and + getattr(inst, 'argval', None) not in + code_obj.co_varnames[:code_obj.co_argcount]): + new_arg = inst.arg + new_locals_len + if new_arg != inst.arg: + modified.extend(_patch_span_arg(span, new_arg)) + continue + modified.extend(span) + new_code = bytes(modified) + return code_obj.replace( + co_argcount=co_argcount + new_locals_len, + co_nlocals=max(code_obj.co_nlocals, len(varnames)), + co_varnames=varnames, + co_code=new_code, + ) def append_arguments(code_obj, new_locals): diff --git a/tests/test_es6.py b/tests/test_es6.py index 68095075..a844d216 100644 --- a/tests/test_es6.py +++ b/tests/test_es6.py @@ -10,11 +10,13 @@ def test_looks_like_es6(): def test_native_default_parameters(): - f = js2py.eval_js('function f(x, y) { y = (y === undefined) ? 2 : y; return x + y; }') - assert f(1) == 3 - f2 = js2py.eval_js('function f(x, y=2) { return x + y; }') - assert f2(1) == 3 - assert f2(1, 5) == 6 + ctx = js2py.EvalJs() + ctx.execute('function f(x, y) { y = (y === undefined) ? 2 : y; return x + y; }') + assert ctx.f(1) == 3 + ctx2 = js2py.EvalJs() + ctx2.execute('function f(x, y=2) { return x + y; }') + assert ctx2.f(1) == 3 + assert ctx2.f(1, 5) == 6 def test_native_object_shorthand(): @@ -66,3 +68,20 @@ class Shape { new Shape(1,2,3) ''') assert shape.x == 2 + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + sys.exit(1 if failed else 0) From 522bcb838af11c5c8fa6f71d3a1121f9d8942e3f Mon Sep 17 00:00:00 2001 From: Gilead Cosman Date: Wed, 24 Jun 2026 16:19:14 -0700 Subject: [PATCH 03/11] Added ES9 support --- Makefile | 8 +- js2py/__init__.py | 3 +- js2py/constructors/jsobject.py | 42 ++++++ js2py/constructors/jspromise.py | 253 ++++++++++++++++++++++++++++++++ js2py/es9/__init__.py | 212 ++++++++++++++++++++++++++ js2py/evaljs.py | 42 ++++-- js2py/event_loop.py | 93 ++++++++++++ js2py/pyjs.py | 2 + js2py/translators/translator.py | 24 ++- tests/test_es9.py | 113 ++++++++++++++ 10 files changed, 773 insertions(+), 19 deletions(-) create mode 100644 js2py/constructors/jspromise.py create mode 100644 js2py/es9/__init__.py create mode 100644 js2py/event_loop.py create mode 100644 tests/test_es9.py diff --git a/Makefile b/Makefile index 3d567254..aded56ab 100644 --- a/Makefile +++ b/Makefile @@ -4,17 +4,18 @@ ROOT := $(CURDIR) export PYTHONPATH := $(ROOT) -.PHONY: help test test-simple test-es6 test-language test-all +.PHONY: help test test-simple test-es6 test-es9 test-language test-all help: @echo "Js2Py test targets:" @echo " make test Run quick integration tests (default)" @echo " make test-simple Run simple_test.py (ES5 + ES6 smoke tests)" @echo " make test-es6 Run tests/test_es6.py" + @echo " make test-es9 Run tests/test_es9.py" @echo " make test-language Run ES5.1 language suite (tests/run.py, slow)" @echo " make test-all Run quick tests and the language suite" -test: test-simple test-es6 +test: test-simple test-es6 test-es9 @: test-simple: @@ -23,6 +24,9 @@ test-simple: test-es6: PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es6.py" +test-es9: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es9.py" + test-language: @test -f "$(ROOT)/tests/node_failed.txt" || touch "$(ROOT)/tests/node_failed.txt" cd "$(ROOT)/tests" && PYTHONPATH="$(ROOT)" $(PYTHON) run.py diff --git a/js2py/__init__.py b/js2py/__init__.py index a7fe5979..a6603d46 100644 --- a/js2py/__init__.py +++ b/js2py/__init__.py @@ -65,7 +65,8 @@ __all__ = [ 'EvalJs', 'translate_js', 'import_js', 'eval_js', 'parse_js', 'translate_file', 'run_file', 'disable_pyimport', 'eval_js6', - 'translate_js6', 'PyJsException', 'get_file_contents', + 'translate_js6', 'eval_js9', 'translate_js9', 'drain_event_loop', + 'PyJsException', 'get_file_contents', 'write_file_contents', 'require' ] diff --git a/js2py/constructors/jsobject.py b/js2py/constructors/jsobject.py index c4e0ada3..b42fce49 100644 --- a/js2py/constructors/jsobject.py +++ b/js2py/constructors/jsobject.py @@ -145,6 +145,48 @@ def keys(obj): raise MakeError('TypeError', 'Object.keys called on non-object') return [e for e, d in six.iteritems(obj.own) if d.get('enumerable')] + def values(obj): + if not obj.is_object(): + raise MakeError('TypeError', 'Object.values called on non-object') + return [obj.get(k) for k, d in six.iteritems(obj.own) + if d.get('enumerable')] + + def entries(obj): + if not obj.is_object(): + raise MakeError('TypeError', 'Object.entries called on non-object') + return [[Js(k), obj.get(k)] for k, d in six.iteritems(obj.own) + if d.get('enumerable')] + + def assign(target): + if not target.is_object(): + raise MakeError('TypeError', 'Object.assign target must be an object') + obj = target.to_object() + for i in range(1, len(arguments)): + src = arguments[i] + if src.is_null() or src.is_undefined(): + continue + src_obj = src.to_object() + for name, desc in six.iteritems(src_obj.own): + if desc.get('enumerable'): + obj.put(name, src_obj.get(name)) + return obj + + def fromEntries(iterable): + obj = PyJsObject(prototype=ObjectPrototype) + items = iterable.to_object() + length = items.get('length') + if length.TYPE == 'Number': + count = length.to_uint32() + for i in range(count): + entry = items.get(str(i)) + if entry.TYPE != 'Object': + raise MakeError('TypeError', 'Invalid entry in fromEntries') + key = entry.get('0') + val = entry.get('1') + obj.put(key.to_string().value, val) + return obj + raise MakeError('TypeError', 'Object.fromEntries requires an iterable') + # add methods attached to Object constructor fill_prototype(Object, ObjectMethods, default_attrs) diff --git a/js2py/constructors/jspromise.py b/js2py/constructors/jspromise.py new file mode 100644 index 00000000..50b1715b --- /dev/null +++ b/js2py/constructors/jspromise.py @@ -0,0 +1,253 @@ +from ..base import * + +PROMISE_STATES = {} + + +def _state(promise): + return PROMISE_STATES[id(promise)] + + +def _unwrap_promise(value): + if isinstance(value, PyJsObject) and getattr(value, 'Class', None) == 'Promise': + return value + return None + + +def _enqueue_reaction(reaction): + from ..event_loop import queue_microtask + queue_microtask(lambda: _run_reaction(reaction)) + + +PromisePrototype = PyJsObject(prototype=ObjectPrototype) + + +def _create_promise(executor): + promise = PyJsObject(prototype=PromisePrototype) + promise.Class = 'Promise' + PROMISE_STATES[id(promise)] = { + 'state': 'pending', + 'value': undefined, + 'reason': undefined, + 'reactions': [], + } + if executor is not None and executor.is_callable(): + resolving = {'done': False} + + @Js + def resolve(value): + if resolving['done']: + return undefined + resolving['done'] = True + _resolve_promise(promise, value) + return undefined + + @Js + def reject(reason): + if resolving['done']: + return undefined + resolving['done'] = True + _reject_promise(promise, reason) + return undefined + + try: + executor.call(undefined, (resolve, reject)) + except PyJsException as exc: + reject.call(undefined, (PyExceptionToJs(exc),)) + except Exception as exc: + reject.call(undefined, (PyExceptionToJs(exc),)) + return promise + + +def _resolve_promise(promise, value): + state = _state(promise) + if state['state'] != 'pending': + return + nested = _unwrap_promise(value) + if nested is not None: + if nested is promise: + _reject_promise(promise, MakeError( + 'TypeError', 'Cannot resolve promise with itself')) + return + + @Js + def on_fulfilled(val): + _resolve_promise(promise, val) + return undefined + + @Js + def on_rejected(reason): + _reject_promise(promise, reason) + return undefined + + nested.callprop('then', on_fulfilled, on_rejected) + return + state['state'] = 'fulfilled' + state['value'] = value + reactions = state['reactions'] + state['reactions'] = [] + for reaction in reactions: + _enqueue_reaction(reaction) + + +def _reject_promise(promise, reason): + state = _state(promise) + if state['state'] != 'pending': + return + state['state'] = 'rejected' + state['reason'] = reason + reactions = state['reactions'] + state['reactions'] = [] + for reaction in reactions: + _enqueue_reaction(reaction) + + +def _run_reaction(reaction): + parent, onFulfilled, onRejected, child = reaction + parent_state = _state(parent) + try: + if parent_state['state'] == 'fulfilled': + if onFulfilled.is_callable(): + val = onFulfilled.call(undefined, (parent_state['value'],)) + else: + val = parent_state['value'] + else: + if onRejected.is_callable(): + val = onRejected.call(undefined, (parent_state['reason'],)) + else: + _reject_promise(child, parent_state['reason']) + return + nested = _unwrap_promise(val) + if nested is not None: + + @Js + def on_fulfilled(v): + _resolve_promise(child, v) + return undefined + + @Js + def on_rejected(r): + _reject_promise(child, r) + return undefined + + nested.callprop('then', on_fulfilled, on_rejected) + else: + _resolve_promise(child, val) + except PyJsException as exc: + _reject_promise(child, PyExceptionToJs(exc)) + except Exception as exc: + _reject_promise(child, PyExceptionToJs(exc)) + + +def _chain_promise(promise, onFulfilled, onRejected): + result = _create_promise(None) + parent_state = _state(promise) + reaction = (promise, onFulfilled, onRejected, result) + if parent_state['state'] == 'pending': + parent_state['reactions'].append(reaction) + else: + _enqueue_reaction(reaction) + return result + + +class PromiseProtoMethods: + def then(onFulfilled, onRejected): + if len(arguments) < 2 or onRejected.is_undefined(): + onRejected = undefined + return _chain_promise(this, onFulfilled, onRejected) + + def catch(onRejected): + return this.callprop('then', undefined, onRejected) + + def finally_(onFinally): + if not onFinally.is_callable(): + onFinally = undefined + + @Js + def on_fulfilled(value): + if onFinally.is_callable(): + cleanup = onFinally.call(undefined, ()) + nested = _unwrap_promise(cleanup) + if nested is not None: + + @Js + def cont(): + return value + + return nested.callprop('then', cont) + return value + + @Js + def on_rejected(reason): + if onFinally.is_callable(): + cleanup = onFinally.call(undefined, ()) + nested = _unwrap_promise(cleanup) + if nested is not None: + + @Js + def cont(): + raise JsToPyException(reason) + + return nested.callprop('then', cont) + raise JsToPyException(reason) + + return this.callprop('then', on_fulfilled, on_rejected) + + +@Js +def promise_constructor(executor): + if len(arguments) and not executor.is_callable(): + raise MakeError('TypeError', 'Promise resolver is not a function') + return _create_promise(executor if len(arguments) else None) + + +Promise = promise_constructor +Promise.create = promise_constructor + + +@Js +def promise_resolve(value): + nested = _unwrap_promise(value) + if nested is not None: + return nested + promise = _create_promise(None) + _resolve_promise(promise, value) + return promise + + +@Js +def promise_reject(reason): + promise = _create_promise(None) + _reject_promise(promise, reason) + return promise + + +Promise.define_own_property('resolve', { + 'value': promise_resolve, + 'writable': True, + 'enumerable': False, + 'configurable': True +}) +Promise.define_own_property('reject', { + 'value': promise_reject, + 'writable': True, + 'enumerable': False, + 'configurable': True +}) +Promise.define_own_property( + 'prototype', { + 'value': PromisePrototype, + 'enumerable': False, + 'writable': False, + 'configurable': False + }) + +PromisePrototype.define_own_property('constructor', { + 'value': Promise, + 'writable': True, + 'enumerable': False, + 'configurable': True +}) + +fill_prototype(PromisePrototype, PromiseProtoMethods, default_attrs) +PromisePrototype.define_own_property( + 'finally', PromisePrototype.own['finally_']) diff --git a/js2py/es9/__init__.py b/js2py/es9/__init__.py new file mode 100644 index 00000000..1437e3d4 --- /dev/null +++ b/js2py/es9/__init__.py @@ -0,0 +1,212 @@ +"""ES9 (ES2018) support: object spread/rest and Promise.finally.""" + +import re + +CP_STRING = ( + '"([^\\\\"]+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|x[0-9a-fA-F]{2}|' + 'u[0-9a-fA-F]{4}))*"|\'([^\\\\\']+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|' + 'x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}))*\'') +CP_STRING_PLACEHOLDER = '__PyJsSTR_%i_PyJsSTR__' + +_ES9_SYNTAX_RE = re.compile( + r'(?:' + r'\{\s*\.\.\.|' + r',\s*\.\.\.|' + r'\{\s*[^}]*,\s*\.\.\.[\w$]|' + r'\.finally\s*\(|' + r'\bObject\.assign\s*\(|' + r'\bObject\.fromEntries\s*\(' + r')', + re.MULTILINE) + +_REST_DECL_RE = re.compile( + r'\b(var|let|const)\s+\{([^}]+)\}\s*=\s*([^;\n]+)\s*;?', + re.MULTILINE) + + +def looks_like_es9(code): + """Return True if source likely contains ES9 syntax or APIs.""" + return bool(_ES9_SYNTAX_RE.search(code)) + + +def prepare_es9(code): + """Apply ES2018 source transforms before translation.""" + matches = [] + + def mask(match): + matches.append(match.group(0)) + return CP_STRING_PLACEHOLDER % (len(matches) - 1) + + masked = re.sub(CP_STRING, mask, code) + masked = _transform_object_rest_declarations(masked) + masked = _transform_object_spreads(masked) + for index, value in enumerate(matches): + masked = masked.replace(CP_STRING_PLACEHOLDER % index, value, 1) + return masked + + +def _contains_spread_property(inner): + return bool(re.search(r'(?:^\s*,?\s*|\,\s*)\.\.\.', inner)) + + +def _find_matching_brace(code, start): + if start >= len(code) or code[start] != '{': + raise ValueError('expected {') + depth = 0 + in_str = None + i = start + while i < len(code): + ch = code[i] + if in_str: + if ch == '\\': + i += 2 + continue + if ch == in_str: + in_str = None + elif ch in ('"', "'"): + in_str = ch + elif ch == '{': + depth += 1 + elif ch == '}': + depth -= 1 + if depth == 0: + return i + i += 1 + return None + + +def _split_object_properties(inner): + parts = [] + buf = [] + depth = 0 + in_str = None + for ch in inner: + if in_str: + buf.append(ch) + if ch == '\\': + continue + if ch == in_str: + in_str = None + continue + if ch in ('"', "'"): + in_str = ch + buf.append(ch) + continue + if ch in '({[': + depth += 1 + elif ch in ')}]': + depth -= 1 + if ch == ',' and depth == 0: + part = ''.join(buf).strip() + if part: + parts.append(part) + buf = [] + continue + buf.append(ch) + part = ''.join(buf).strip() + if part: + parts.append(part) + return parts + + +def _is_destructuring_brace(code, brace_pos): + before = code[:brace_pos].rstrip() + if re.search(r'\b(var|let|const|for)\s*$', before): + return True + if re.search(r'(?:function\s+\w+\s*|\()\s*$', before): + return True + return False + + +def _desugar_spread_object(literal): + inner = literal[1:-1] + props = _split_object_properties(inner) + lines = ['var __o = {};'] + for prop in props: + prop = prop.strip() + if prop.startswith('...'): + expr = prop[3:].strip() + lines.append( + 'var __t = %s; if (__t != null) { for (var __k in __t) { ' + 'if (__t.hasOwnProperty(__k)) __o[__k] = __t[__k]; } }' % expr) + elif re.match(r'^[\w$]+\s*:', prop): + key, val = prop.split(':', 1) + key = key.strip() + val = val.strip() + if re.match(r'^[\w$]+$', key): + lines.append('__o.%s = %s;' % (key, val)) + else: + lines.append('__o[%s] = %s;' % (key, val)) + elif re.match(r'^[\w$]+$', prop): + lines.append('__o.%s = %s;' % (prop, prop)) + else: + lines.append('__o[%s] = %s;' % (prop, prop)) + lines.append('return __o;') + return '(function() { %s })()' % ' '.join(lines) + + +def _transform_object_spreads(code): + changed = True + while changed: + changed = False + i = 0 + while i < len(code): + if code[i] != '{': + i += 1 + continue + if _is_destructuring_brace(code, i): + i += 1 + continue + end = _find_matching_brace(code, i) + if end is None: + i += 1 + continue + literal = code[i:end + 1] + if not _contains_spread_property(literal[1:-1]): + i = end + 1 + continue + replacement = _desugar_spread_object(literal) + code = code[:i] + replacement + code[end + 1:] + changed = True + i += len(replacement) + return code + + +def _transform_object_rest_declarations(code): + def replacer(match): + kind, inner, src = match.group(1), match.group(2), match.group(3).strip() + props = [p.strip() for p in _split_object_properties(inner) if p.strip()] + if not any(p.startswith('...') for p in props): + return match.group(0) + fixed = [] + rest_name = None + excluded = [] + for prop in props: + if prop.startswith('...'): + rest_name = prop[3:].strip() + continue + if ':' in prop: + key, val = prop.split(':', 1) + key = key.strip().strip('"').strip("'") + val = val.strip() + else: + key = prop.strip() + val = '__src.' + key + excluded.append(key) + if re.match(r'^[\w$]+$', key): + fixed.append('var %s = __src.%s;' % (key, key)) + else: + fixed.append('var %s = __src[%s];' % (val, repr(key))) + if rest_name is None: + return match.group(0) + cond = 'true' + for key in excluded: + piece = '(__k !== "%s")' % key + cond = piece if cond == 'true' else cond + ' && ' + piece + fixed.append('var %s = {};' % rest_name) + fixed.append( + 'for (var __k in __src) { if (__src.hasOwnProperty(__k) && %s) ' + '%s[__k] = __src[__k]; }' % (cond, rest_name)) + return 'var __src = %s; %s' % (src, ' '.join(fixed)) + + return _REST_DECL_RE.sub(replacer, code) diff --git a/js2py/evaljs.py b/js2py/evaljs.py index 06a37a48..00360aa2 100644 --- a/js2py/evaljs.py +++ b/js2py/evaljs.py @@ -1,6 +1,7 @@ # coding=utf-8 from .translators import translate_js, DEFAULT_HEADER from .translators.translator import _prepare_js_source +from .event_loop import drain_event_loop import sys import time import json @@ -11,7 +12,8 @@ __all__ = [ 'EvalJs', 'translate_js', 'import_js', 'eval_js', 'translate_file', - 'eval_js6', 'translate_js6', 'run_file', 'disable_pyimport', + 'eval_js6', 'translate_js6', 'eval_js9', 'translate_js9', + 'run_file', 'disable_pyimport', 'drain_event_loop', 'get_file_contents', 'write_file_contents' ] DEBUG = False @@ -57,12 +59,13 @@ def write_file_contents(path_or_file, contents): f.write(contents) -def translate_file(input_path, output_path, es6=False): +def translate_file(input_path, output_path, es6=False, es9=False): ''' Translates input JS file to python and saves the it to the output path. It appends some convenience code at the end so that it is easy to import JS objects. es6: False, True, or 'auto' — transpile ES6 via Babel before translation. + es9: False, True, or 'auto' — enable ES2018 features (object spread/rest, etc.). For example we have a file 'example.js' with: var a = function(x) {return x} translate_file('example.js', 'example.py') @@ -74,7 +77,7 @@ def translate_file(input_path, output_path, es6=False): ''' js = get_file_contents(input_path) - py_code = translate_js(js, es6=es6) + py_code = translate_js(js, es6=es6, es9=es9) lib_name = os.path.basename(output_path).split('.')[0] head = '__all__ = [%s]\n\n# Don\'t look below, you will not understand this Python code :) I don\'t.\n\n' % repr( lib_name) @@ -94,12 +97,13 @@ def run_file(path_or_file, context=None): return eval_value, context -def eval_js(js, es6=False): +def eval_js(js, es6=False, es9=False): """Just like javascript eval. Translates javascript to python, executes and returns python object. js is javascript source code es6: False, True, or 'auto' — see translate_js. + es9: False, True, or 'auto' — enable ES2018 features. EXAMPLE: >>> import js2py @@ -116,7 +120,19 @@ def eval_js(js, es6=False): If you really want to convert object to python dict you can use to_dict method. """ e = EvalJs() - return e.eval(js, es6=es6) + result = e.eval(js, es6=es6, es9=es9) + drain_event_loop() + return result + + +def eval_js9(js): + """Like eval_js with ES2018 support enabled.""" + return eval_js(js, es9=True) + + +def translate_js9(js): + """Like translate_js with ES2018 support enabled.""" + return translate_js(js, es9=True) def eval_js6(js): @@ -175,10 +191,11 @@ def _js_require_impl(npm_module_name): for k, v in six.iteritems(context): setattr(self._var, k, v) - def execute(self, js=None, use_compilation_plan=False, es6=False): + def execute(self, js=None, use_compilation_plan=False, es6=False, es9=False): """executes javascript js in current context es6: False, True, or 'auto' — transpile ES6 via Babel before translation. + es9: False, True, or 'auto' — enable ES2018 features. During initial execute() the converted js is cached for re-use. That means next time you run the same javascript snippet you save many instructions needed to parse and convert the @@ -194,21 +211,24 @@ def execute(self, js=None, use_compilation_plan=False, es6=False): cache = self.__dict__['cache'] except KeyError: cache = self.__dict__['cache'] = {} - cache_key = (hashlib.md5(js.encode('utf-8')).digest(), es6) + cache_key = (hashlib.md5(js.encode('utf-8')).digest(), es6, es9) try: compiled = cache[cache_key] except KeyError: code = translate_js( - js, '', use_compilation_plan=use_compilation_plan, es6=es6) + js, '', use_compilation_plan=use_compilation_plan, + es6=es6, es9=es9) compiled = cache[cache_key] = compile(code, '', 'exec') exec (compiled, self._context) + drain_event_loop() - def eval(self, expression, use_compilation_plan=False, es6=False): + def eval(self, expression, use_compilation_plan=False, es6=False, es9=False): """evaluates expression in current context and returns its value""" - expression = _prepare_js_source(expression, es6) + expression = _prepare_js_source(expression, es6=es6, es9=es9) code = 'PyJsEvalResult = eval(%s)' % json.dumps(expression) - self.execute(code, use_compilation_plan=use_compilation_plan) + self.execute(code, use_compilation_plan=use_compilation_plan, + es6=es6, es9=es9) return self['PyJsEvalResult'] def execute_debug(self, js): diff --git a/js2py/event_loop.py b/js2py/event_loop.py new file mode 100644 index 00000000..26a9aacb --- /dev/null +++ b/js2py/event_loop.py @@ -0,0 +1,93 @@ +"""JavaScript-style event loop: microtasks (Promises) and macrotasks (timers).""" + +import heapq +import time + +_microtasks = [] +_macrotasks = [] +_macrotask_seq = 0 +_timers = {} +_next_timer_id = 1 +_MAX_DRAIN_ITERATIONS = 100000 + + +def queue_microtask(callback): + """Schedule a microtask (Promise reaction, etc.).""" + _microtasks.append(callback) + + +def _schedule_macrotask(callback, delay_ms): + due = time.monotonic() + max(0.0, float(delay_ms)) / 1000.0 + global _macrotask_seq + _macrotask_seq += 1 + heapq.heappush(_macrotasks, (due, _macrotask_seq, callback)) + + +def schedule_timer(delay_ms, callback, repeat_ms=None): + """Schedule setTimeout/setInterval; returns timer handle id.""" + global _next_timer_id + timer_id = _next_timer_id + _next_timer_id += 1 + + _timers[timer_id] = { + 'cancelled': False, + 'repeat_ms': repeat_ms, + } + + def fire(): + info = _timers.get(timer_id) + if not info or info['cancelled']: + return + callback() + if info['cancelled']: + return + repeat = info.get('repeat_ms') + if repeat is not None: + _schedule_macrotask(fire, repeat) + + _schedule_macrotask(fire, delay_ms) + return timer_id + + +def clear_timer(timer_id): + info = _timers.pop(int(timer_id), None) + if info is not None: + info['cancelled'] = True + + +def drain_event_loop(timeout=None): + """Run microtasks and due macrotasks until idle or timeout (seconds).""" + deadline = None if timeout is None else time.monotonic() + timeout + iterations = 0 + while iterations < _MAX_DRAIN_ITERATIONS: + iterations += 1 + if _microtasks: + batch = _microtasks[:] + _microtasks.clear() + for callback in batch: + callback() + continue + if not _macrotasks: + return + now = time.monotonic() + if _macrotasks[0][0] > now: + if deadline is not None and now >= deadline: + return + wait = _macrotasks[0][0] - now + if deadline is not None: + wait = min(wait, max(0.0, deadline - now)) + if wait > 0: + time.sleep(wait) + continue + _, _, callback = heapq.heappop(_macrotasks) + callback() + if deadline is not None and time.monotonic() >= deadline: + return + raise RuntimeError('Event loop drain exceeded maximum iterations') + + +def reset_event_loop(): + """Clear all queued tasks (mainly for tests).""" + _microtasks[:] = [] + _macrotasks[:] = [] + _timers.clear() diff --git a/js2py/pyjs.py b/js2py/pyjs.py index 98e106a0..c7151946 100644 --- a/js2py/pyjs.py +++ b/js2py/pyjs.py @@ -8,6 +8,7 @@ from .constructors.jsboolean import Boolean from .constructors.jsregexp import RegExp from .constructors.jsarray import Array +from .constructors.jspromise import Promise from .constructors.jsarraybuffer import ArrayBuffer from .constructors.jsint8array import Int8Array from .constructors.jsuint8array import Uint8Array @@ -50,6 +51,7 @@ 'Object', 'Function', 'Array', + 'Promise', 'Int8Array', 'Uint8Array', 'Uint8ClampedArray', diff --git a/js2py/translators/translator.py b/js2py/translators/translator.py index dfa2d52d..1c280fd2 100644 --- a/js2py/translators/translator.py +++ b/js2py/translators/translator.py @@ -11,6 +11,12 @@ js6_to_js5 = None looks_like_es6 = None +try: + from ..es9 import looks_like_es9, prepare_es9 +except ImportError: + looks_like_es9 = None + prepare_es9 = None + # Enable Js2Py exceptions and pyimport in parser pyjsparser.parser.ENABLE_PYIMPORT = True @@ -67,13 +73,20 @@ def pyjsparser_parse_fn(code): parser = pyjsparser.PyJsParser() return parser.parse(code) -def _prepare_js_source(js, es6=False): - """Optionally downlevel ES6 source to ES5 before translation.""" +def _prepare_js_source(js, es6=False, es9=False): + """Optionally downlevel ES6/ES9 source before translation.""" + if es9 == 'auto': + if looks_like_es9 and looks_like_es9(js): + es9 = True + else: + es9 = False if es6 == 'auto': if looks_like_es6 and looks_like_es6(js): es6 = True else: es6 = False + if es9 and prepare_es9: + js = prepare_es9(js) if es6: if js6_to_js5 is None: raise RuntimeError('ES6 support is not available') @@ -82,13 +95,14 @@ def _prepare_js_source(js, es6=False): def translate_js(js, HEADER=DEFAULT_HEADER, use_compilation_plan=False, - parse_fn=pyjsparser_parse_fn, es6=False): + parse_fn=pyjsparser_parse_fn, es6=False, es9=False): """js has to be a javascript source code. returns equivalent python code. es6: False (ES5 only), True (always transpile via Babel), or 'auto' - (transpile when ES6 syntax is detected).""" - js = _prepare_js_source(js, es6) + (transpile when ES6 syntax is detected). + es9: False, True, or 'auto' — enable ES2018 features (spread/rest, etc.).""" + js = _prepare_js_source(js, es6=es6, es9=es9) if use_compilation_plan and not '//' in js and not '/*' in js: return translate_js_with_compilation_plan(js, HEADER=HEADER) diff --git a/tests/test_es9.py b/tests/test_es9.py new file mode 100644 index 00000000..8e4d5a7b --- /dev/null +++ b/tests/test_es9.py @@ -0,0 +1,113 @@ +"""Tests for ES9 (ES2018) JavaScript support.""" +import js2py +from js2py.es9 import looks_like_es9, prepare_es9 +from js2py.event_loop import reset_event_loop + + +def test_looks_like_es9(): + assert looks_like_es9('var x = {...a};') + assert looks_like_es9('Promise.resolve(1).finally(function() {})') + assert looks_like_es9('Object.fromEntries([])') + assert not looks_like_es9('var a = 1; Object.keys({})') + + +def test_prepare_es9_object_spread(): + out = prepare_es9('var x = {...a, b: 2};') + assert '__o' in out + assert 'b = 2' in out or 'b=2' in out.replace(' ', '') + + +def test_object_assign(): + ctx = js2py.EvalJs() + ctx.execute('var a = {x: 1}; var b = {y: 2}; var c = Object.assign({}, a, b);') + assert ctx.c.x == 1 + assert ctx.c.y == 2 + + +def test_object_spread_literal(): + ctx = js2py.EvalJs({'src': {'a': 1, 'b': 2}}) + ctx.execute('var merged = {...src, c: 3};', es9=True) + assert ctx.merged.a == 1 + assert ctx.merged.b == 2 + assert ctx.merged.c == 3 + + +def test_object_spread_auto(): + ctx = js2py.EvalJs({'base': {'x': 9}}) + ctx.execute('var out = {...base, y: 1};', es9='auto') + assert ctx.out.x == 9 + assert ctx.out.y == 1 + + +def test_object_rest_destructuring(): + ctx = js2py.EvalJs() + ctx.execute( + 'var obj = {a: 1, b: 2, c: 3}; var {a, ...rest} = obj;', es9=True) + assert ctx.a == 1 + assert ctx.rest.b == 2 + assert ctx.rest.c == 3 + assert ctx.rest.a is None or str(getattr(ctx.rest, 'a', None)) in ('undefined', 'None') + + +def test_object_from_entries(): + ctx = js2py.EvalJs() + ctx.execute( + 'var o = Object.fromEntries([["a", 1], ["b", 2]]);', es9=True) + assert ctx.o.a == 1 + assert ctx.o.b == 2 + + +def test_object_entries_roundtrip(): + ctx = js2py.EvalJs() + ctx.execute( + 'var src = {p: 4, q: 5}; var copy = Object.fromEntries(Object.entries(src));', + es9=True) + assert ctx.copy.p == 4 + assert ctx.copy.q == 5 + + +def test_promise_finally_fulfilled(): + reset_event_loop() + ctx = js2py.EvalJs() + ctx.execute(''' + var log = []; + Promise.resolve(10).finally(function() { log.push("done"); }).then(function(v) { + log.push(v); + }); + ''', es9=True) + assert list(ctx.log) == ['done', 10] + + +def test_promise_finally_rejected(): + reset_event_loop() + ctx = js2py.EvalJs() + ctx.execute(''' + var log = []; + Promise.reject("err").finally(function() { log.push("cleanup"); }).catch(function(e) { + log.push(e); + }); + ''', es9=True) + assert list(ctx.log) == ['cleanup', 'err'] + + +def test_eval_js9(): + assert js2py.eval_js9('Object.assign({a:1}, {b:2}).b') == 2 + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) From 1de749ffe0171ae6f7a96da9f393dbd24ce1c98e Mon Sep 17 00:00:00 2001 From: Gilead Cosman Date: Wed, 24 Jun 2026 16:30:05 -0700 Subject: [PATCH 04/11] Added ES10 support --- Makefile | 8 +++- js2py/__init__.py | 3 +- js2py/es10/__init__.py | 41 ++++++++++++++++ js2py/evaljs.py | 37 +++++++++++---- js2py/prototypes/jsarray.py | 32 +++++++++++++ js2py/prototypes/jsstring.py | 22 +++++++++ js2py/translators/translator.py | 25 ++++++++-- tests/test_es10.py | 83 +++++++++++++++++++++++++++++++++ 8 files changed, 233 insertions(+), 18 deletions(-) create mode 100644 js2py/es10/__init__.py create mode 100644 tests/test_es10.py diff --git a/Makefile b/Makefile index aded56ab..105651c2 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ ROOT := $(CURDIR) export PYTHONPATH := $(ROOT) -.PHONY: help test test-simple test-es6 test-es9 test-language test-all +.PHONY: help test test-simple test-es6 test-es9 test-es10 test-language test-all help: @echo "Js2Py test targets:" @@ -12,10 +12,11 @@ help: @echo " make test-simple Run simple_test.py (ES5 + ES6 smoke tests)" @echo " make test-es6 Run tests/test_es6.py" @echo " make test-es9 Run tests/test_es9.py" + @echo " make test-es10 Run tests/test_es10.py" @echo " make test-language Run ES5.1 language suite (tests/run.py, slow)" @echo " make test-all Run quick tests and the language suite" -test: test-simple test-es6 test-es9 +test: test-simple test-es6 test-es9 test-es10 @: test-simple: @@ -27,6 +28,9 @@ test-es6: test-es9: PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es9.py" +test-es10: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es10.py" + test-language: @test -f "$(ROOT)/tests/node_failed.txt" || touch "$(ROOT)/tests/node_failed.txt" cd "$(ROOT)/tests" && PYTHONPATH="$(ROOT)" $(PYTHON) run.py diff --git a/js2py/__init__.py b/js2py/__init__.py index a6603d46..33ef027b 100644 --- a/js2py/__init__.py +++ b/js2py/__init__.py @@ -65,7 +65,8 @@ __all__ = [ 'EvalJs', 'translate_js', 'import_js', 'eval_js', 'parse_js', 'translate_file', 'run_file', 'disable_pyimport', 'eval_js6', - 'translate_js6', 'eval_js9', 'translate_js9', 'drain_event_loop', + 'translate_js6', 'eval_js9', 'translate_js9', 'eval_js10', 'translate_js10', + 'drain_event_loop', 'PyJsException', 'get_file_contents', 'write_file_contents', 'require' ] diff --git a/js2py/es10/__init__.py b/js2py/es10/__init__.py new file mode 100644 index 00000000..831a272c --- /dev/null +++ b/js2py/es10/__init__.py @@ -0,0 +1,41 @@ +"""ES10 (ES2019) support: optional catch binding and syntax detection.""" + +import re + +CP_STRING = ( + '"([^\\\\"]+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|x[0-9a-fA-F]{2}|' + 'u[0-9a-fA-F]{4}))*"|\'([^\\\\\']+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|' + 'x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}))*\'') +CP_STRING_PLACEHOLDER = '__PyJsSTR_%i_PyJsSTR__' + +_ES10_SYNTAX_RE = re.compile( + r'(?:' + r'\.flat\s*\(|' + r'\.flatMap\s*\(|' + r'\.trimStart\s*\(|' + r'\.trimEnd\s*\(|' + r'\bcatch\s*\{' + r')', + re.MULTILINE) + +_OPTIONAL_CATCH_RE = re.compile(r'\bcatch\s*\{') + + +def looks_like_es10(code): + """Return True if source likely contains ES10 syntax or APIs.""" + return bool(_ES10_SYNTAX_RE.search(code)) + + +def prepare_es10(code): + """Apply ES2019 source transforms before translation.""" + matches = [] + + def mask(match): + matches.append(match.group(0)) + return CP_STRING_PLACEHOLDER % (len(matches) - 1) + + masked = re.sub(CP_STRING, mask, code) + masked = _OPTIONAL_CATCH_RE.sub('catch (__PyJsOptionalCatch) {', masked) + for index, value in enumerate(matches): + masked = masked.replace(CP_STRING_PLACEHOLDER % index, value, 1) + return masked diff --git a/js2py/evaljs.py b/js2py/evaljs.py index 00360aa2..e1e57a9f 100644 --- a/js2py/evaljs.py +++ b/js2py/evaljs.py @@ -13,6 +13,7 @@ __all__ = [ 'EvalJs', 'translate_js', 'import_js', 'eval_js', 'translate_file', 'eval_js6', 'translate_js6', 'eval_js9', 'translate_js9', + 'eval_js10', 'translate_js10', 'run_file', 'disable_pyimport', 'drain_event_loop', 'get_file_contents', 'write_file_contents' ] @@ -59,13 +60,14 @@ def write_file_contents(path_or_file, contents): f.write(contents) -def translate_file(input_path, output_path, es6=False, es9=False): +def translate_file(input_path, output_path, es6=False, es9=False, es10=False): ''' Translates input JS file to python and saves the it to the output path. It appends some convenience code at the end so that it is easy to import JS objects. es6: False, True, or 'auto' — transpile ES6 via Babel before translation. es9: False, True, or 'auto' — enable ES2018 features (object spread/rest, etc.). + es10: False, True, or 'auto' — enable ES2019 features (flat, trimStart, etc.). For example we have a file 'example.js' with: var a = function(x) {return x} translate_file('example.js', 'example.py') @@ -77,7 +79,7 @@ def translate_file(input_path, output_path, es6=False, es9=False): ''' js = get_file_contents(input_path) - py_code = translate_js(js, es6=es6, es9=es9) + py_code = translate_js(js, es6=es6, es9=es9, es10=es10) lib_name = os.path.basename(output_path).split('.')[0] head = '__all__ = [%s]\n\n# Don\'t look below, you will not understand this Python code :) I don\'t.\n\n' % repr( lib_name) @@ -97,13 +99,14 @@ def run_file(path_or_file, context=None): return eval_value, context -def eval_js(js, es6=False, es9=False): +def eval_js(js, es6=False, es9=False, es10=False): """Just like javascript eval. Translates javascript to python, executes and returns python object. js is javascript source code es6: False, True, or 'auto' — see translate_js. es9: False, True, or 'auto' — enable ES2018 features. + es10: False, True, or 'auto' — enable ES2019 features. EXAMPLE: >>> import js2py @@ -120,11 +123,21 @@ def eval_js(js, es6=False, es9=False): If you really want to convert object to python dict you can use to_dict method. """ e = EvalJs() - result = e.eval(js, es6=es6, es9=es9) + result = e.eval(js, es6=es6, es9=es9, es10=es10) drain_event_loop() return result +def eval_js10(js): + """Like eval_js with ES2019 support enabled.""" + return eval_js(js, es10=True) + + +def translate_js10(js): + """Like translate_js with ES2019 support enabled.""" + return translate_js(js, es10=True) + + def eval_js9(js): """Like eval_js with ES2018 support enabled.""" return eval_js(js, es9=True) @@ -191,11 +204,13 @@ def _js_require_impl(npm_module_name): for k, v in six.iteritems(context): setattr(self._var, k, v) - def execute(self, js=None, use_compilation_plan=False, es6=False, es9=False): + def execute(self, js=None, use_compilation_plan=False, es6=False, es9=False, + es10=False): """executes javascript js in current context es6: False, True, or 'auto' — transpile ES6 via Babel before translation. es9: False, True, or 'auto' — enable ES2018 features. + es10: False, True, or 'auto' — enable ES2019 features. During initial execute() the converted js is cached for re-use. That means next time you run the same javascript snippet you save many instructions needed to parse and convert the @@ -211,24 +226,26 @@ def execute(self, js=None, use_compilation_plan=False, es6=False, es9=False): cache = self.__dict__['cache'] except KeyError: cache = self.__dict__['cache'] = {} - cache_key = (hashlib.md5(js.encode('utf-8')).digest(), es6, es9) + cache_key = (hashlib.md5(js.encode('utf-8')).digest(), es6, es9, es10) try: compiled = cache[cache_key] except KeyError: code = translate_js( js, '', use_compilation_plan=use_compilation_plan, - es6=es6, es9=es9) + es6=es6, es9=es9, es10=es10) compiled = cache[cache_key] = compile(code, '', 'exec') exec (compiled, self._context) drain_event_loop() - def eval(self, expression, use_compilation_plan=False, es6=False, es9=False): + def eval(self, expression, use_compilation_plan=False, es6=False, es9=False, + es10=False): """evaluates expression in current context and returns its value""" - expression = _prepare_js_source(expression, es6=es6, es9=es9) + expression = _prepare_js_source( + expression, es6=es6, es9=es9, es10=es10) code = 'PyJsEvalResult = eval(%s)' % json.dumps(expression) self.execute(code, use_compilation_plan=use_compilation_plan, - es6=es6, es9=es9) + es6=es6, es9=es9, es10=es10) return self['PyJsEvalResult'] def execute_debug(self, js): diff --git a/js2py/prototypes/jsarray.py b/js2py/prototypes/jsarray.py index d02e62b2..6720dde8 100644 --- a/js2py/prototypes/jsarray.py +++ b/js2py/prototypes/jsarray.py @@ -447,6 +447,38 @@ def reduceRight(callbackfn): k -= 1 return accumulator + def flat(depth): + array = this.to_object() + depth_num = 1 if depth.is_undefined() else depth.to_int() + if depth_num < 0: + depth_num = 0 + result = this.Js([]) + n = [0] + + def flatten(source, current_depth): + length = source.get('length').to_uint32() + for i in xrange(length): + key = str(i) + if not source.has_property(key): + continue + element = source.get(key) + if current_depth > 0 and element.Class == 'Array': + flatten(element, current_depth - 1) + else: + result.put(str(n[0]), element) + n[0] += 1 + + flatten(array, depth_num) + result.put('length', this.Js(n[0])) + return result + + def flatMap(callbackfn): + if not callbackfn.is_callable(): + raise this.MakeError('TypeError', 'callbackfn must be a function') + mapped = this.callprop('map', callbackfn, + arguments[1] if len(arguments) > 1 else this.undefined) + return mapped.callprop('flat', this.Js(1)) + def sort_compare(a, b, comp): if a is None: diff --git a/js2py/prototypes/jsstring.py b/js2py/prototypes/jsstring.py index a313bfb9..199263df 100644 --- a/js2py/prototypes/jsstring.py +++ b/js2py/prototypes/jsstring.py @@ -299,6 +299,28 @@ def trim(): this.cok() return this.Js(this.to_string().value.strip(WHITE)) + def trimStart(): + this.cok() + s = this.to_string().value + i = 0 + while i < len(s) and s[i] in WHITE: + i += 1 + return this.Js(s[i:]) + + def trimEnd(): + this.cok() + s = this.to_string().value + i = len(s) + while i > 0 and s[i - 1] in WHITE: + i -= 1 + return this.Js(s[:i]) + + def trimLeft(): + return this.callprop('trimStart') + + def trimRight(): + return this.callprop('trimEnd') + def SplitMatch(s, q, R): # s is Py String to match, q is the py int match start and R is Js RegExp or String. diff --git a/js2py/translators/translator.py b/js2py/translators/translator.py index 1c280fd2..7996c210 100644 --- a/js2py/translators/translator.py +++ b/js2py/translators/translator.py @@ -17,6 +17,12 @@ looks_like_es9 = None prepare_es9 = None +try: + from ..es10 import looks_like_es10, prepare_es10 +except ImportError: + looks_like_es10 = None + prepare_es10 = None + # Enable Js2Py exceptions and pyimport in parser pyjsparser.parser.ENABLE_PYIMPORT = True @@ -73,8 +79,13 @@ def pyjsparser_parse_fn(code): parser = pyjsparser.PyJsParser() return parser.parse(code) -def _prepare_js_source(js, es6=False, es9=False): - """Optionally downlevel ES6/ES9 source before translation.""" +def _prepare_js_source(js, es6=False, es9=False, es10=False): + """Optionally downlevel ES6/ES9/ES10 source before translation.""" + if es10 == 'auto': + if looks_like_es10 and looks_like_es10(js): + es10 = True + else: + es10 = False if es9 == 'auto': if looks_like_es9 and looks_like_es9(js): es9 = True @@ -85,6 +96,8 @@ def _prepare_js_source(js, es6=False, es9=False): es6 = True else: es6 = False + if es10 and prepare_es10: + js = prepare_es10(js) if es9 and prepare_es9: js = prepare_es9(js) if es6: @@ -95,14 +108,16 @@ def _prepare_js_source(js, es6=False, es9=False): def translate_js(js, HEADER=DEFAULT_HEADER, use_compilation_plan=False, - parse_fn=pyjsparser_parse_fn, es6=False, es9=False): + parse_fn=pyjsparser_parse_fn, es6=False, es9=False, + es10=False): """js has to be a javascript source code. returns equivalent python code. es6: False (ES5 only), True (always transpile via Babel), or 'auto' (transpile when ES6 syntax is detected). - es9: False, True, or 'auto' — enable ES2018 features (spread/rest, etc.).""" - js = _prepare_js_source(js, es6=es6, es9=es9) + es9: False, True, or 'auto' — enable ES2018 features (spread/rest, etc.). + es10: False, True, or 'auto' — enable ES2019 features (flat, trimStart, etc.).""" + js = _prepare_js_source(js, es6=es6, es9=es9, es10=es10) if use_compilation_plan and not '//' in js and not '/*' in js: return translate_js_with_compilation_plan(js, HEADER=HEADER) diff --git a/tests/test_es10.py b/tests/test_es10.py new file mode 100644 index 00000000..ce1b927b --- /dev/null +++ b/tests/test_es10.py @@ -0,0 +1,83 @@ +"""Tests for ES10 (ES2019) JavaScript support.""" +import js2py +from js2py.es10 import looks_like_es10, prepare_es10 + + +def test_looks_like_es10(): + assert looks_like_es10('[1, [2]].flat()') + assert looks_like_es10('" x".trimStart()') + assert looks_like_es10('try {} catch {}') + assert not looks_like_es10('var a = [1, 2]; a.join()') + + +def test_prepare_es10_optional_catch(): + out = prepare_es10('try { x(); } catch { y = 1; }') + assert '__PyJsOptionalCatch' in out + + +def test_array_flat(): + ctx = js2py.EvalJs() + ctx.execute('var a = [1, [2, 3], [[4]]]; var b = a.flat();', es10=True) + assert list(ctx.b) == [1, 2, 3, [4]] + + +def test_array_flat_depth(): + ctx = js2py.EvalJs() + ctx.execute('var a = [1, [2, [3]]]; var b = a.flat(2);', es10=True) + assert list(ctx.b) == [1, 2, 3] + + +def test_array_flat_map(): + ctx = js2py.EvalJs() + ctx.execute( + 'var a = [1, 2, 3]; var b = a.flatMap(function(x) { return [x, x * 10]; });', + es10=True) + assert list(ctx.b) == [1, 10, 2, 20, 3, 30] + + +def test_array_flat_auto(): + ctx = js2py.EvalJs() + ctx.execute('var out = [0, [1]].flat();', es10='auto') + assert list(ctx.out) == [0, 1] + + +def test_string_trim_start(): + assert js2py.eval_js('" hello".trimStart()', es10=True) == 'hello' + assert js2py.eval_js('" hello".trimLeft()', es10=True) == 'hello' + + +def test_string_trim_end(): + assert js2py.eval_js('"hello ".trimEnd()', es10=True) == 'hello' + assert js2py.eval_js('"hello ".trimRight()', es10=True) == 'hello' + + +def test_optional_catch_binding(): + ctx = js2py.EvalJs() + ctx.execute(''' + var ok = false; + try { throw "boom"; } catch { ok = true; } + ''', es10=True) + assert ctx.ok is True + + +def test_eval_js10(): + assert js2py.eval_js10('[1, [2]].flat().length') == 2 + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) From 417eb7801976d7077338eba4b1de5e9264908b28 Mon Sep 17 00:00:00 2001 From: Gilead Cosman Date: Wed, 24 Jun 2026 20:56:58 -0700 Subject: [PATCH 05/11] Added ES11 support --- Makefile | 8 +- js2py/__init__.py | 2 +- js2py/constructors/jspromise.py | 53 +++++ js2py/es11/__init__.py | 349 ++++++++++++++++++++++++++++++++ js2py/evaljs.py | 38 ++-- js2py/pyjs.py | 2 + js2py/translators/translator.py | 24 ++- tests/test_es11.py | 99 +++++++++ 8 files changed, 555 insertions(+), 20 deletions(-) create mode 100644 js2py/es11/__init__.py create mode 100644 tests/test_es11.py diff --git a/Makefile b/Makefile index 105651c2..220e4eee 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ ROOT := $(CURDIR) export PYTHONPATH := $(ROOT) -.PHONY: help test test-simple test-es6 test-es9 test-es10 test-language test-all +.PHONY: help test test-simple test-es6 test-es9 test-es10 test-es11 test-language test-all help: @echo "Js2Py test targets:" @@ -13,10 +13,11 @@ help: @echo " make test-es6 Run tests/test_es6.py" @echo " make test-es9 Run tests/test_es9.py" @echo " make test-es10 Run tests/test_es10.py" + @echo " make test-es11 Run tests/test_es11.py" @echo " make test-language Run ES5.1 language suite (tests/run.py, slow)" @echo " make test-all Run quick tests and the language suite" -test: test-simple test-es6 test-es9 test-es10 +test: test-simple test-es6 test-es9 test-es10 test-es11 @: test-simple: @@ -31,6 +32,9 @@ test-es9: test-es10: PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es10.py" +test-es11: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es11.py" + test-language: @test -f "$(ROOT)/tests/node_failed.txt" || touch "$(ROOT)/tests/node_failed.txt" cd "$(ROOT)/tests" && PYTHONPATH="$(ROOT)" $(PYTHON) run.py diff --git a/js2py/__init__.py b/js2py/__init__.py index 33ef027b..6a0fcdcf 100644 --- a/js2py/__init__.py +++ b/js2py/__init__.py @@ -66,7 +66,7 @@ 'EvalJs', 'translate_js', 'import_js', 'eval_js', 'parse_js', 'translate_file', 'run_file', 'disable_pyimport', 'eval_js6', 'translate_js6', 'eval_js9', 'translate_js9', 'eval_js10', 'translate_js10', - 'drain_event_loop', + 'eval_js11', 'translate_js11', 'drain_event_loop', 'PyJsException', 'get_file_contents', 'write_file_contents', 'require' ] diff --git a/js2py/constructors/jspromise.py b/js2py/constructors/jspromise.py index 50b1715b..a1e78884 100644 --- a/js2py/constructors/jspromise.py +++ b/js2py/constructors/jspromise.py @@ -221,6 +221,53 @@ def promise_reject(reason): return promise +@Js +def promise_all_settled(iterable): + arr = iterable.to_object() + length = arr.get('length').to_uint32() + result_promise = _create_promise(None) + if length == 0: + _resolve_promise(result_promise, []) + return result_promise + results = [undefined] * length + remaining = {'count': length} + + def settle(index, status, payload): + entry = PyJsObject(prototype=ObjectPrototype) + entry.put('status', Js(status)) + if status == 'fulfilled': + entry.put('value', payload) + else: + entry.put('reason', payload) + results[index] = entry + remaining['count'] -= 1 + if remaining['count'] == 0: + out = Js([]) + for i, item in enumerate(results): + out.put(str(i), item) + _resolve_promise(result_promise, out) + + for i in range(length): + p = promise_resolve(arr.get(str(i))) + + def make_handlers(idx): + @Js + def on_fulfilled(value): + settle(idx, 'fulfilled', value) + return undefined + + @Js + def on_rejected(reason): + settle(idx, 'rejected', reason) + return undefined + + return on_fulfilled, on_rejected + + on_fulfilled, on_rejected = make_handlers(i) + p.callprop('then', on_fulfilled, on_rejected) + return result_promise + + Promise.define_own_property('resolve', { 'value': promise_resolve, 'writable': True, @@ -233,6 +280,12 @@ def promise_reject(reason): 'enumerable': False, 'configurable': True }) +Promise.define_own_property('allSettled', { + 'value': promise_all_settled, + 'writable': True, + 'enumerable': False, + 'configurable': True +}) Promise.define_own_property( 'prototype', { 'value': PromisePrototype, diff --git a/js2py/es11/__init__.py b/js2py/es11/__init__.py new file mode 100644 index 00000000..642f1e24 --- /dev/null +++ b/js2py/es11/__init__.py @@ -0,0 +1,349 @@ +"""ES11 (ES2020) support: optional chaining, nullish coalescing, globalThis.""" + +import re + +CP_STRING = ( + '"([^\\\\"]+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|x[0-9a-fA-F]{2}|' + 'u[0-9a-fA-F]{4}))*"|\'([^\\\\\']+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|' + 'x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}))*\'') +CP_STRING_PLACEHOLDER = '__PyJsSTR_%i_PyJsSTR__' + +_ES11_SYNTAX_RE = re.compile( + r'(?:' + r'\?\?|' + r'\?\.|' + r'\bglobalThis\b|' + r'\bPromise\.allSettled\s*\(' + r')', + re.MULTILINE) + +_IDENT_RE = re.compile(r'[\w$]+') + + +def looks_like_es11(code): + """Return True if source likely contains ES11 syntax or APIs.""" + return bool(_ES11_SYNTAX_RE.search(code)) + + +def prepare_es11(code): + """Apply ES2020 source transforms before translation.""" + matches = [] + + def mask(match): + matches.append(match.group(0)) + return CP_STRING_PLACEHOLDER % (len(matches) - 1) + + masked = re.sub(CP_STRING, mask, code) + masked = _transform_nullish_coalescing(masked) + masked = _transform_optional_chaining(masked) + for index, value in enumerate(matches): + masked = masked.replace(CP_STRING_PLACEHOLDER % index, value, 1) + return masked + + +def _is_ident_char(ch): + return ch.isalnum() or ch in ('_', '$') + + +def _skip_ws(code, i, direction=1): + if direction >= 0: + while i < len(code) and code[i].isspace(): + i += 1 + return i + while i >= 0 and code[i].isspace(): + i -= 1 + return i + + +def _find_matching(code, start, open_ch, close_ch): + depth = 0 + in_str = None + i = start + while i < len(code): + ch = code[i] + if in_str: + if ch == '\\': + i += 2 + continue + if ch == in_str: + in_str = None + elif ch in ('"', "'"): + in_str = ch + elif ch == open_ch: + depth += 1 + elif ch == close_ch: + depth -= 1 + if depth == 0: + return i + i += 1 + return None + + +def _parse_optional_access(code, pos): + pos = _skip_ws(code, pos, 1) + if pos >= len(code): + return None, pos + if code[pos] == '[': + end = _find_matching(code, pos, '[', ']') + if end is None: + return None, pos + return code[pos:end + 1], end + 1 + if code[pos] == '(': + end = _find_matching(code, pos, '(', ')') + if end is None: + return None, pos + return code[pos:end + 1], end + 1 + m = _IDENT_RE.match(code, pos) + if m: + return '.' + m.group(0), m.end() + return None, pos + + +def _parse_member_access(code, pos): + pos = _skip_ws(code, pos, 1) + if pos >= len(code): + return None, pos + if code[pos] == '.': + pos += 1 + m = _IDENT_RE.match(code, pos) + if not m: + return None, pos + return '.' + m.group(0), m.end() + if code[pos] == '[': + end = _find_matching(code, pos, '[', ']') + if end is None: + return None, pos + return code[pos:end + 1], end + 1 + if code[pos] == '(': + end = _find_matching(code, pos, '(', ')') + if end is None: + return None, pos + return code[pos:end + 1], end + 1 + return None, pos + + +def _parse_base_backwards(code, end): + end = _skip_ws(code, end - 1, -1) + 1 + if end <= 0: + return None, None + i = end - 1 + if code[i] == ')': + start = _find_matching(code, i, '(', ')') + if start is None: + return None, None + return start, end + if code[i] == ']': + start = _find_matching(code, i, '[', ']') + if start is None: + return None, None + return start, end + if _is_ident_char(code[i]): + while i >= 0 and _is_ident_char(code[i]): + i -= 1 + if i >= 0 and code[i] == '.': + while i >= 0: + if _is_ident_char(code[i]) or code[i] == '.': + i -= 1 + elif code[i] == ')': + start = _find_matching(code, i, '(', ')') + if start is None: + break + i = start - 1 + else: + break + return i + 1, end + return None, None + + +def _parse_optional_chain(code, qmark_pos): + if code[qmark_pos:qmark_pos + 2] != '?.': + return None + base_start, base_end = _parse_base_backwards(code, qmark_pos) + if base_start is None: + return None + base = code[base_start:base_end].strip() + pos = qmark_pos + 2 + segments = [] + access, pos = _parse_optional_access(code, pos) + if access is None: + return None + segments.append(('opt', access)) + while True: + pos = _skip_ws(code, pos, 1) + if pos >= len(code): + break + if code[pos:pos + 2] == '?.': + pos += 2 + access, pos = _parse_optional_access(code, pos) + if access is None: + break + segments.append(('opt', access)) + continue + if code[pos] == '.': + access, pos = _parse_member_access(code, pos) + if access is None: + break + segments.append(('req', access)) + continue + break + chain_end = pos + return base_start, chain_end, base, segments + + +def _apply_access(var, access): + if access.startswith('.'): + return '%s%s' % (var, access) + if access.startswith('['): + return '%s%s' % (var, access) + if access.startswith('('): + return '%s%s' % (var, access) + return var + + +def _emit_optional_chain(base, segments): + lines = ['var _r = %s;' % base] + for kind, access in segments: + if kind == 'opt': + lines.append('if (_r == null) return undefined;') + if access.startswith('('): + lines.append('_r = _r%s;' % access) + else: + lines.append('_r = _r%s;' % access) + else: + lines.append('_r = _r%s;' % access) + lines.append('return _r;') + body = ' '.join(lines) + return '(function() { %s })()' % body + + +def _transform_optional_chaining(code): + changed = True + while changed: + changed = False + idx = 0 + while idx < len(code) - 1: + if code[idx:idx + 2] != '?.': + idx += 1 + continue + parsed = _parse_optional_chain(code, idx) + if parsed is None: + idx += 1 + continue + start, end, base, segments = parsed + replacement = _emit_optional_chain(base, segments) + code = code[:start] + replacement + code[end:] + changed = True + idx = start + len(replacement) + return code + + +def _scan_nullish_operand(code, start, direction): + """Scan one nullish-coalescing operand; start is first char of operand.""" + if direction < 0: + i = _skip_ws(code, start, -1) + end = i + 1 + paren = bracket = 0 + in_str = None + while i >= 0: + ch = code[i] + if in_str: + if ch == '\\': + i -= 1 + continue + if ch == in_str: + in_str = None + i -= 1 + continue + if ch in ('"', "'"): + in_str = ch + i -= 1 + continue + if paren == 0 and bracket == 0: + if i > 0 and code[i - 1:i + 1] == '??': + break + if code[i:i + 2] == '||' or code[i:i + 2] == '&&': + break + if ch in ',;:{}': + break + if ch == ')': + paren += 1 + elif ch == '(': + paren -= 1 + if paren < 0: + break + elif ch == ']': + bracket += 1 + elif ch == '[': + bracket -= 1 + if bracket < 0: + break + i -= 1 + start_idx = i + 1 + while start_idx < end and code[start_idx].isspace(): + start_idx += 1 + return start_idx, end + i = _skip_ws(code, start, 1) + begin = i + paren = bracket = 0 + in_str = None + while i < len(code): + ch = code[i] + if in_str: + if ch == '\\': + i += 2 + continue + if ch == in_str: + in_str = None + i += 1 + continue + if ch in ('"', "'"): + in_str = ch + i += 1 + continue + if paren == 0 and bracket == 0: + if code[i:i + 2] == '??': + break + if code[i:i + 2] == '||' or code[i:i + 2] == '&&': + break + if ch in ',;:{}': + break + if ch == ')': + break + if ch == '(': + paren += 1 + elif ch == ')': + paren -= 1 + elif ch == '[': + bracket += 1 + elif ch == ']': + bracket -= 1 + i += 1 + end = i + while end > begin and code[end - 1].isspace(): + end -= 1 + return begin, end + + +def _parse_nullish_operand(code, op_pos, side): + if side == 'left': + return _scan_nullish_operand(code, op_pos - 1, -1) + return _scan_nullish_operand(code, op_pos + 2, 1) + + +def _transform_nullish_coalescing(code): + while True: + op_pos = -1 + for i in range(len(code) - 1): + if code[i:i + 2] == '??': + op_pos = i + break + if op_pos < 0: + break + left_start, left_end = _parse_nullish_operand(code, op_pos, 'left') + right_start, right_end = _parse_nullish_operand(code, op_pos, 'right') + left = code[left_start:left_end].strip() + right = code[right_start:right_end].strip() + replacement = ( + '(%s !== null && %s !== undefined ? %s : %s)' % (left, left, left, right)) + code = code[:left_start] + replacement + code[right_end:] + return code diff --git a/js2py/evaljs.py b/js2py/evaljs.py index e1e57a9f..38eb4d5c 100644 --- a/js2py/evaljs.py +++ b/js2py/evaljs.py @@ -13,7 +13,7 @@ __all__ = [ 'EvalJs', 'translate_js', 'import_js', 'eval_js', 'translate_file', 'eval_js6', 'translate_js6', 'eval_js9', 'translate_js9', - 'eval_js10', 'translate_js10', + 'eval_js10', 'translate_js10', 'eval_js11', 'translate_js11', 'run_file', 'disable_pyimport', 'drain_event_loop', 'get_file_contents', 'write_file_contents' ] @@ -60,7 +60,8 @@ def write_file_contents(path_or_file, contents): f.write(contents) -def translate_file(input_path, output_path, es6=False, es9=False, es10=False): +def translate_file(input_path, output_path, es6=False, es9=False, es10=False, + es11=False): ''' Translates input JS file to python and saves the it to the output path. It appends some convenience code at the end so that it is easy to import JS objects. @@ -68,6 +69,7 @@ def translate_file(input_path, output_path, es6=False, es9=False, es10=False): es6: False, True, or 'auto' — transpile ES6 via Babel before translation. es9: False, True, or 'auto' — enable ES2018 features (object spread/rest, etc.). es10: False, True, or 'auto' — enable ES2019 features (flat, trimStart, etc.). + es11: False, True, or 'auto' — enable ES2020 features (??, ?., globalThis). For example we have a file 'example.js' with: var a = function(x) {return x} translate_file('example.js', 'example.py') @@ -79,7 +81,7 @@ def translate_file(input_path, output_path, es6=False, es9=False, es10=False): ''' js = get_file_contents(input_path) - py_code = translate_js(js, es6=es6, es9=es9, es10=es10) + py_code = translate_js(js, es6=es6, es9=es9, es10=es10, es11=es11) lib_name = os.path.basename(output_path).split('.')[0] head = '__all__ = [%s]\n\n# Don\'t look below, you will not understand this Python code :) I don\'t.\n\n' % repr( lib_name) @@ -99,7 +101,7 @@ def run_file(path_or_file, context=None): return eval_value, context -def eval_js(js, es6=False, es9=False, es10=False): +def eval_js(js, es6=False, es9=False, es10=False, es11=False): """Just like javascript eval. Translates javascript to python, executes and returns python object. js is javascript source code @@ -107,6 +109,7 @@ def eval_js(js, es6=False, es9=False, es10=False): es6: False, True, or 'auto' — see translate_js. es9: False, True, or 'auto' — enable ES2018 features. es10: False, True, or 'auto' — enable ES2019 features. + es11: False, True, or 'auto' — enable ES2020 features. EXAMPLE: >>> import js2py @@ -123,11 +126,21 @@ def eval_js(js, es6=False, es9=False, es10=False): If you really want to convert object to python dict you can use to_dict method. """ e = EvalJs() - result = e.eval(js, es6=es6, es9=es9, es10=es10) + result = e.eval(js, es6=es6, es9=es9, es10=es10, es11=es11) drain_event_loop() return result +def eval_js11(js): + """Like eval_js with ES2020 support enabled.""" + return eval_js(js, es11=True) + + +def translate_js11(js): + """Like translate_js with ES2020 support enabled.""" + return translate_js(js, es11=True) + + def eval_js10(js): """Like eval_js with ES2019 support enabled.""" return eval_js(js, es10=True) @@ -205,12 +218,13 @@ def _js_require_impl(npm_module_name): setattr(self._var, k, v) def execute(self, js=None, use_compilation_plan=False, es6=False, es9=False, - es10=False): + es10=False, es11=False): """executes javascript js in current context es6: False, True, or 'auto' — transpile ES6 via Babel before translation. es9: False, True, or 'auto' — enable ES2018 features. es10: False, True, or 'auto' — enable ES2019 features. + es11: False, True, or 'auto' — enable ES2020 features. During initial execute() the converted js is cached for re-use. That means next time you run the same javascript snippet you save many instructions needed to parse and convert the @@ -226,26 +240,26 @@ def execute(self, js=None, use_compilation_plan=False, es6=False, es9=False, cache = self.__dict__['cache'] except KeyError: cache = self.__dict__['cache'] = {} - cache_key = (hashlib.md5(js.encode('utf-8')).digest(), es6, es9, es10) + cache_key = (hashlib.md5(js.encode('utf-8')).digest(), es6, es9, es10, es11) try: compiled = cache[cache_key] except KeyError: code = translate_js( js, '', use_compilation_plan=use_compilation_plan, - es6=es6, es9=es9, es10=es10) + es6=es6, es9=es9, es10=es10, es11=es11) compiled = cache[cache_key] = compile(code, '', 'exec') exec (compiled, self._context) drain_event_loop() def eval(self, expression, use_compilation_plan=False, es6=False, es9=False, - es10=False): + es10=False, es11=False): """evaluates expression in current context and returns its value""" expression = _prepare_js_source( - expression, es6=es6, es9=es9, es10=es10) - code = 'PyJsEvalResult = eval(%s)' % json.dumps(expression) + expression, es6=es6, es9=es9, es10=es10, es11=es11) + code = 'PyJsEvalResult = %s' % expression self.execute(code, use_compilation_plan=use_compilation_plan, - es6=es6, es9=es9, es10=es10) + es6=es6, es9=es9, es10=es10, es11=es11) return self['PyJsEvalResult'] def execute_debug(self, js): diff --git a/js2py/pyjs.py b/js2py/pyjs.py index c7151946..5349ec37 100644 --- a/js2py/pyjs.py +++ b/js2py/pyjs.py @@ -90,6 +90,8 @@ def set_global_object(obj): # also add window and set it to be a global object for compatibility obj.register('window') obj.put('window', this) + obj.register('globalThis') + obj.put('globalThis', this) scope = dict(zip(builtins, [globals()[e] for e in builtins])) diff --git a/js2py/translators/translator.py b/js2py/translators/translator.py index 7996c210..e08fb0a3 100644 --- a/js2py/translators/translator.py +++ b/js2py/translators/translator.py @@ -23,6 +23,12 @@ looks_like_es10 = None prepare_es10 = None +try: + from ..es11 import looks_like_es11, prepare_es11 +except ImportError: + looks_like_es11 = None + prepare_es11 = None + # Enable Js2Py exceptions and pyimport in parser pyjsparser.parser.ENABLE_PYIMPORT = True @@ -79,8 +85,13 @@ def pyjsparser_parse_fn(code): parser = pyjsparser.PyJsParser() return parser.parse(code) -def _prepare_js_source(js, es6=False, es9=False, es10=False): - """Optionally downlevel ES6/ES9/ES10 source before translation.""" +def _prepare_js_source(js, es6=False, es9=False, es10=False, es11=False): + """Optionally downlevel ES6/ES9/ES10/ES11 source before translation.""" + if es11 == 'auto': + if looks_like_es11 and looks_like_es11(js): + es11 = True + else: + es11 = False if es10 == 'auto': if looks_like_es10 and looks_like_es10(js): es10 = True @@ -96,6 +107,8 @@ def _prepare_js_source(js, es6=False, es9=False, es10=False): es6 = True else: es6 = False + if es11 and prepare_es11: + js = prepare_es11(js) if es10 and prepare_es10: js = prepare_es10(js) if es9 and prepare_es9: @@ -109,15 +122,16 @@ def _prepare_js_source(js, es6=False, es9=False, es10=False): def translate_js(js, HEADER=DEFAULT_HEADER, use_compilation_plan=False, parse_fn=pyjsparser_parse_fn, es6=False, es9=False, - es10=False): + es10=False, es11=False): """js has to be a javascript source code. returns equivalent python code. es6: False (ES5 only), True (always transpile via Babel), or 'auto' (transpile when ES6 syntax is detected). es9: False, True, or 'auto' — enable ES2018 features (spread/rest, etc.). - es10: False, True, or 'auto' — enable ES2019 features (flat, trimStart, etc.).""" - js = _prepare_js_source(js, es6=es6, es9=es9, es10=es10) + es10: False, True, or 'auto' — enable ES2019 features (flat, trimStart, etc.). + es11: False, True, or 'auto' — enable ES2020 features (??, ?., globalThis).""" + js = _prepare_js_source(js, es6=es6, es9=es9, es10=es10, es11=es11) if use_compilation_plan and not '//' in js and not '/*' in js: return translate_js_with_compilation_plan(js, HEADER=HEADER) diff --git a/tests/test_es11.py b/tests/test_es11.py new file mode 100644 index 00000000..0d4b6e0d --- /dev/null +++ b/tests/test_es11.py @@ -0,0 +1,99 @@ +"""Tests for ES11 (ES2020) JavaScript support.""" +import js2py +from js2py.es11 import looks_like_es11, prepare_es11 +from js2py.event_loop import reset_event_loop + + +def test_looks_like_es11(): + assert looks_like_es11('a?.b') + assert looks_like_es11('x ?? y') + assert looks_like_es11('globalThis') + assert looks_like_es11('Promise.allSettled([])') + assert not looks_like_es11('var a = 1; a.b') + + +def test_prepare_es11_nullish(): + out = prepare_es11('a ?? b') + assert '!== null' in out and '!== undefined' in out + + +def test_prepare_es11_optional_chain(): + out = prepare_es11('obj?.prop') + assert '_r' in out and '== null' in out + + +def test_nullish_coalescing(): + assert js2py.eval_js('0 ?? 1', es11=True) == 0 + assert js2py.eval_js('null ?? 2', es11=True) == 2 + assert js2py.eval_js('undefined ?? 3', es11=True) == 3 + + +def test_nullish_coalescing_chain(): + assert js2py.eval_js('null ?? null ?? 4', es11=True) == 4 + + +def test_optional_chaining_property(): + ctx = js2py.EvalJs({'obj': {'x': 9}}) + ctx.execute('var v = obj?.x;', es11=True) + assert ctx.v == 9 + + +def test_optional_chaining_null(): + assert js2py.eval_js('null?.missing', es11=True) is None + + +def test_optional_chaining_nested(): + ctx = js2py.EvalJs({'obj': {'inner': {'y': 5}}}) + ctx.execute('var v = obj?.inner?.y;', es11=True) + assert ctx.v == 5 + + +def test_optional_chaining_auto(): + ctx = js2py.EvalJs({'a': {'b': 7}}) + ctx.execute('var v = a?.b;', es11='auto') + assert ctx.v == 7 + + +def test_global_this(): + ctx = js2py.EvalJs() + ctx.execute('var same = (globalThis === window);', es11=True) + assert ctx.same is True + + +def test_promise_all_settled(): + reset_event_loop() + ctx = js2py.EvalJs() + ctx.execute(''' + var out; + Promise.allSettled([ + Promise.resolve(1), + Promise.reject("no") + ]).then(function(r) { out = r; }); + ''', es11=True) + assert list(ctx.out)[0]['status'] == 'fulfilled' + assert list(ctx.out)[0]['value'] == 1 + assert list(ctx.out)[1]['status'] == 'rejected' + assert list(ctx.out)[1]['reason'] == 'no' + + +def test_eval_js11(): + assert js2py.eval_js11('(null ?? 5) + 1') == 6 + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) From d6260143cc505845ad45b32aba644f41f90679b4 Mon Sep 17 00:00:00 2001 From: Gilead Cosman Date: Wed, 24 Jun 2026 20:59:40 -0700 Subject: [PATCH 06/11] Added ES12 support --- Makefile | 8 +- js2py/__init__.py | 3 +- js2py/constructors/jspromise.py | 48 ++++++++++ js2py/es12/__init__.py | 165 ++++++++++++++++++++++++++++++++ js2py/evaljs.py | 35 +++++-- js2py/prototypes/jsstring.py | 51 ++++++++++ js2py/translators/translator.py | 24 ++++- tests/test_es12.py | 99 +++++++++++++++++++ 8 files changed, 415 insertions(+), 18 deletions(-) create mode 100644 js2py/es12/__init__.py create mode 100644 tests/test_es12.py diff --git a/Makefile b/Makefile index 220e4eee..5595e5af 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ ROOT := $(CURDIR) export PYTHONPATH := $(ROOT) -.PHONY: help test test-simple test-es6 test-es9 test-es10 test-es11 test-language test-all +.PHONY: help test test-simple test-es6 test-es9 test-es10 test-es11 test-es12 test-language test-all help: @echo "Js2Py test targets:" @@ -14,10 +14,11 @@ help: @echo " make test-es9 Run tests/test_es9.py" @echo " make test-es10 Run tests/test_es10.py" @echo " make test-es11 Run tests/test_es11.py" + @echo " make test-es12 Run tests/test_es12.py" @echo " make test-language Run ES5.1 language suite (tests/run.py, slow)" @echo " make test-all Run quick tests and the language suite" -test: test-simple test-es6 test-es9 test-es10 test-es11 +test: test-simple test-es6 test-es9 test-es10 test-es11 test-es12 @: test-simple: @@ -35,6 +36,9 @@ test-es10: test-es11: PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es11.py" +test-es12: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es12.py" + test-language: @test -f "$(ROOT)/tests/node_failed.txt" || touch "$(ROOT)/tests/node_failed.txt" cd "$(ROOT)/tests" && PYTHONPATH="$(ROOT)" $(PYTHON) run.py diff --git a/js2py/__init__.py b/js2py/__init__.py index 6a0fcdcf..b8ec941e 100644 --- a/js2py/__init__.py +++ b/js2py/__init__.py @@ -66,7 +66,8 @@ 'EvalJs', 'translate_js', 'import_js', 'eval_js', 'parse_js', 'translate_file', 'run_file', 'disable_pyimport', 'eval_js6', 'translate_js6', 'eval_js9', 'translate_js9', 'eval_js10', 'translate_js10', - 'eval_js11', 'translate_js11', 'drain_event_loop', + 'eval_js11', 'translate_js11', 'eval_js12', 'translate_js12', + 'drain_event_loop', 'PyJsException', 'get_file_contents', 'write_file_contents', 'require' ] diff --git a/js2py/constructors/jspromise.py b/js2py/constructors/jspromise.py index a1e78884..31aa5d1b 100644 --- a/js2py/constructors/jspromise.py +++ b/js2py/constructors/jspromise.py @@ -268,6 +268,48 @@ def on_rejected(reason): return result_promise +@Js +def promise_any(iterable): + arr = iterable.to_object() + length = arr.get('length').to_uint32() + result_promise = _create_promise(None) + if length == 0: + _reject_promise(result_promise, MakeError( + 'Error', 'All promises were rejected')) + return result_promise + remaining = {'count': length} + + def on_any_reject(): + remaining['count'] -= 1 + if remaining['count'] == 0: + state = _state(result_promise) + if state['state'] == 'pending': + _reject_promise(result_promise, MakeError( + 'Error', 'All promises were rejected')) + + for i in range(length): + p = promise_resolve(arr.get(str(i))) + + def make_handlers(idx): + @Js + def on_fulfilled(value): + state = _state(result_promise) + if state['state'] == 'pending': + _resolve_promise(result_promise, value) + return undefined + + @Js + def on_rejected(reason): + on_any_reject() + return undefined + + return on_fulfilled, on_rejected + + on_fulfilled, on_rejected = make_handlers(i) + p.callprop('then', on_fulfilled, on_rejected) + return result_promise + + Promise.define_own_property('resolve', { 'value': promise_resolve, 'writable': True, @@ -286,6 +328,12 @@ def on_rejected(reason): 'enumerable': False, 'configurable': True }) +Promise.define_own_property('any', { + 'value': promise_any, + 'writable': True, + 'enumerable': False, + 'configurable': True +}) Promise.define_own_property( 'prototype', { 'value': PromisePrototype, diff --git a/js2py/es12/__init__.py b/js2py/es12/__init__.py new file mode 100644 index 00000000..27facb8f --- /dev/null +++ b/js2py/es12/__init__.py @@ -0,0 +1,165 @@ +"""ES12 (ES2021) support: logical assignment, numeric separators, replaceAll.""" + +import re + +CP_STRING = ( + '"([^\\\\"]+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|x[0-9a-fA-F]{2}|' + 'u[0-9a-fA-F]{4}))*"|\'([^\\\\\']+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|' + 'x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}))*\'') +CP_STRING_PLACEHOLDER = '__PyJsSTR_%i_PyJsSTR__' + +_ES12_SYNTAX_RE = re.compile( + r'(?:' + r'\?\?=|&&=|\|\|=|' + r'[0-9]+_[0-9]|' + r'\.replaceAll\s*\(|' + r'\bPromise\.any\s*\(' + r')', + re.MULTILINE) + +_LOGICAL_ASSIGN_OPS = ('??=', '&&=', '||=') +_NUMERIC_LITERAL_RE = re.compile( + r'(?= 0: + while i < len(code) and code[i].isspace(): + i += 1 + return i + while i >= 0 and code[i].isspace(): + i -= 1 + return i + + +def _scan_lhs(code, op_start): + i = _skip_ws(code, op_start - 1, -1) + end = i + 1 + paren = bracket = 0 + in_str = None + while i >= 0: + ch = code[i] + if in_str: + if ch == '\\': + i -= 1 + continue + if ch == in_str: + in_str = None + i -= 1 + continue + if ch in ('"', "'"): + in_str = ch + i -= 1 + continue + if paren == 0 and bracket == 0: + if ch in '=,;:{}': + break + if ch == ')': + paren += 1 + elif ch == '(': + paren -= 1 + if paren < 0: + break + elif ch == ']': + bracket += 1 + elif ch == '[': + bracket -= 1 + if bracket < 0: + break + i -= 1 + start = i + 1 + while start < end and code[start].isspace(): + start += 1 + return start, end + + +def _scan_rhs(code, start): + i = _skip_ws(code, start, 1) + begin = i + paren = bracket = 0 + in_str = None + while i < len(code): + ch = code[i] + if in_str: + if ch == '\\': + i += 2 + continue + if ch == in_str: + in_str = None + i += 1 + continue + if ch in ('"', "'"): + in_str = ch + i += 1 + continue + if paren == 0 and bracket == 0: + if ch in ',;': + break + if ch == '(': + paren += 1 + elif ch == ')': + paren -= 1 + elif ch == '[': + bracket += 1 + elif ch == ']': + bracket -= 1 + i += 1 + end = i + while end > begin and code[end - 1].isspace(): + end -= 1 + return begin, end + + +def _transform_logical_assignment(code): + while True: + op_pos = -1 + op = None + for candidate in _LOGICAL_ASSIGN_OPS: + idx = code.find(candidate) + if idx >= 0 and (op_pos < 0 or idx < op_pos): + op_pos = idx + op = candidate + if op_pos < 0: + break + lhs_start, lhs_end = _scan_lhs(code, op_pos) + rhs_start, rhs_end = _scan_rhs(code, op_pos + len(op)) + lhs = code[lhs_start:lhs_end].strip() + rhs = code[rhs_start:rhs_end].strip() + if op == '&&=': + repl = '%s = (%s && (%s))' % (lhs, lhs, rhs) + elif op == '||=': + repl = '%s = (%s || (%s))' % (lhs, lhs, rhs) + else: + repl = ('%s = (%s !== null && %s !== undefined ? %s : %s)' % + (lhs, lhs, lhs, lhs, rhs)) + code = code[:lhs_start] + repl + code[rhs_end:] + return code + + +def _strip_numeric_underscores(match): + return match.group(1).replace('_', '') + + +def _transform_numeric_separators(code): + return _NUMERIC_LITERAL_RE.sub(_strip_numeric_underscores, code) diff --git a/js2py/evaljs.py b/js2py/evaljs.py index 38eb4d5c..586d9625 100644 --- a/js2py/evaljs.py +++ b/js2py/evaljs.py @@ -14,6 +14,7 @@ 'EvalJs', 'translate_js', 'import_js', 'eval_js', 'translate_file', 'eval_js6', 'translate_js6', 'eval_js9', 'translate_js9', 'eval_js10', 'translate_js10', 'eval_js11', 'translate_js11', + 'eval_js12', 'translate_js12', 'run_file', 'disable_pyimport', 'drain_event_loop', 'get_file_contents', 'write_file_contents' ] @@ -61,7 +62,7 @@ def write_file_contents(path_or_file, contents): def translate_file(input_path, output_path, es6=False, es9=False, es10=False, - es11=False): + es11=False, es12=False): ''' Translates input JS file to python and saves the it to the output path. It appends some convenience code at the end so that it is easy to import JS objects. @@ -70,6 +71,7 @@ def translate_file(input_path, output_path, es6=False, es9=False, es10=False, es9: False, True, or 'auto' — enable ES2018 features (object spread/rest, etc.). es10: False, True, or 'auto' — enable ES2019 features (flat, trimStart, etc.). es11: False, True, or 'auto' — enable ES2020 features (??, ?., globalThis). + es12: False, True, or 'auto' — enable ES2021 features (&&=, numeric separators). For example we have a file 'example.js' with: var a = function(x) {return x} translate_file('example.js', 'example.py') @@ -81,7 +83,7 @@ def translate_file(input_path, output_path, es6=False, es9=False, es10=False, ''' js = get_file_contents(input_path) - py_code = translate_js(js, es6=es6, es9=es9, es10=es10, es11=es11) + py_code = translate_js(js, es6=es6, es9=es9, es10=es10, es11=es11, es12=es12) lib_name = os.path.basename(output_path).split('.')[0] head = '__all__ = [%s]\n\n# Don\'t look below, you will not understand this Python code :) I don\'t.\n\n' % repr( lib_name) @@ -101,7 +103,7 @@ def run_file(path_or_file, context=None): return eval_value, context -def eval_js(js, es6=False, es9=False, es10=False, es11=False): +def eval_js(js, es6=False, es9=False, es10=False, es11=False, es12=False): """Just like javascript eval. Translates javascript to python, executes and returns python object. js is javascript source code @@ -110,6 +112,7 @@ def eval_js(js, es6=False, es9=False, es10=False, es11=False): es9: False, True, or 'auto' — enable ES2018 features. es10: False, True, or 'auto' — enable ES2019 features. es11: False, True, or 'auto' — enable ES2020 features. + es12: False, True, or 'auto' — enable ES2021 features. EXAMPLE: >>> import js2py @@ -126,11 +129,21 @@ def eval_js(js, es6=False, es9=False, es10=False, es11=False): If you really want to convert object to python dict you can use to_dict method. """ e = EvalJs() - result = e.eval(js, es6=es6, es9=es9, es10=es10, es11=es11) + result = e.eval(js, es6=es6, es9=es9, es10=es10, es11=es11, es12=es12) drain_event_loop() return result +def eval_js12(js): + """Like eval_js with ES2021 support enabled.""" + return eval_js(js, es12=True) + + +def translate_js12(js): + """Like translate_js with ES2021 support enabled.""" + return translate_js(js, es12=True) + + def eval_js11(js): """Like eval_js with ES2020 support enabled.""" return eval_js(js, es11=True) @@ -218,13 +231,14 @@ def _js_require_impl(npm_module_name): setattr(self._var, k, v) def execute(self, js=None, use_compilation_plan=False, es6=False, es9=False, - es10=False, es11=False): + es10=False, es11=False, es12=False): """executes javascript js in current context es6: False, True, or 'auto' — transpile ES6 via Babel before translation. es9: False, True, or 'auto' — enable ES2018 features. es10: False, True, or 'auto' — enable ES2019 features. es11: False, True, or 'auto' — enable ES2020 features. + es12: False, True, or 'auto' — enable ES2021 features. During initial execute() the converted js is cached for re-use. That means next time you run the same javascript snippet you save many instructions needed to parse and convert the @@ -240,26 +254,27 @@ def execute(self, js=None, use_compilation_plan=False, es6=False, es9=False, cache = self.__dict__['cache'] except KeyError: cache = self.__dict__['cache'] = {} - cache_key = (hashlib.md5(js.encode('utf-8')).digest(), es6, es9, es10, es11) + cache_key = (hashlib.md5(js.encode('utf-8')).digest(), es6, es9, es10, + es11, es12) try: compiled = cache[cache_key] except KeyError: code = translate_js( js, '', use_compilation_plan=use_compilation_plan, - es6=es6, es9=es9, es10=es10, es11=es11) + es6=es6, es9=es9, es10=es10, es11=es11, es12=es12) compiled = cache[cache_key] = compile(code, '', 'exec') exec (compiled, self._context) drain_event_loop() def eval(self, expression, use_compilation_plan=False, es6=False, es9=False, - es10=False, es11=False): + es10=False, es11=False, es12=False): """evaluates expression in current context and returns its value""" expression = _prepare_js_source( - expression, es6=es6, es9=es9, es10=es10, es11=es11) + expression, es6=es6, es9=es9, es10=es10, es11=es11, es12=es12) code = 'PyJsEvalResult = %s' % expression self.execute(code, use_compilation_plan=use_compilation_plan, - es6=es6, es9=es9, es10=es10, es11=es11) + es6=es6, es9=es9, es10=es10, es11=es11, es12=es12) return self['PyJsEvalResult'] def execute_debug(self, js): diff --git a/js2py/prototypes/jsstring.py b/js2py/prototypes/jsstring.py index 199263df..cfa1ab0c 100644 --- a/js2py/prototypes/jsstring.py +++ b/js2py/prototypes/jsstring.py @@ -201,6 +201,57 @@ def replace(searchValue, replaceValue): res += s[span[1]:] return res + def replaceAll(searchValue, replaceValue): + this.cok() + string = this.to_string() + s = string.value + if not replaceValue.is_callable(): + replaceValue = replaceValue.to_string().value + func = False + else: + func = True + if searchValue.Class == 'RegExp': + if not searchValue.glob: + raise this.MakeError( + 'TypeError', + 'replaceAll must be called with a global RegExp') + res = '' + last = 0 + for e in re.finditer(searchValue.pat, s): + res += s[last:e.span()[0]] + if func: + args = (e.group(), ) + e.groups() + (e.span()[1], string) + args = map(this.Js, args) + res += replaceValue(*args).to_string().value + else: + res += replacement_template(replaceValue, s, e.span(), + e.groups()) + last = e.span()[1] + res += s[last:] + return this.Js(res) + match = searchValue.to_string().value + if not match: + raise this.MakeError( + 'TypeError', 'replaceAll must not replace with empty string') + if func: + res = '' + last = 0 + ind = 0 + while True: + pos = s.find(match, last) + if pos == -1: + break + span = (pos, pos + len(match)) + res += s[last:span[0]] + args = (match, ) + () + (span[1], string) + args = tuple(this.Js(x) for x in args) + res += replaceValue(*args).to_string().value + last = span[1] + ind += 1 + res += s[last:] + return this.Js(res) + return this.Js(s.replace(match, replaceValue)) + def search(regexp): this.cok() string = this.to_string() diff --git a/js2py/translators/translator.py b/js2py/translators/translator.py index e08fb0a3..05dbef01 100644 --- a/js2py/translators/translator.py +++ b/js2py/translators/translator.py @@ -29,6 +29,12 @@ looks_like_es11 = None prepare_es11 = None +try: + from ..es12 import looks_like_es12, prepare_es12 +except ImportError: + looks_like_es12 = None + prepare_es12 = None + # Enable Js2Py exceptions and pyimport in parser pyjsparser.parser.ENABLE_PYIMPORT = True @@ -85,8 +91,13 @@ def pyjsparser_parse_fn(code): parser = pyjsparser.PyJsParser() return parser.parse(code) -def _prepare_js_source(js, es6=False, es9=False, es10=False, es11=False): - """Optionally downlevel ES6/ES9/ES10/ES11 source before translation.""" +def _prepare_js_source(js, es6=False, es9=False, es10=False, es11=False, es12=False): + """Optionally downlevel ES6/ES9/ES10/ES11/ES12 source before translation.""" + if es12 == 'auto': + if looks_like_es12 and looks_like_es12(js): + es12 = True + else: + es12 = False if es11 == 'auto': if looks_like_es11 and looks_like_es11(js): es11 = True @@ -107,6 +118,8 @@ def _prepare_js_source(js, es6=False, es9=False, es10=False, es11=False): es6 = True else: es6 = False + if es12 and prepare_es12: + js = prepare_es12(js) if es11 and prepare_es11: js = prepare_es11(js) if es10 and prepare_es10: @@ -122,7 +135,7 @@ def _prepare_js_source(js, es6=False, es9=False, es10=False, es11=False): def translate_js(js, HEADER=DEFAULT_HEADER, use_compilation_plan=False, parse_fn=pyjsparser_parse_fn, es6=False, es9=False, - es10=False, es11=False): + es10=False, es11=False, es12=False): """js has to be a javascript source code. returns equivalent python code. @@ -130,8 +143,9 @@ def translate_js(js, HEADER=DEFAULT_HEADER, use_compilation_plan=False, (transpile when ES6 syntax is detected). es9: False, True, or 'auto' — enable ES2018 features (spread/rest, etc.). es10: False, True, or 'auto' — enable ES2019 features (flat, trimStart, etc.). - es11: False, True, or 'auto' — enable ES2020 features (??, ?., globalThis).""" - js = _prepare_js_source(js, es6=es6, es9=es9, es10=es10, es11=es11) + es11: False, True, or 'auto' — enable ES2020 features (??, ?., globalThis). + es12: False, True, or 'auto' — enable ES2021 features (&&=, numeric separators).""" + js = _prepare_js_source(js, es6=es6, es9=es9, es10=es10, es11=es11, es12=es12) if use_compilation_plan and not '//' in js and not '/*' in js: return translate_js_with_compilation_plan(js, HEADER=HEADER) diff --git a/tests/test_es12.py b/tests/test_es12.py new file mode 100644 index 00000000..6a004ba2 --- /dev/null +++ b/tests/test_es12.py @@ -0,0 +1,99 @@ +"""Tests for ES12 (ES2021) JavaScript support.""" +import js2py +from js2py.es12 import looks_like_es12, prepare_es12 +from js2py.event_loop import reset_event_loop + + +def test_looks_like_es12(): + assert looks_like_es12('a &&= 1') + assert looks_like_es12('1_000') + assert looks_like_es12('"a".replaceAll("a","b")') + assert looks_like_es12('Promise.any([])') + assert not looks_like_es12('var x = 1; x + 2') + + +def test_prepare_es12_logical_assign(): + out = prepare_es12('count &&= 1') + assert 'count = (count &&' in out + assert '1' in out + + +def test_prepare_es12_numeric_separator(): + assert prepare_es12('var n = 1_000_000;') == 'var n = 1000000;' + + +def test_numeric_separator(): + assert js2py.eval_js('1_000 + 2', es12=True) == 1002 + + +def test_logical_and_assign(): + ctx = js2py.EvalJs() + ctx.execute('var x = 1; x &&= 2; var y = x;', es12=True) + assert ctx.y == 2 + + +def test_logical_or_assign(): + ctx = js2py.EvalJs() + ctx.execute('var x = 0; x ||= 5; var y = x;', es12=True) + assert ctx.y == 5 + + +def test_nullish_assign(): + ctx = js2py.EvalJs() + ctx.execute('var x = null; x ??= 7; var y = x;', es12=True) + assert ctx.y == 7 + ctx.execute('var z = 0; z ??= 9;', es12=True) + assert ctx.z == 0 + + +def test_string_replace_all(): + assert js2py.eval_js('"a-a-a".replaceAll("-", "+")', es12=True) == 'a+a+a' + + +def test_promise_any_fulfilled(): + reset_event_loop() + ctx = js2py.EvalJs() + ctx.execute(''' + var out; + Promise.any([ + Promise.reject("nope"), + Promise.resolve(42) + ]).then(function(v) { out = v; }); + ''', es12=True) + assert ctx.out == 42 + + +def test_promise_any_all_rejected(): + reset_event_loop() + ctx = js2py.EvalJs() + ctx.execute(''' + var err = null; + Promise.any([ + Promise.reject("a"), + Promise.reject("b") + ]).catch(function(e) { err = e; }); + ''', es12=True) + assert ctx.err is not None + + +def test_eval_js12(): + assert js2py.eval_js12('1_00 * 2') == 200 + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) From f16be24269a5c160f512bae7a9268d7df00ee3a1 Mon Sep 17 00:00:00 2001 From: Gilead Cosman Date: Wed, 24 Jun 2026 21:01:27 -0700 Subject: [PATCH 07/11] Added ES13 support --- Makefile | 8 +++- js2py/__init__.py | 2 +- js2py/constructors/jsobject.py | 5 +++ js2py/es13/__init__.py | 20 ++++++++++ js2py/evaljs.py | 39 ++++++++++++------ js2py/prototypes/jsarray.py | 13 ++++++ js2py/prototypes/jsstring.py | 11 ++++++ js2py/translators/translator.py | 26 +++++++++--- tests/test_es13.py | 70 +++++++++++++++++++++++++++++++++ 9 files changed, 175 insertions(+), 19 deletions(-) create mode 100644 js2py/es13/__init__.py create mode 100644 tests/test_es13.py diff --git a/Makefile b/Makefile index 5595e5af..0ab7d1d7 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ ROOT := $(CURDIR) export PYTHONPATH := $(ROOT) -.PHONY: help test test-simple test-es6 test-es9 test-es10 test-es11 test-es12 test-language test-all +.PHONY: help test test-simple test-es6 test-es9 test-es10 test-es11 test-es12 test-es13 test-language test-all help: @echo "Js2Py test targets:" @@ -15,10 +15,11 @@ help: @echo " make test-es10 Run tests/test_es10.py" @echo " make test-es11 Run tests/test_es11.py" @echo " make test-es12 Run tests/test_es12.py" + @echo " make test-es13 Run tests/test_es13.py" @echo " make test-language Run ES5.1 language suite (tests/run.py, slow)" @echo " make test-all Run quick tests and the language suite" -test: test-simple test-es6 test-es9 test-es10 test-es11 test-es12 +test: test-simple test-es6 test-es9 test-es10 test-es11 test-es12 test-es13 @: test-simple: @@ -39,6 +40,9 @@ test-es11: test-es12: PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es12.py" +test-es13: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es13.py" + test-language: @test -f "$(ROOT)/tests/node_failed.txt" || touch "$(ROOT)/tests/node_failed.txt" cd "$(ROOT)/tests" && PYTHONPATH="$(ROOT)" $(PYTHON) run.py diff --git a/js2py/__init__.py b/js2py/__init__.py index b8ec941e..7ab76346 100644 --- a/js2py/__init__.py +++ b/js2py/__init__.py @@ -67,7 +67,7 @@ 'translate_file', 'run_file', 'disable_pyimport', 'eval_js6', 'translate_js6', 'eval_js9', 'translate_js9', 'eval_js10', 'translate_js10', 'eval_js11', 'translate_js11', 'eval_js12', 'translate_js12', - 'drain_event_loop', + 'eval_js13', 'translate_js13', 'drain_event_loop', 'PyJsException', 'get_file_contents', 'write_file_contents', 'require' ] diff --git a/js2py/constructors/jsobject.py b/js2py/constructors/jsobject.py index b42fce49..cbe6d6b0 100644 --- a/js2py/constructors/jsobject.py +++ b/js2py/constructors/jsobject.py @@ -187,6 +187,11 @@ def fromEntries(iterable): return obj raise MakeError('TypeError', 'Object.fromEntries requires an iterable') + def hasOwn(obj, prop): + if not obj.is_object(): + raise MakeError('TypeError', 'Object.hasOwn called on non-object') + return prop.to_string().value in obj.own + # add methods attached to Object constructor fill_prototype(Object, ObjectMethods, default_attrs) diff --git a/js2py/es13/__init__.py b/js2py/es13/__init__.py new file mode 100644 index 00000000..c5a14217 --- /dev/null +++ b/js2py/es13/__init__.py @@ -0,0 +1,20 @@ +"""ES13 (ES2022) support: at() and Object.hasOwn detection.""" + +import re + +_ES13_SYNTAX_RE = re.compile( + r'(?:' + r'\.at\s*\(|' + r'\bObject\.hasOwn\s*\(' + r')', + re.MULTILINE) + + +def looks_like_es13(code): + """Return True if source likely contains ES13 syntax or APIs.""" + return bool(_ES13_SYNTAX_RE.search(code)) + + +def prepare_es13(code): + """Apply ES2022 source transforms before translation.""" + return code diff --git a/js2py/evaljs.py b/js2py/evaljs.py index 586d9625..d1658307 100644 --- a/js2py/evaljs.py +++ b/js2py/evaljs.py @@ -14,7 +14,7 @@ 'EvalJs', 'translate_js', 'import_js', 'eval_js', 'translate_file', 'eval_js6', 'translate_js6', 'eval_js9', 'translate_js9', 'eval_js10', 'translate_js10', 'eval_js11', 'translate_js11', - 'eval_js12', 'translate_js12', + 'eval_js12', 'translate_js12', 'eval_js13', 'translate_js13', 'run_file', 'disable_pyimport', 'drain_event_loop', 'get_file_contents', 'write_file_contents' ] @@ -62,7 +62,7 @@ def write_file_contents(path_or_file, contents): def translate_file(input_path, output_path, es6=False, es9=False, es10=False, - es11=False, es12=False): + es11=False, es12=False, es13=False): ''' Translates input JS file to python and saves the it to the output path. It appends some convenience code at the end so that it is easy to import JS objects. @@ -72,6 +72,7 @@ def translate_file(input_path, output_path, es6=False, es9=False, es10=False, es10: False, True, or 'auto' — enable ES2019 features (flat, trimStart, etc.). es11: False, True, or 'auto' — enable ES2020 features (??, ?., globalThis). es12: False, True, or 'auto' — enable ES2021 features (&&=, numeric separators). + es13: False, True, or 'auto' — enable ES2022 features (at, Object.hasOwn). For example we have a file 'example.js' with: var a = function(x) {return x} translate_file('example.js', 'example.py') @@ -83,7 +84,8 @@ def translate_file(input_path, output_path, es6=False, es9=False, es10=False, ''' js = get_file_contents(input_path) - py_code = translate_js(js, es6=es6, es9=es9, es10=es10, es11=es11, es12=es12) + py_code = translate_js(js, es6=es6, es9=es9, es10=es10, es11=es11, + es12=es12, es13=es13) lib_name = os.path.basename(output_path).split('.')[0] head = '__all__ = [%s]\n\n# Don\'t look below, you will not understand this Python code :) I don\'t.\n\n' % repr( lib_name) @@ -103,7 +105,7 @@ def run_file(path_or_file, context=None): return eval_value, context -def eval_js(js, es6=False, es9=False, es10=False, es11=False, es12=False): +def eval_js(js, es6=False, es9=False, es10=False, es11=False, es12=False, es13=False): """Just like javascript eval. Translates javascript to python, executes and returns python object. js is javascript source code @@ -113,6 +115,7 @@ def eval_js(js, es6=False, es9=False, es10=False, es11=False, es12=False): es10: False, True, or 'auto' — enable ES2019 features. es11: False, True, or 'auto' — enable ES2020 features. es12: False, True, or 'auto' — enable ES2021 features. + es13: False, True, or 'auto' — enable ES2022 features. EXAMPLE: >>> import js2py @@ -129,11 +132,22 @@ def eval_js(js, es6=False, es9=False, es10=False, es11=False, es12=False): If you really want to convert object to python dict you can use to_dict method. """ e = EvalJs() - result = e.eval(js, es6=es6, es9=es9, es10=es10, es11=es11, es12=es12) + result = e.eval(js, es6=es6, es9=es9, es10=es10, es11=es11, es12=es12, + es13=es13) drain_event_loop() return result +def eval_js13(js): + """Like eval_js with ES2022 support enabled.""" + return eval_js(js, es13=True) + + +def translate_js13(js): + """Like translate_js with ES2022 support enabled.""" + return translate_js(js, es13=True) + + def eval_js12(js): """Like eval_js with ES2021 support enabled.""" return eval_js(js, es12=True) @@ -231,7 +245,7 @@ def _js_require_impl(npm_module_name): setattr(self._var, k, v) def execute(self, js=None, use_compilation_plan=False, es6=False, es9=False, - es10=False, es11=False, es12=False): + es10=False, es11=False, es12=False, es13=False): """executes javascript js in current context es6: False, True, or 'auto' — transpile ES6 via Babel before translation. @@ -239,6 +253,7 @@ def execute(self, js=None, use_compilation_plan=False, es6=False, es9=False, es10: False, True, or 'auto' — enable ES2019 features. es11: False, True, or 'auto' — enable ES2020 features. es12: False, True, or 'auto' — enable ES2021 features. + es13: False, True, or 'auto' — enable ES2022 features. During initial execute() the converted js is cached for re-use. That means next time you run the same javascript snippet you save many instructions needed to parse and convert the @@ -255,26 +270,28 @@ def execute(self, js=None, use_compilation_plan=False, es6=False, es9=False, except KeyError: cache = self.__dict__['cache'] = {} cache_key = (hashlib.md5(js.encode('utf-8')).digest(), es6, es9, es10, - es11, es12) + es11, es12, es13) try: compiled = cache[cache_key] except KeyError: code = translate_js( js, '', use_compilation_plan=use_compilation_plan, - es6=es6, es9=es9, es10=es10, es11=es11, es12=es12) + es6=es6, es9=es9, es10=es10, es11=es11, es12=es12, es13=es13) compiled = cache[cache_key] = compile(code, '', 'exec') exec (compiled, self._context) drain_event_loop() def eval(self, expression, use_compilation_plan=False, es6=False, es9=False, - es10=False, es11=False, es12=False): + es10=False, es11=False, es12=False, es13=False): """evaluates expression in current context and returns its value""" expression = _prepare_js_source( - expression, es6=es6, es9=es9, es10=es10, es11=es11, es12=es12) + expression, es6=es6, es9=es9, es10=es10, es11=es11, es12=es12, + es13=es13) code = 'PyJsEvalResult = %s' % expression self.execute(code, use_compilation_plan=use_compilation_plan, - es6=es6, es9=es9, es10=es10, es11=es11, es12=es12) + es6=es6, es9=es9, es10=es10, es11=es11, es12=es12, + es13=es13) return self['PyJsEvalResult'] def execute_debug(self, js): diff --git a/js2py/prototypes/jsarray.py b/js2py/prototypes/jsarray.py index 6720dde8..270eaf4d 100644 --- a/js2py/prototypes/jsarray.py +++ b/js2py/prototypes/jsarray.py @@ -479,6 +479,19 @@ def flatMap(callbackfn): arguments[1] if len(arguments) > 1 else this.undefined) return mapped.callprop('flat', this.Js(1)) + def at(index): + array = this.to_object() + length = array.get('length').to_uint32() + k = index.to_int() + if k < 0: + k = length + k + if k < 0 or k >= length: + return this.undefined + key = str(k) + if not array.has_property(key): + return this.undefined + return array.get(key) + def sort_compare(a, b, comp): if a is None: diff --git a/js2py/prototypes/jsstring.py b/js2py/prototypes/jsstring.py index cfa1ab0c..ca044917 100644 --- a/js2py/prototypes/jsstring.py +++ b/js2py/prototypes/jsstring.py @@ -372,6 +372,17 @@ def trimLeft(): def trimRight(): return this.callprop('trimEnd') + def at(index): + this.cok() + s = this.to_string().value + length = len(s) + k = index.to_int() + if k < 0: + k = length + k + if k < 0 or k >= length: + return this.undefined + return this.Js(s[k]) + def SplitMatch(s, q, R): # s is Py String to match, q is the py int match start and R is Js RegExp or String. diff --git a/js2py/translators/translator.py b/js2py/translators/translator.py index 05dbef01..57b7140f 100644 --- a/js2py/translators/translator.py +++ b/js2py/translators/translator.py @@ -35,6 +35,12 @@ looks_like_es12 = None prepare_es12 = None +try: + from ..es13 import looks_like_es13, prepare_es13 +except ImportError: + looks_like_es13 = None + prepare_es13 = None + # Enable Js2Py exceptions and pyimport in parser pyjsparser.parser.ENABLE_PYIMPORT = True @@ -91,8 +97,14 @@ def pyjsparser_parse_fn(code): parser = pyjsparser.PyJsParser() return parser.parse(code) -def _prepare_js_source(js, es6=False, es9=False, es10=False, es11=False, es12=False): - """Optionally downlevel ES6/ES9/ES10/ES11/ES12 source before translation.""" +def _prepare_js_source(js, es6=False, es9=False, es10=False, es11=False, es12=False, + es13=False): + """Optionally downlevel ES6/ES9–ES13 source before translation.""" + if es13 == 'auto': + if looks_like_es13 and looks_like_es13(js): + es13 = True + else: + es13 = False if es12 == 'auto': if looks_like_es12 and looks_like_es12(js): es12 = True @@ -118,6 +130,8 @@ def _prepare_js_source(js, es6=False, es9=False, es10=False, es11=False, es12=Fa es6 = True else: es6 = False + if es13 and prepare_es13: + js = prepare_es13(js) if es12 and prepare_es12: js = prepare_es12(js) if es11 and prepare_es11: @@ -135,7 +149,7 @@ def _prepare_js_source(js, es6=False, es9=False, es10=False, es11=False, es12=Fa def translate_js(js, HEADER=DEFAULT_HEADER, use_compilation_plan=False, parse_fn=pyjsparser_parse_fn, es6=False, es9=False, - es10=False, es11=False, es12=False): + es10=False, es11=False, es12=False, es13=False): """js has to be a javascript source code. returns equivalent python code. @@ -144,8 +158,10 @@ def translate_js(js, HEADER=DEFAULT_HEADER, use_compilation_plan=False, es9: False, True, or 'auto' — enable ES2018 features (spread/rest, etc.). es10: False, True, or 'auto' — enable ES2019 features (flat, trimStart, etc.). es11: False, True, or 'auto' — enable ES2020 features (??, ?., globalThis). - es12: False, True, or 'auto' — enable ES2021 features (&&=, numeric separators).""" - js = _prepare_js_source(js, es6=es6, es9=es9, es10=es10, es11=es11, es12=es12) + es12: False, True, or 'auto' — enable ES2021 features (&&=, numeric separators). + es13: False, True, or 'auto' — enable ES2022 features (at, Object.hasOwn).""" + js = _prepare_js_source(js, es6=es6, es9=es9, es10=es10, es11=es11, + es12=es12, es13=es13) if use_compilation_plan and not '//' in js and not '/*' in js: return translate_js_with_compilation_plan(js, HEADER=HEADER) diff --git a/tests/test_es13.py b/tests/test_es13.py new file mode 100644 index 00000000..a05c1cf2 --- /dev/null +++ b/tests/test_es13.py @@ -0,0 +1,70 @@ +"""Tests for ES13 (ES2022) JavaScript support.""" +import js2py +from js2py.es13 import looks_like_es13 + + +def test_looks_like_es13(): + assert looks_like_es13('[1, 2, 3].at(-1)') + assert looks_like_es13('Object.hasOwn({}, "x")') + assert not looks_like_es13('var a = [1, 2]; a[0]') + + +def test_array_at_positive(): + assert js2py.eval_js('[10, 20, 30].at(1)', es13=True) == 20 + + +def test_array_at_negative(): + assert js2py.eval_js('[10, 20, 30].at(-1)', es13=True) == 30 + + +def test_array_at_out_of_range(): + assert js2py.eval_js('[1].at(5)', es13=True) is None + + +def test_array_at_auto(): + ctx = js2py.EvalJs() + ctx.execute('var a = [5, 6]; var v = a.at(-2);', es13='auto') + assert ctx.v == 5 + + +def test_string_at(): + assert js2py.eval_js('"hello".at(-1)', es13=True) == 'o' + assert js2py.eval_js('"hi".at(5)', es13=True) is None + + +def test_object_has_own_true(): + assert js2py.eval_js('Object.hasOwn({a: 1}, "a")', es13=True) is True + + +def test_object_has_own_false(): + assert js2py.eval_js('Object.hasOwn({a: 1}, "toString")', es13=True) is False + + +def test_object_has_own_inherited(): + ctx = js2py.EvalJs() + ctx.execute('var o = Object.create({x: 1}); var v = Object.hasOwn(o, "x");', + es13=True) + assert ctx.v is False + + +def test_eval_js13(): + assert js2py.eval_js13('Object.hasOwn({k: 9}, "k")') is True + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) From 82becdf1069188ce91eb9667a46063e47af6c23f Mon Sep 17 00:00:00 2001 From: Gilead Cosman Date: Wed, 24 Jun 2026 21:16:08 -0700 Subject: [PATCH 08/11] Added ES14 support, fixed some ES6 tests not passing, and added ES7 and ES8 tests --- Makefile | 18 ++- js2py/__init__.py | 5 +- js2py/constructors/jsobject.py | 12 ++ js2py/es14/__init__.py | 29 ++++ js2py/es7/__init__.py | 254 ++++++++++++++++++++++++++++++++ js2py/es8/__init__.py | 23 +++ js2py/evaljs.py | 166 ++++++++++++++++++--- js2py/prototypes/jsarray.py | 53 +++++++ js2py/prototypes/jsstring.py | 30 ++++ js2py/translators/translator.py | 58 +++++++- tests/test_es14.py | 82 +++++++++++ tests/test_es7.py | 80 ++++++++++ tests/test_es8.py | 74 ++++++++++ 13 files changed, 850 insertions(+), 34 deletions(-) create mode 100644 js2py/es14/__init__.py create mode 100644 js2py/es7/__init__.py create mode 100644 js2py/es8/__init__.py create mode 100644 tests/test_es14.py create mode 100644 tests/test_es7.py create mode 100644 tests/test_es8.py diff --git a/Makefile b/Makefile index 0ab7d1d7..b3d43cbe 100644 --- a/Makefile +++ b/Makefile @@ -4,30 +4,41 @@ ROOT := $(CURDIR) export PYTHONPATH := $(ROOT) -.PHONY: help test test-simple test-es6 test-es9 test-es10 test-es11 test-es12 test-es13 test-language test-all +.PHONY: help test test-simple test-es-all test-es6 test-es7 test-es8 test-es9 test-es10 test-es11 test-es12 test-es13 test-es14 test-language test-all help: @echo "Js2Py test targets:" @echo " make test Run quick integration tests (default)" @echo " make test-simple Run simple_test.py (ES5 + ES6 smoke tests)" @echo " make test-es6 Run tests/test_es6.py" + @echo " make test-es7 Run tests/test_es7.py" + @echo " make test-es8 Run tests/test_es8.py" @echo " make test-es9 Run tests/test_es9.py" @echo " make test-es10 Run tests/test_es10.py" @echo " make test-es11 Run tests/test_es11.py" @echo " make test-es12 Run tests/test_es12.py" @echo " make test-es13 Run tests/test_es13.py" + @echo " make test-es14 Run tests/test_es14.py" @echo " make test-language Run ES5.1 language suite (tests/run.py, slow)" @echo " make test-all Run quick tests and the language suite" -test: test-simple test-es6 test-es9 test-es10 test-es11 test-es12 test-es13 +test: test-simple test-es_ @: test-simple: PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/simple_test.py" +test-es_: test-es6 test-es7 test-es8 test-es9 test-es10 test-es11 test-es12 test-es13 test-es14 + test-es6: PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es6.py" +test-es7: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es7.py" + +test-es8: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es8.py" + test-es9: PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es9.py" @@ -43,6 +54,9 @@ test-es12: test-es13: PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es13.py" +test-es14: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es14.py" + test-language: @test -f "$(ROOT)/tests/node_failed.txt" || touch "$(ROOT)/tests/node_failed.txt" cd "$(ROOT)/tests" && PYTHONPATH="$(ROOT)" $(PYTHON) run.py diff --git a/js2py/__init__.py b/js2py/__init__.py index 7ab76346..a2e7458d 100644 --- a/js2py/__init__.py +++ b/js2py/__init__.py @@ -65,9 +65,10 @@ __all__ = [ 'EvalJs', 'translate_js', 'import_js', 'eval_js', 'parse_js', 'translate_file', 'run_file', 'disable_pyimport', 'eval_js6', - 'translate_js6', 'eval_js9', 'translate_js9', 'eval_js10', 'translate_js10', + 'translate_js6', 'eval_js7', 'translate_js7', 'eval_js8', 'translate_js8', + 'eval_js9', 'translate_js9', 'eval_js10', 'translate_js10', 'eval_js11', 'translate_js11', 'eval_js12', 'translate_js12', - 'eval_js13', 'translate_js13', 'drain_event_loop', + 'eval_js13', 'translate_js13', 'eval_js14', 'translate_js14', 'drain_event_loop', 'PyJsException', 'get_file_contents', 'write_file_contents', 'require' ] diff --git a/js2py/constructors/jsobject.py b/js2py/constructors/jsobject.py index cbe6d6b0..1dea5ead 100644 --- a/js2py/constructors/jsobject.py +++ b/js2py/constructors/jsobject.py @@ -192,6 +192,18 @@ def hasOwn(obj, prop): raise MakeError('TypeError', 'Object.hasOwn called on non-object') return prop.to_string().value in obj.own + def getOwnPropertyDescriptors(obj): + if not obj.is_object(): + raise MakeError('TypeError', + 'Object.getOwnPropertyDescriptors called on non-object') + result = PyJsObject(prototype=ObjectPrototype) + for name, desc in six.iteritems(obj.own): + desc_obj = PyJsObject(prototype=ObjectPrototype) + for key, val in six.iteritems(desc): + desc_obj.put(key, val) + result.put(name, desc_obj) + return result + # add methods attached to Object constructor fill_prototype(Object, ObjectMethods, default_attrs) diff --git a/js2py/es14/__init__.py b/js2py/es14/__init__.py new file mode 100644 index 00000000..53cef8b7 --- /dev/null +++ b/js2py/es14/__init__.py @@ -0,0 +1,29 @@ +"""ES14 (ES2023) support: hashbang comments and findLast APIs.""" + +import re + +_ES14_SYNTAX_RE = re.compile( + r'(?:' + r'^#!|' + r'\.findLast\s*\(|' + r'\.findLastIndex\s*\(' + r')', + re.MULTILINE) + + +def looks_like_es14(code): + """Return True if source likely contains ES14 syntax or APIs.""" + if code.startswith('#!'): + return True + return bool(_ES14_SYNTAX_RE.search(code)) + + +def prepare_es14(code): + """Apply ES2023 source transforms before translation.""" + if code.startswith('#!'): + newline = code.find('\n') + if newline >= 0: + code = code[newline + 1:] + else: + code = '' + return code diff --git a/js2py/es7/__init__.py b/js2py/es7/__init__.py new file mode 100644 index 00000000..3fdcd4ed --- /dev/null +++ b/js2py/es7/__init__.py @@ -0,0 +1,254 @@ +"""ES7 (ES2016) support: exponentiation operator and includes().""" + +import re + +CP_STRING = ( + '"([^\\\\"]+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|x[0-9a-fA-F]{2}|' + 'u[0-9a-fA-F]{4}))*"|\'([^\\\\\']+|\\\\([bfnrtv\'"\\\\]|[0-3]?[0-7]{1,2}|' + 'x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}))*\'') +CP_STRING_PLACEHOLDER = '__PyJsSTR_%i_PyJsSTR__' + +_ES7_SYNTAX_RE = re.compile( + r'(?:' + r'\*\*=|\*\*|' + r'\.includes\s*\(' + r')', + re.MULTILINE) + + +def looks_like_es7(code): + """Return True if source likely contains ES7 syntax or APIs.""" + return bool(_ES7_SYNTAX_RE.search(code)) + + +def prepare_es7(code): + """Apply ES2016 source transforms before translation.""" + matches = [] + + def mask(match): + matches.append(match.group(0)) + return CP_STRING_PLACEHOLDER % (len(matches) - 1) + + masked = re.sub(CP_STRING, mask, code) + masked = _transform_exponentiation_assignment(masked) + masked = _transform_exponentiation(masked) + for index, value in enumerate(matches): + masked = masked.replace(CP_STRING_PLACEHOLDER % index, value, 1) + return masked + + +def _skip_ws(code, i, direction=1): + if direction >= 0: + while i < len(code) and code[i].isspace(): + i += 1 + return i + while i >= 0 and code[i].isspace(): + i -= 1 + return i + + +def _scan_lhs(code, op_start): + i = _skip_ws(code, op_start - 1, -1) + end = i + 1 + paren = bracket = 0 + in_str = None + while i >= 0: + ch = code[i] + if in_str: + if ch == '\\': + i -= 1 + continue + if ch == in_str: + in_str = None + i -= 1 + continue + if ch in ('"', "'"): + in_str = ch + i -= 1 + continue + if paren == 0 and bracket == 0: + if ch in '=,;:{}': + break + if ch == ')': + paren += 1 + elif ch == '(': + paren -= 1 + if paren < 0: + break + elif ch == ']': + bracket += 1 + elif ch == '[': + bracket -= 1 + if bracket < 0: + break + i -= 1 + start = i + 1 + while start < end and code[start].isspace(): + start += 1 + return start, end + + +def _scan_rhs(code, start): + i = _skip_ws(code, start, 1) + begin = i + paren = bracket = 0 + in_str = None + escape = False + while i < len(code): + ch = code[i] + if escape: + escape = False + i += 1 + continue + if in_str: + if ch == '\\': + escape = True + i += 1 + continue + if ch == in_str: + in_str = None + i += 1 + continue + if ch in ('"', "'"): + in_str = ch + i += 1 + continue + if paren == 0 and bracket == 0: + if ch in ',;': + break + if ch == '(': + paren += 1 + elif ch == ')': + paren -= 1 + elif ch == '[': + bracket += 1 + elif ch == ']': + bracket -= 1 + i += 1 + end = i + while end > begin and code[end - 1].isspace(): + end -= 1 + return begin, end + + +def _transform_exponentiation_assignment(code): + while True: + op_pos = code.find('**=') + if op_pos < 0: + break + lhs_start, lhs_end = _scan_lhs(code, op_pos) + rhs_start, rhs_end = _scan_rhs(code, op_pos + 3) + lhs = code[lhs_start:lhs_end].strip() + rhs = code[rhs_start:rhs_end].strip() + repl = '%s = Math.pow(%s, %s)' % (lhs, lhs, rhs) + code = code[:lhs_start] + repl + code[rhs_end:] + return code + + +def _find_exponentiation(code): + for i in range(len(code) - 1, 0, -1): + if code[i - 1:i + 1] == '**' and (i + 1 >= len(code) or code[i + 1] != '='): + return i - 1 + return -1 + + +def _scan_exp_lhs(code, op_start): + i = _skip_ws(code, op_start - 1, -1) + end = i + 1 + paren = bracket = 0 + in_str = None + while i >= 0: + ch = code[i] + if in_str: + if ch == '\\': + i -= 1 + continue + if ch == in_str: + in_str = None + i -= 1 + continue + if ch in ('"', "'"): + in_str = ch + i -= 1 + continue + if paren == 0 and bracket == 0: + if ch == '*' and i > 0 and code[i - 1] == '*' and (i - 1) != op_start: + break + if ch in '=,;:{}': + break + if ch == ')': + paren += 1 + elif ch == '(': + paren -= 1 + if paren < 0: + break + elif ch == ']': + bracket += 1 + elif ch == '[': + bracket -= 1 + if bracket < 0: + break + i -= 1 + start = i + 1 + while start < end and code[start].isspace(): + start += 1 + return start, end + + +def _scan_exp_rhs(code, start): + i = _skip_ws(code, start, 1) + begin = i + paren = bracket = 0 + in_str = None + escape = False + while i < len(code): + ch = code[i] + if escape: + escape = False + i += 1 + continue + if in_str: + if ch == '\\': + escape = True + i += 1 + continue + if ch == in_str: + in_str = None + i += 1 + continue + if ch in ('"', "'"): + in_str = ch + i += 1 + continue + if paren == 0 and bracket == 0: + if ch == '*' and i + 1 < len(code) and code[i + 1] == '*': + break + if ch in ',;': + break + if ch == '(': + paren += 1 + elif ch == ')': + paren -= 1 + elif ch == '[': + bracket += 1 + elif ch == ']': + bracket -= 1 + i += 1 + end = i + while end > begin and code[end - 1].isspace(): + end -= 1 + return begin, end + + +def _transform_exponentiation(code): + while True: + op_pos = _find_exponentiation(code) + if op_pos < 0: + break + lhs_start, lhs_end = _scan_exp_lhs(code, op_pos) + rhs_start, rhs_end = _scan_exp_rhs(code, op_pos + 2) + lhs = code[lhs_start:lhs_end].strip() + rhs = code[rhs_start:rhs_end].strip() + repl = 'Math.pow(%s, %s)' % (lhs, rhs) + code = code[:lhs_start] + repl + code[rhs_end:] + return code diff --git a/js2py/es8/__init__.py b/js2py/es8/__init__.py new file mode 100644 index 00000000..ca62ab15 --- /dev/null +++ b/js2py/es8/__init__.py @@ -0,0 +1,23 @@ +"""ES8 (ES2017) support: padStart/padEnd, Object.values/entries, getOwnPropertyDescriptors.""" + +import re + +_ES8_SYNTAX_RE = re.compile( + r'(?:' + r'\.padStart\s*\(|' + r'\.padEnd\s*\(|' + r'\bObject\.values\s*\(|' + r'\bObject\.entries\s*\(|' + r'\bObject\.getOwnPropertyDescriptors\s*\(' + r')', + re.MULTILINE) + + +def looks_like_es8(code): + """Return True if source likely contains ES8 syntax or APIs.""" + return bool(_ES8_SYNTAX_RE.search(code)) + + +def prepare_es8(code): + """Apply ES2017 source transforms before translation.""" + return code diff --git a/js2py/evaljs.py b/js2py/evaljs.py index d1658307..60ac0a79 100644 --- a/js2py/evaljs.py +++ b/js2py/evaljs.py @@ -12,15 +12,96 @@ __all__ = [ 'EvalJs', 'translate_js', 'import_js', 'eval_js', 'translate_file', - 'eval_js6', 'translate_js6', 'eval_js9', 'translate_js9', + 'eval_js6', 'translate_js6', 'eval_js7', 'translate_js7', + 'eval_js8', 'translate_js8', 'eval_js9', 'translate_js9', 'eval_js10', 'translate_js10', 'eval_js11', 'translate_js11', 'eval_js12', 'translate_js12', 'eval_js13', 'translate_js13', + 'eval_js14', 'translate_js14', 'run_file', 'disable_pyimport', 'drain_event_loop', 'get_file_contents', 'write_file_contents' ] DEBUG = False +def _strip_leading_use_strict(code): + code = code.lstrip('\ufeff') + while True: + stripped = code.lstrip() + for quote in ('"', "'"): + directive = quote + 'use strict' + quote + if stripped.startswith(directive): + rest = stripped[len(directive):].lstrip() + if rest.startswith(';'): + code = rest[1:].lstrip() + break + return code + else: + return code + + +def _split_top_level_statements(code): + parts = [] + current = [] + depth = 0 + in_single = in_double = in_template = False + escape = False + for char in code: + if escape: + escape = False + current.append(char) + continue + if char == '\\' and (in_single or in_double): + escape = True + current.append(char) + continue + if not in_single and not in_double and not in_template: + if char == "'": + in_single = True + elif char == '"': + in_double = True + elif char == '`': + in_template = True + elif char in '({[': + depth += 1 + elif char in ')}]': + depth = max(0, depth - 1) + elif char == ';' and depth == 0: + part = ''.join(current).strip() + if part: + parts.append(part) + current = [] + continue + elif char == '\n' and depth == 0: + part = ''.join(current).strip() + if part: + parts.append(part) + current = [] + continue + elif in_single and char == "'": + in_single = False + elif in_double and char == '"': + in_double = False + elif in_template and char == '`': + in_template = False + current.append(char) + remainder = ''.join(current).strip() + if remainder: + parts.append(remainder) + return parts + + +def _wrap_js_for_eval(prepared): + """Wrap prepared JS so the last statement's value becomes PyJsEvalResult.""" + prepared = _strip_leading_use_strict(prepared).strip() + if not prepared: + return 'PyJsEvalResult = undefined' + parts = _split_top_level_statements(prepared) + if len(parts) == 1: + return 'PyJsEvalResult = (%s)' % parts[0] + body = ';\n'.join(parts[:-1]) + return '%s;\nPyJsEvalResult = %s' % (body, parts[-1]) + + def disable_pyimport(): import pyjsparser.parser pyjsparser.parser.ENABLE_PYIMPORT = False @@ -61,18 +142,21 @@ def write_file_contents(path_or_file, contents): f.write(contents) -def translate_file(input_path, output_path, es6=False, es9=False, es10=False, - es11=False, es12=False, es13=False): +def translate_file(input_path, output_path, es6=False, es7=False, es8=False, es9=False, es10=False, + es11=False, es12=False, es13=False, es14=False): ''' Translates input JS file to python and saves the it to the output path. It appends some convenience code at the end so that it is easy to import JS objects. es6: False, True, or 'auto' — transpile ES6 via Babel before translation. + es7: False, True, or 'auto' — enable ES2016 features (**, includes). + es8: False, True, or 'auto' — enable ES2017 features (padStart, Object.values). es9: False, True, or 'auto' — enable ES2018 features (object spread/rest, etc.). es10: False, True, or 'auto' — enable ES2019 features (flat, trimStart, etc.). es11: False, True, or 'auto' — enable ES2020 features (??, ?., globalThis). es12: False, True, or 'auto' — enable ES2021 features (&&=, numeric separators). es13: False, True, or 'auto' — enable ES2022 features (at, Object.hasOwn). + es14: False, True, or 'auto' — enable ES2023 features (findLast, hashbang). For example we have a file 'example.js' with: var a = function(x) {return x} translate_file('example.js', 'example.py') @@ -84,8 +168,8 @@ def translate_file(input_path, output_path, es6=False, es9=False, es10=False, ''' js = get_file_contents(input_path) - py_code = translate_js(js, es6=es6, es9=es9, es10=es10, es11=es11, - es12=es12, es13=es13) + py_code = translate_js(js, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, + es12=es12, es13=es13, es14=es14) lib_name = os.path.basename(output_path).split('.')[0] head = '__all__ = [%s]\n\n# Don\'t look below, you will not understand this Python code :) I don\'t.\n\n' % repr( lib_name) @@ -105,17 +189,21 @@ def run_file(path_or_file, context=None): return eval_value, context -def eval_js(js, es6=False, es9=False, es10=False, es11=False, es12=False, es13=False): +def eval_js(js, es6=False, es7=False, es8=False, es9=False, es10=False, es11=False, es12=False, + es13=False, es14=False): """Just like javascript eval. Translates javascript to python, executes and returns python object. js is javascript source code es6: False, True, or 'auto' — see translate_js. + es7: False, True, or 'auto' — enable ES2016 features. + es8: False, True, or 'auto' — enable ES2017 features. es9: False, True, or 'auto' — enable ES2018 features. es10: False, True, or 'auto' — enable ES2019 features. es11: False, True, or 'auto' — enable ES2020 features. es12: False, True, or 'auto' — enable ES2021 features. es13: False, True, or 'auto' — enable ES2022 features. + es14: False, True, or 'auto' — enable ES2023 features. EXAMPLE: >>> import js2py @@ -132,12 +220,22 @@ def eval_js(js, es6=False, es9=False, es10=False, es11=False, es12=False, es13=F If you really want to convert object to python dict you can use to_dict method. """ e = EvalJs() - result = e.eval(js, es6=es6, es9=es9, es10=es10, es11=es11, es12=es12, - es13=es13) + result = e.eval(js, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, + es13=es13, es14=es14) drain_event_loop() return result +def eval_js14(js): + """Like eval_js with ES2023 support enabled.""" + return eval_js(js, es14=True) + + +def translate_js14(js): + """Like translate_js with ES2023 support enabled.""" + return translate_js(js, es14=True) + + def eval_js13(js): """Like eval_js with ES2022 support enabled.""" return eval_js(js, es13=True) @@ -190,12 +288,32 @@ def translate_js9(js): def eval_js6(js): """Just like eval_js but with experimental support for js6 via babel.""" - return eval_js(_prepare_js_source(js, True)) + return eval_js(js, es6=True) def translate_js6(js): """Just like translate_js but with experimental support for js6 via babel.""" - return translate_js(_prepare_js_source(js, True)) + return translate_js(js, es6=True) + + +def eval_js7(js): + """Like eval_js with ES2016 support enabled.""" + return eval_js(js, es7=True) + + +def translate_js7(js): + """Like translate_js with ES2016 support enabled.""" + return translate_js(js, es7=True) + + +def eval_js8(js): + """Like eval_js with ES2017 support enabled.""" + return eval_js(js, es8=True) + + +def translate_js8(js): + """Like translate_js with ES2017 support enabled.""" + return translate_js(js, es8=True) class EvalJs(object): @@ -244,16 +362,19 @@ def _js_require_impl(npm_module_name): for k, v in six.iteritems(context): setattr(self._var, k, v) - def execute(self, js=None, use_compilation_plan=False, es6=False, es9=False, - es10=False, es11=False, es12=False, es13=False): + def execute(self, js=None, use_compilation_plan=False, es6=False, es7=False, es8=False, es9=False, + es10=False, es11=False, es12=False, es13=False, es14=False): """executes javascript js in current context es6: False, True, or 'auto' — transpile ES6 via Babel before translation. + es7: False, True, or 'auto' — enable ES2016 features. + es8: False, True, or 'auto' — enable ES2017 features. es9: False, True, or 'auto' — enable ES2018 features. es10: False, True, or 'auto' — enable ES2019 features. es11: False, True, or 'auto' — enable ES2020 features. es12: False, True, or 'auto' — enable ES2021 features. es13: False, True, or 'auto' — enable ES2022 features. + es14: False, True, or 'auto' — enable ES2023 features. During initial execute() the converted js is cached for re-use. That means next time you run the same javascript snippet you save many instructions needed to parse and convert the @@ -269,29 +390,30 @@ def execute(self, js=None, use_compilation_plan=False, es6=False, es9=False, cache = self.__dict__['cache'] except KeyError: cache = self.__dict__['cache'] = {} - cache_key = (hashlib.md5(js.encode('utf-8')).digest(), es6, es9, es10, - es11, es12, es13) + cache_key = (hashlib.md5(js.encode('utf-8')).digest(), es6, es7, es8, es9, es10, + es11, es12, es13, es14) try: compiled = cache[cache_key] except KeyError: code = translate_js( js, '', use_compilation_plan=use_compilation_plan, - es6=es6, es9=es9, es10=es10, es11=es11, es12=es12, es13=es13) + es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, + es13=es13, es14=es14) compiled = cache[cache_key] = compile(code, '', 'exec') exec (compiled, self._context) drain_event_loop() - def eval(self, expression, use_compilation_plan=False, es6=False, es9=False, - es10=False, es11=False, es12=False, es13=False): + def eval(self, expression, use_compilation_plan=False, es6=False, es7=False, es8=False, es9=False, + es10=False, es11=False, es12=False, es13=False, es14=False): """evaluates expression in current context and returns its value""" expression = _prepare_js_source( - expression, es6=es6, es9=es9, es10=es10, es11=es11, es12=es12, - es13=es13) - code = 'PyJsEvalResult = %s' % expression + expression, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, + es13=es13, es14=es14) + code = _wrap_js_for_eval(expression) self.execute(code, use_compilation_plan=use_compilation_plan, - es6=es6, es9=es9, es10=es10, es11=es11, es12=es12, - es13=es13) + es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, + es13=es13, es14=es14) return self['PyJsEvalResult'] def execute_debug(self, js): diff --git a/js2py/prototypes/jsarray.py b/js2py/prototypes/jsarray.py index 270eaf4d..ffa70a21 100644 --- a/js2py/prototypes/jsarray.py +++ b/js2py/prototypes/jsarray.py @@ -304,6 +304,27 @@ def lastIndexOf(searchElement): k -= 1 return -1 + def includes(searchElement): + array = this.to_object() + arr_len = array.get('length').to_uint32() + if len(arguments) > 1: + fromIndex = arguments[1].to_int() + else: + fromIndex = 0 + k = fromIndex + if k < 0: + k = arr_len + k + if k < 0: + k = 0 + while k < arr_len: + if array.has_property(str(k)): + elementK = array.get(str(k)) + if (searchElement.is_nan() and elementK.is_nan()) or ( + searchElement.strict_equality_comparison(elementK).value): + return True + k += 1 + return False + def every(callbackfn): array = this.to_object() arr_len = array.get('length').to_uint32() @@ -389,6 +410,38 @@ def filter(callbackfn): k += 1 return res # converted to js array automatically + def findLast(callbackfn): + array = this.to_object() + arr_len = array.get('length').to_uint32() + if not callbackfn.is_callable(): + raise this.MakeError('TypeError', 'callbackfn must be a function') + T = arguments[1] if len(arguments) > 1 else this.undefined + k = arr_len - 1 + while k >= 0: + if array.has_property(str(k)): + kValue = array.get(str(k)) + if callbackfn.call( + T, (kValue, this.Js(k), array)).to_boolean().value: + return kValue + k -= 1 + return this.undefined + + def findLastIndex(callbackfn): + array = this.to_object() + arr_len = array.get('length').to_uint32() + if not callbackfn.is_callable(): + raise this.MakeError('TypeError', 'callbackfn must be a function') + T = arguments[1] if len(arguments) > 1 else this.undefined + k = arr_len - 1 + while k >= 0: + if array.has_property(str(k)): + kValue = array.get(str(k)) + if callbackfn.call( + T, (kValue, this.Js(k), array)).to_boolean().value: + return k + k -= 1 + return -1 + def reduce(callbackfn): array = this.to_object() arr_len = array.get('length').to_uint32() diff --git a/js2py/prototypes/jsstring.py b/js2py/prototypes/jsstring.py index ca044917..570dffb1 100644 --- a/js2py/prototypes/jsstring.py +++ b/js2py/prototypes/jsstring.py @@ -372,6 +372,36 @@ def trimLeft(): def trimRight(): return this.callprop('trimEnd') + def padStart(maxLength): + this.cok() + s = this.to_string().value + target = maxLength.to_int() + fill = ' ' + if len(arguments) > 1 and not arguments[1].is_undefined(): + fill = arguments[1].to_string().value + if not fill: + fill = ' ' + if len(s) >= target: + return this.Js(s) + pad_len = target - len(s) + repeated = (fill * ((pad_len // len(fill)) + 1))[:pad_len] + return this.Js(repeated + s) + + def padEnd(maxLength): + this.cok() + s = this.to_string().value + target = maxLength.to_int() + fill = ' ' + if len(arguments) > 1 and not arguments[1].is_undefined(): + fill = arguments[1].to_string().value + if not fill: + fill = ' ' + if len(s) >= target: + return this.Js(s) + pad_len = target - len(s) + repeated = (fill * ((pad_len // len(fill)) + 1))[:pad_len] + return this.Js(s + repeated) + def at(index): this.cok() s = this.to_string().value diff --git a/js2py/translators/translator.py b/js2py/translators/translator.py index 57b7140f..efe6cb4a 100644 --- a/js2py/translators/translator.py +++ b/js2py/translators/translator.py @@ -11,6 +11,18 @@ js6_to_js5 = None looks_like_es6 = None +try: + from ..es8 import looks_like_es8, prepare_es8 +except ImportError: + looks_like_es8 = None + prepare_es8 = None + +try: + from ..es7 import looks_like_es7, prepare_es7 +except ImportError: + looks_like_es7 = None + prepare_es7 = None + try: from ..es9 import looks_like_es9, prepare_es9 except ImportError: @@ -41,6 +53,12 @@ looks_like_es13 = None prepare_es13 = None +try: + from ..es14 import looks_like_es14, prepare_es14 +except ImportError: + looks_like_es14 = None + prepare_es14 = None + # Enable Js2Py exceptions and pyimport in parser pyjsparser.parser.ENABLE_PYIMPORT = True @@ -97,9 +115,14 @@ def pyjsparser_parse_fn(code): parser = pyjsparser.PyJsParser() return parser.parse(code) -def _prepare_js_source(js, es6=False, es9=False, es10=False, es11=False, es12=False, - es13=False): - """Optionally downlevel ES6/ES9–ES13 source before translation.""" +def _prepare_js_source(js, es6=False, es7=False, es8=False, es9=False, es10=False, es11=False, es12=False, + es13=False, es14=False): + """Optionally downlevel ES6/ES7–ES14 source before translation.""" + if es14 == 'auto': + if looks_like_es14 and looks_like_es14(js): + es14 = True + else: + es14 = False if es13 == 'auto': if looks_like_es13 and looks_like_es13(js): es13 = True @@ -125,11 +148,23 @@ def _prepare_js_source(js, es6=False, es9=False, es10=False, es11=False, es12=Fa es9 = True else: es9 = False + if es8 == 'auto': + if looks_like_es8 and looks_like_es8(js): + es8 = True + else: + es8 = False + if es7 == 'auto': + if looks_like_es7 and looks_like_es7(js): + es7 = True + else: + es7 = False if es6 == 'auto': if looks_like_es6 and looks_like_es6(js): es6 = True else: es6 = False + if es14 and prepare_es14: + js = prepare_es14(js) if es13 and prepare_es13: js = prepare_es13(js) if es12 and prepare_es12: @@ -140,6 +175,10 @@ def _prepare_js_source(js, es6=False, es9=False, es10=False, es11=False, es12=Fa js = prepare_es10(js) if es9 and prepare_es9: js = prepare_es9(js) + if es8 and prepare_es8: + js = prepare_es8(js) + if es7 and prepare_es7: + js = prepare_es7(js) if es6: if js6_to_js5 is None: raise RuntimeError('ES6 support is not available') @@ -148,20 +187,23 @@ def _prepare_js_source(js, es6=False, es9=False, es10=False, es11=False, es12=Fa def translate_js(js, HEADER=DEFAULT_HEADER, use_compilation_plan=False, - parse_fn=pyjsparser_parse_fn, es6=False, es9=False, - es10=False, es11=False, es12=False, es13=False): + parse_fn=pyjsparser_parse_fn, es6=False, es7=False, es8=False, es9=False, + es10=False, es11=False, es12=False, es13=False, es14=False): """js has to be a javascript source code. returns equivalent python code. es6: False (ES5 only), True (always transpile via Babel), or 'auto' (transpile when ES6 syntax is detected). + es7: False, True, or 'auto' — enable ES2016 features (**, includes). + es8: False, True, or 'auto' — enable ES2017 features (padStart, Object.values). es9: False, True, or 'auto' — enable ES2018 features (spread/rest, etc.). es10: False, True, or 'auto' — enable ES2019 features (flat, trimStart, etc.). es11: False, True, or 'auto' — enable ES2020 features (??, ?., globalThis). es12: False, True, or 'auto' — enable ES2021 features (&&=, numeric separators). - es13: False, True, or 'auto' — enable ES2022 features (at, Object.hasOwn).""" - js = _prepare_js_source(js, es6=es6, es9=es9, es10=es10, es11=es11, - es12=es12, es13=es13) + es13: False, True, or 'auto' — enable ES2022 features (at, Object.hasOwn). + es14: False, True, or 'auto' — enable ES2023 features (findLast, hashbang).""" + js = _prepare_js_source(js, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, + es12=es12, es13=es13, es14=es14) if use_compilation_plan and not '//' in js and not '/*' in js: return translate_js_with_compilation_plan(js, HEADER=HEADER) diff --git a/tests/test_es14.py b/tests/test_es14.py new file mode 100644 index 00000000..a667ebc5 --- /dev/null +++ b/tests/test_es14.py @@ -0,0 +1,82 @@ +"""Tests for ES14 (ES2023) JavaScript support.""" +import js2py +from js2py.es14 import looks_like_es14 + + +def test_looks_like_es14(): + assert looks_like_es14('[1, 2, 3].findLast(function(x) { return x > 1; })') + assert looks_like_es14('[1, 2, 3].findLastIndex(function(x) { return x > 1; })') + assert looks_like_es14('#!/usr/bin/env node\nvar x = 1;') + assert not looks_like_es14('var a = [1, 2]; a[0]') + + +def test_array_find_last(): + assert js2py.eval_js( + '[1, 2, 3, 4].findLast(function(x) { return x % 2 === 0; })', + es14=True) == 4 + + +def test_array_find_last_none(): + assert js2py.eval_js( + '[1, 3, 5].findLast(function(x) { return x % 2 === 0; })', + es14=True) is None + + +def test_array_find_last_index(): + assert js2py.eval_js( + '[1, 2, 3, 4].findLastIndex(function(x) { return x % 2 === 0; })', + es14=True) == 3 + + +def test_array_find_last_index_missing(): + assert js2py.eval_js( + '[1, 3, 5].findLastIndex(function(x) { return x % 2 === 0; })', + es14=True) == -1 + + +def test_array_find_last_sparse(): + assert js2py.eval_js( + '[1, , 3, 4].findLast(function(x) { return x > 2; })', + es14=True) == 4 + + +def test_array_find_last_auto(): + ctx = js2py.EvalJs() + ctx.execute( + 'var a = [10, 20, 30]; var v = a.findLast(function(x) { return x < 25; });', + es14='auto') + assert ctx.v == 20 + + +def test_hashbang_execute(): + ctx = js2py.EvalJs() + ctx.execute('#!/usr/bin/env node\nvar x = 42;', es14=True) + assert ctx.x == 42 + + +def test_hashbang_eval(): + assert js2py.eval_js('#!/usr/bin/env node\n1 + 2', es14=True) == 3 + + +def test_eval_js14(): + assert js2py.eval_js14( + '[5, 6, 7].findLastIndex(function(x) { return x < 7; })') == 1 + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) diff --git a/tests/test_es7.py b/tests/test_es7.py new file mode 100644 index 00000000..9955b33e --- /dev/null +++ b/tests/test_es7.py @@ -0,0 +1,80 @@ +"""Tests for ES7 (ES2016) JavaScript support.""" +import js2py +from js2py.es7 import looks_like_es7, prepare_es7 + + +def test_looks_like_es7(): + assert looks_like_es7('[1, 2, 3].includes(2)') + assert looks_like_es7('2 ** 3') + assert looks_like_es7('x **= 2') + assert not looks_like_es7('var a = [1, 2]; a.indexOf(1)') + + +def test_prepare_es7_exponentiation(): + out = prepare_es7('2 ** 3 ** 2') + assert 'Math.pow' in out + assert '**' not in out + + +def test_prepare_es7_exponentiation_assign(): + out = prepare_es7('x **= 3') + assert out == 'x = Math.pow(x, 3)' + + +def test_exponentiation(): + assert js2py.eval_js('2 ** 10', es7=True) == 1024 + + +def test_exponentiation_right_associative(): + assert js2py.eval_js('2 ** 3 ** 2', es7=True) == 512 + + +def test_exponentiation_assign(): + ctx = js2py.EvalJs() + ctx.execute('var x = 2; x **= 3;', es7=True) + assert ctx.x == 8 + + +def test_array_includes_true(): + assert js2py.eval_js('[1, 2, 3].includes(2)', es7=True) is True + + +def test_array_includes_false(): + assert js2py.eval_js('[1, 2, 3].includes(4)', es7=True) is False + + +def test_array_includes_nan(): + assert js2py.eval_js('[NaN].includes(NaN)', es7=True) is True + + +def test_array_includes_from_index(): + assert js2py.eval_js('[1, 2, 3].includes(2, 2)', es7=True) is False + + +def test_array_includes_auto(): + ctx = js2py.EvalJs() + ctx.execute('var ok = [10, 20].includes(20);', es7='auto') + assert ctx.ok is True + + +def test_eval_js7(): + assert js2py.eval_js7('3 ** 4') == 81 + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) diff --git a/tests/test_es8.py b/tests/test_es8.py new file mode 100644 index 00000000..c380ef0d --- /dev/null +++ b/tests/test_es8.py @@ -0,0 +1,74 @@ +"""Tests for ES8 (ES2017) JavaScript support.""" +import js2py +from js2py.es8 import looks_like_es8 + + +def test_looks_like_es8(): + assert looks_like_es8('"x".padStart(4)') + assert looks_like_es8('Object.values({a: 1})') + assert looks_like_es8('Object.getOwnPropertyDescriptors({})') + assert not looks_like_es8('Object.keys({a: 1})') + + +def test_object_values(): + assert list(js2py.eval_js('Object.values({a: 1, b: 2})', es8=True)) == [1, 2] + + +def test_object_entries(): + ctx = js2py.EvalJs() + ctx.execute('var e = Object.entries({x: 9, y: 8});', es8=True) + assert list(ctx.e[0]) == ['x', 9] + assert list(ctx.e[1]) == ['y', 8] + + +def test_string_pad_start(): + assert js2py.eval_js('"9".padStart(4, "0")', es8=True) == '0009' + assert js2py.eval_js('"abc".padStart(3)', es8=True) == 'abc' + assert js2py.eval_js('"abc".padStart(6, "123")', es8=True) == '123abc' + + +def test_string_pad_end(): + assert js2py.eval_js('"9".padEnd(4, "0")', es8=True) == '9000' + assert js2py.eval_js('"abc".padEnd(3)', es8=True) == 'abc' + assert js2py.eval_js('"abc".padEnd(6, "123")', es8=True) == 'abc123' + + +def test_pad_auto(): + ctx = js2py.EvalJs() + ctx.execute('var s = "hi".padEnd(5, "-");', es8='auto') + assert ctx.s == 'hi---' + + +def test_get_own_property_descriptors(): + ctx = js2py.EvalJs() + ctx.execute(''' + var o = {a: 1, b: 2}; + var d = Object.getOwnPropertyDescriptors(o); + var keys = Object.keys(d).sort().join(","); + ''', es8=True) + assert ctx.keys == 'a,b' + assert ctx.d.a.value == 1 + assert ctx.d.b.value == 2 + + +def test_eval_js8(): + assert list(js2py.eval_js8('Object.values({k: 5})')) == [5] + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) From 1409c6d9c7f37714b1ea49b86e27e0e421493a49 Mon Sep 17 00:00:00 2001 From: Gilead Cosman Date: Wed, 24 Jun 2026 21:30:26 -0700 Subject: [PATCH 09/11] Added ES15 support --- Makefile | 9 ++- js2py/__init__.py | 3 +- js2py/constructors/jsmath.py | 12 ++++ js2py/constructors/jsobject.py | 20 +++++++ js2py/constructors/jspromise.py | 29 ++++++++++ js2py/es15/__init__.py | 23 ++++++++ js2py/evaljs.py | 35 ++++++++---- js2py/prototypes/jsstring.py | 45 +++++++++++++++ js2py/translators/translator.py | 24 ++++++-- tests/test_es15.py | 98 +++++++++++++++++++++++++++++++++ 10 files changed, 279 insertions(+), 19 deletions(-) create mode 100644 js2py/es15/__init__.py create mode 100644 tests/test_es15.py diff --git a/Makefile b/Makefile index b3d43cbe..87459dd9 100644 --- a/Makefile +++ b/Makefile @@ -4,12 +4,13 @@ ROOT := $(CURDIR) export PYTHONPATH := $(ROOT) -.PHONY: help test test-simple test-es-all test-es6 test-es7 test-es8 test-es9 test-es10 test-es11 test-es12 test-es13 test-es14 test-language test-all +.PHONY: help test test-simple test-es-all test-es6 test-es7 test-es8 test-es9 test-es10 test-es11 test-es12 test-es13 test-es14 test-es15 test-language test-all help: @echo "Js2Py test targets:" @echo " make test Run quick integration tests (default)" @echo " make test-simple Run simple_test.py (ES5 + ES6 smoke tests)" + @echo " make test-es_ Run all tests/test_es*.py tests" @echo " make test-es6 Run tests/test_es6.py" @echo " make test-es7 Run tests/test_es7.py" @echo " make test-es8 Run tests/test_es8.py" @@ -19,6 +20,7 @@ help: @echo " make test-es12 Run tests/test_es12.py" @echo " make test-es13 Run tests/test_es13.py" @echo " make test-es14 Run tests/test_es14.py" + @echo " make test-es15 Run tests/test_es15.py" @echo " make test-language Run ES5.1 language suite (tests/run.py, slow)" @echo " make test-all Run quick tests and the language suite" @@ -28,7 +30,7 @@ test: test-simple test-es_ test-simple: PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/simple_test.py" -test-es_: test-es6 test-es7 test-es8 test-es9 test-es10 test-es11 test-es12 test-es13 test-es14 +test-es_: test-es6 test-es7 test-es8 test-es9 test-es10 test-es11 test-es12 test-es13 test-es14 test-es15 test-es6: PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es6.py" @@ -57,6 +59,9 @@ test-es13: test-es14: PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es14.py" +test-es15: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es15.py" + test-language: @test -f "$(ROOT)/tests/node_failed.txt" || touch "$(ROOT)/tests/node_failed.txt" cd "$(ROOT)/tests" && PYTHONPATH="$(ROOT)" $(PYTHON) run.py diff --git a/js2py/__init__.py b/js2py/__init__.py index a2e7458d..823bed3f 100644 --- a/js2py/__init__.py +++ b/js2py/__init__.py @@ -68,7 +68,8 @@ 'translate_js6', 'eval_js7', 'translate_js7', 'eval_js8', 'translate_js8', 'eval_js9', 'translate_js9', 'eval_js10', 'translate_js10', 'eval_js11', 'translate_js11', 'eval_js12', 'translate_js12', - 'eval_js13', 'translate_js13', 'eval_js14', 'translate_js14', 'drain_event_loop', + 'eval_js13', 'translate_js13', 'eval_js14', 'translate_js14', + 'eval_js15', 'translate_js15', 'drain_event_loop', 'PyJsException', 'get_file_contents', 'write_file_contents', 'require' ] diff --git a/js2py/constructors/jsmath.py b/js2py/constructors/jsmath.py index 06751a3b..1d652144 100644 --- a/js2py/constructors/jsmath.py +++ b/js2py/constructors/jsmath.py @@ -153,5 +153,17 @@ def max(): def random(): return random.random() + def sumPrecise(): + import math + values = [] + for i in range(len(arguments)): + n = arguments[i] + if n.TYPE != 'Number': + raise MakeError('TypeError', 'Math.sumPrecise requires numbers') + values.append(n.to_number().value) + if not values: + return +0.0 + return math.fsum(values) + fill_prototype(Math, MathFunctions, default_attrs) diff --git a/js2py/constructors/jsobject.py b/js2py/constructors/jsobject.py index 1dea5ead..f4f69071 100644 --- a/js2py/constructors/jsobject.py +++ b/js2py/constructors/jsobject.py @@ -204,6 +204,26 @@ def getOwnPropertyDescriptors(obj): result.put(name, desc_obj) return result + def groupBy(items, callbackfn): + if not callbackfn.is_callable(): + raise MakeError('TypeError', 'callbackfn must be a function') + items_obj = items.to_object() + length_val = items_obj.get('length') + if length_val.TYPE != 'Number': + raise MakeError('TypeError', 'Object.groupBy requires an iterable') + length = length_val.to_uint32() + groups = {} + for k in range(length): + if items_obj.has_property(str(k)): + value = items_obj.get(str(k)) + key = callbackfn.call(undefined, (value, Js(k), items_obj)) + key_str = key.to_string().value + groups.setdefault(key_str, []).append(value) + result = PyJsObject(prototype=null) + for key_str, values in six.iteritems(groups): + result.put(key_str, PyJsArray(values, ArrayPrototype)) + return result + # add methods attached to Object constructor fill_prototype(Object, ObjectMethods, default_attrs) diff --git a/js2py/constructors/jspromise.py b/js2py/constructors/jspromise.py index 31aa5d1b..64e417ad 100644 --- a/js2py/constructors/jspromise.py +++ b/js2py/constructors/jspromise.py @@ -310,6 +310,29 @@ def on_rejected(reason): return result_promise +@Js +def promise_with_resolvers(): + out = PyJsObject(prototype=ObjectPrototype) + holder = {'promise': None} + + @Js + def resolve(value): + _resolve_promise(holder['promise'], value) + return undefined + + @Js + def reject(reason): + _reject_promise(holder['promise'], reason) + return undefined + + promise = _create_promise(None) + holder['promise'] = promise + out.put('promise', promise) + out.put('resolve', resolve) + out.put('reject', reject) + return out + + Promise.define_own_property('resolve', { 'value': promise_resolve, 'writable': True, @@ -334,6 +357,12 @@ def on_rejected(reason): 'enumerable': False, 'configurable': True }) +Promise.define_own_property('withResolvers', { + 'value': promise_with_resolvers, + 'writable': True, + 'enumerable': False, + 'configurable': True +}) Promise.define_own_property( 'prototype', { 'value': PromisePrototype, diff --git a/js2py/es15/__init__.py b/js2py/es15/__init__.py new file mode 100644 index 00000000..78eac88d --- /dev/null +++ b/js2py/es15/__init__.py @@ -0,0 +1,23 @@ +"""ES15 (ES2024) support: groupBy, withResolvers, well-formed strings, sumPrecise.""" + +import re + +_ES15_SYNTAX_RE = re.compile( + r'(?:' + r'\bObject\.groupBy\s*\(|' + r'\bPromise\.withResolvers\s*\(|' + r'\.isWellFormed\s*\(|' + r'\.toWellFormed\s*\(|' + r'\bMath\.sumPrecise\s*\(' + r')', + re.MULTILINE) + + +def looks_like_es15(code): + """Return True if source likely contains ES15 syntax or APIs.""" + return bool(_ES15_SYNTAX_RE.search(code)) + + +def prepare_es15(code): + """Apply ES2024 source transforms before translation.""" + return code diff --git a/js2py/evaljs.py b/js2py/evaljs.py index 60ac0a79..2945dfc6 100644 --- a/js2py/evaljs.py +++ b/js2py/evaljs.py @@ -16,7 +16,7 @@ 'eval_js8', 'translate_js8', 'eval_js9', 'translate_js9', 'eval_js10', 'translate_js10', 'eval_js11', 'translate_js11', 'eval_js12', 'translate_js12', 'eval_js13', 'translate_js13', - 'eval_js14', 'translate_js14', + 'eval_js14', 'translate_js14', 'eval_js15', 'translate_js15', 'run_file', 'disable_pyimport', 'drain_event_loop', 'get_file_contents', 'write_file_contents' ] @@ -143,7 +143,7 @@ def write_file_contents(path_or_file, contents): def translate_file(input_path, output_path, es6=False, es7=False, es8=False, es9=False, es10=False, - es11=False, es12=False, es13=False, es14=False): + es11=False, es12=False, es13=False, es14=False, es15=False): ''' Translates input JS file to python and saves the it to the output path. It appends some convenience code at the end so that it is easy to import JS objects. @@ -157,6 +157,7 @@ def translate_file(input_path, output_path, es6=False, es7=False, es8=False, es9 es12: False, True, or 'auto' — enable ES2021 features (&&=, numeric separators). es13: False, True, or 'auto' — enable ES2022 features (at, Object.hasOwn). es14: False, True, or 'auto' — enable ES2023 features (findLast, hashbang). + es15: False, True, or 'auto' — enable ES2024 features (groupBy, withResolvers). For example we have a file 'example.js' with: var a = function(x) {return x} translate_file('example.js', 'example.py') @@ -169,7 +170,7 @@ def translate_file(input_path, output_path, es6=False, es7=False, es8=False, es9 js = get_file_contents(input_path) py_code = translate_js(js, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, - es12=es12, es13=es13, es14=es14) + es12=es12, es13=es13, es14=es14, es15=es15) lib_name = os.path.basename(output_path).split('.')[0] head = '__all__ = [%s]\n\n# Don\'t look below, you will not understand this Python code :) I don\'t.\n\n' % repr( lib_name) @@ -190,7 +191,7 @@ def run_file(path_or_file, context=None): def eval_js(js, es6=False, es7=False, es8=False, es9=False, es10=False, es11=False, es12=False, - es13=False, es14=False): + es13=False, es14=False, es15=False): """Just like javascript eval. Translates javascript to python, executes and returns python object. js is javascript source code @@ -204,6 +205,7 @@ def eval_js(js, es6=False, es7=False, es8=False, es9=False, es10=False, es11=Fal es12: False, True, or 'auto' — enable ES2021 features. es13: False, True, or 'auto' — enable ES2022 features. es14: False, True, or 'auto' — enable ES2023 features. + es15: False, True, or 'auto' — enable ES2024 features. EXAMPLE: >>> import js2py @@ -221,11 +223,21 @@ def eval_js(js, es6=False, es7=False, es8=False, es9=False, es10=False, es11=Fal """ e = EvalJs() result = e.eval(js, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, - es13=es13, es14=es14) + es13=es13, es14=es14, es15=es15) drain_event_loop() return result +def eval_js15(js): + """Like eval_js with ES2024 support enabled.""" + return eval_js(js, es15=True) + + +def translate_js15(js): + """Like translate_js with ES2024 support enabled.""" + return translate_js(js, es15=True) + + def eval_js14(js): """Like eval_js with ES2023 support enabled.""" return eval_js(js, es14=True) @@ -363,7 +375,7 @@ def _js_require_impl(npm_module_name): setattr(self._var, k, v) def execute(self, js=None, use_compilation_plan=False, es6=False, es7=False, es8=False, es9=False, - es10=False, es11=False, es12=False, es13=False, es14=False): + es10=False, es11=False, es12=False, es13=False, es14=False, es15=False): """executes javascript js in current context es6: False, True, or 'auto' — transpile ES6 via Babel before translation. @@ -375,6 +387,7 @@ def execute(self, js=None, use_compilation_plan=False, es6=False, es7=False, es8 es12: False, True, or 'auto' — enable ES2021 features. es13: False, True, or 'auto' — enable ES2022 features. es14: False, True, or 'auto' — enable ES2023 features. + es15: False, True, or 'auto' — enable ES2024 features. During initial execute() the converted js is cached for re-use. That means next time you run the same javascript snippet you save many instructions needed to parse and convert the @@ -391,29 +404,29 @@ def execute(self, js=None, use_compilation_plan=False, es6=False, es7=False, es8 except KeyError: cache = self.__dict__['cache'] = {} cache_key = (hashlib.md5(js.encode('utf-8')).digest(), es6, es7, es8, es9, es10, - es11, es12, es13, es14) + es11, es12, es13, es14, es15) try: compiled = cache[cache_key] except KeyError: code = translate_js( js, '', use_compilation_plan=use_compilation_plan, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, - es13=es13, es14=es14) + es13=es13, es14=es14, es15=es15) compiled = cache[cache_key] = compile(code, '', 'exec') exec (compiled, self._context) drain_event_loop() def eval(self, expression, use_compilation_plan=False, es6=False, es7=False, es8=False, es9=False, - es10=False, es11=False, es12=False, es13=False, es14=False): + es10=False, es11=False, es12=False, es13=False, es14=False, es15=False): """evaluates expression in current context and returns its value""" expression = _prepare_js_source( expression, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, - es13=es13, es14=es14) + es13=es13, es14=es14, es15=es15) code = _wrap_js_for_eval(expression) self.execute(code, use_compilation_plan=use_compilation_plan, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, - es13=es13, es14=es14) + es13=es13, es14=es14, es15=es15) return self['PyJsEvalResult'] def execute_debug(self, js): diff --git a/js2py/prototypes/jsstring.py b/js2py/prototypes/jsstring.py index 570dffb1..9967c5f2 100644 --- a/js2py/prototypes/jsstring.py +++ b/js2py/prototypes/jsstring.py @@ -402,6 +402,51 @@ def padEnd(maxLength): repeated = (fill * ((pad_len // len(fill)) + 1))[:pad_len] return this.Js(s + repeated) + def isWellFormed(): + this.cok() + s = this.to_string().value + i = 0 + n = len(s) + while i < n: + c = ord(s[i]) + if 0xD800 <= c <= 0xDBFF: + if i + 1 >= n: + return False + c2 = ord(s[i + 1]) + if not (0xDC00 <= c2 <= 0xDFFF): + return False + i += 2 + elif 0xDC00 <= c <= 0xDFFF: + return False + else: + i += 1 + return True + + def toWellFormed(): + this.cok() + s = this.to_string().value + out = [] + i = 0 + n = len(s) + while i < n: + c = ord(s[i]) + if 0xD800 <= c <= 0xDBFF: + if i + 1 < n: + c2 = ord(s[i + 1]) + if 0xDC00 <= c2 <= 0xDFFF: + out.append(s[i:i + 2]) + i += 2 + continue + out.append('\uFFFD') + i += 1 + elif 0xDC00 <= c <= 0xDFFF: + out.append('\uFFFD') + i += 1 + else: + out.append(s[i]) + i += 1 + return this.Js(''.join(out)) + def at(index): this.cok() s = this.to_string().value diff --git a/js2py/translators/translator.py b/js2py/translators/translator.py index efe6cb4a..0928cf43 100644 --- a/js2py/translators/translator.py +++ b/js2py/translators/translator.py @@ -53,6 +53,12 @@ looks_like_es13 = None prepare_es13 = None +try: + from ..es15 import looks_like_es15, prepare_es15 +except ImportError: + looks_like_es15 = None + prepare_es15 = None + try: from ..es14 import looks_like_es14, prepare_es14 except ImportError: @@ -116,8 +122,13 @@ def pyjsparser_parse_fn(code): return parser.parse(code) def _prepare_js_source(js, es6=False, es7=False, es8=False, es9=False, es10=False, es11=False, es12=False, - es13=False, es14=False): - """Optionally downlevel ES6/ES7–ES14 source before translation.""" + es13=False, es14=False, es15=False): + """Optionally downlevel ES6/ES7–ES15 source before translation.""" + if es15 == 'auto': + if looks_like_es15 and looks_like_es15(js): + es15 = True + else: + es15 = False if es14 == 'auto': if looks_like_es14 and looks_like_es14(js): es14 = True @@ -163,6 +174,8 @@ def _prepare_js_source(js, es6=False, es7=False, es8=False, es9=False, es10=Fals es6 = True else: es6 = False + if es15 and prepare_es15: + js = prepare_es15(js) if es14 and prepare_es14: js = prepare_es14(js) if es13 and prepare_es13: @@ -188,7 +201,7 @@ def _prepare_js_source(js, es6=False, es7=False, es8=False, es9=False, es10=Fals def translate_js(js, HEADER=DEFAULT_HEADER, use_compilation_plan=False, parse_fn=pyjsparser_parse_fn, es6=False, es7=False, es8=False, es9=False, - es10=False, es11=False, es12=False, es13=False, es14=False): + es10=False, es11=False, es12=False, es13=False, es14=False, es15=False): """js has to be a javascript source code. returns equivalent python code. @@ -201,9 +214,10 @@ def translate_js(js, HEADER=DEFAULT_HEADER, use_compilation_plan=False, es11: False, True, or 'auto' — enable ES2020 features (??, ?., globalThis). es12: False, True, or 'auto' — enable ES2021 features (&&=, numeric separators). es13: False, True, or 'auto' — enable ES2022 features (at, Object.hasOwn). - es14: False, True, or 'auto' — enable ES2023 features (findLast, hashbang).""" + es14: False, True, or 'auto' — enable ES2023 features (findLast, hashbang). + es15: False, True, or 'auto' — enable ES2024 features (groupBy, withResolvers).""" js = _prepare_js_source(js, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, - es12=es12, es13=es13, es14=es14) + es12=es12, es13=es13, es14=es14, es15=es15) if use_compilation_plan and not '//' in js and not '/*' in js: return translate_js_with_compilation_plan(js, HEADER=HEADER) diff --git a/tests/test_es15.py b/tests/test_es15.py new file mode 100644 index 00000000..ab8890b4 --- /dev/null +++ b/tests/test_es15.py @@ -0,0 +1,98 @@ +"""Tests for ES15 (ES2024) JavaScript support.""" +import js2py +from js2py.es15 import looks_like_es15 +from js2py.event_loop import drain_event_loop + + +def test_looks_like_es15(): + assert looks_like_es15('Object.groupBy([], function() {})') + assert looks_like_es15('Promise.withResolvers()') + assert looks_like_es15('"x".isWellFormed()') + assert looks_like_es15('Math.sumPrecise(1, 2)') + assert not looks_like_es15('Object.keys({})') + + +def test_object_group_by(): + js = ''' + Object.groupBy([1, 2, 3, 4, 5, 6], function(x) { + return x % 2 === 0 ? "even" : "odd"; + }).even.length + ''' + assert js2py.eval_js(js, es15=True) == 3 + js2 = ''' + Object.groupBy([1, 2, 3, 4, 5, 6], function(x) { + return x % 2 === 0 ? "even" : "odd"; + }).even[0] + ''' + assert js2py.eval_js(js2, es15=True) == 2 + + +def test_object_group_by_auto(): + assert js2py.eval_js( + 'Object.groupBy(["a", "b", "aa"], function(s) { return s.length; })["1"].length', + es15='auto') == 2 + assert js2py.eval_js( + 'Object.groupBy(["a", "b", "aa"], function(s) { return s.length; })["2"][0]', + es15='auto') == 'aa' + + +def test_promise_with_resolvers(): + ctx = js2py.EvalJs() + ctx.execute('var r = Promise.withResolvers(); r.resolve(42);', es15=True) + drain_event_loop() + assert ctx.r.promise is not None + + +def test_promise_with_resolvers_value(): + ctx = js2py.EvalJs() + ctx.execute(''' + var out; + var r = Promise.withResolvers(); + r.promise.then(function(v) { out = v; }); + r.resolve(99); + ''', es15=True) + drain_event_loop() + assert ctx.out == 99 + + +def test_string_is_well_formed_true(): + assert js2py.eval_js('"hello".isWellFormed()', es15=True) is True + + +def test_string_is_well_formed_false(): + assert js2py.eval_js('"\\uD800".isWellFormed()', es15=True) is False + + +def test_string_to_well_formed(): + assert js2py.eval_js('"\\uD800".toWellFormed()', es15=True) == '\ufffd' + + +def test_math_sum_precise(): + assert js2py.eval_js('Math.sumPrecise(1, 2, 3)', es15=True) == 6 + + +def test_math_sum_precise_floats(): + assert js2py.eval_js('Math.sumPrecise(1e20, 1, -1e20)', es15=True) == 1 + + +def test_eval_js15(): + assert js2py.eval_js15('Math.sumPrecise(10, 20)') == 30 + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) From 2d9c04c8718cee5c25aa2e121166df6c9022e985 Mon Sep 17 00:00:00 2001 From: Gilead Cosman Date: Wed, 24 Jun 2026 21:33:44 -0700 Subject: [PATCH 10/11] Added ES16 support --- Makefile | 8 ++- js2py/__init__.py | 2 +- js2py/constructors/jspromise.py | 23 ++++++++ js2py/constructors/jsregexp.py | 28 ++++++++++ js2py/es16/__init__.py | 22 ++++++++ js2py/evaljs.py | 34 ++++++++---- js2py/prototypes/jsjson.py | 38 +++++++++++++ js2py/translators/translator.py | 24 ++++++-- tests/test_es16.py | 97 +++++++++++++++++++++++++++++++++ 9 files changed, 258 insertions(+), 18 deletions(-) create mode 100644 js2py/es16/__init__.py create mode 100644 tests/test_es16.py diff --git a/Makefile b/Makefile index 87459dd9..b5027207 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ ROOT := $(CURDIR) export PYTHONPATH := $(ROOT) -.PHONY: help test test-simple test-es-all test-es6 test-es7 test-es8 test-es9 test-es10 test-es11 test-es12 test-es13 test-es14 test-es15 test-language test-all +.PHONY: help test test-simple test-es-all test-es6 test-es7 test-es8 test-es9 test-es10 test-es11 test-es12 test-es13 test-es14 test-es15 test-es16 test-language test-all help: @echo "Js2Py test targets:" @@ -21,6 +21,7 @@ help: @echo " make test-es13 Run tests/test_es13.py" @echo " make test-es14 Run tests/test_es14.py" @echo " make test-es15 Run tests/test_es15.py" + @echo " make test-es16 Run tests/test_es16.py" @echo " make test-language Run ES5.1 language suite (tests/run.py, slow)" @echo " make test-all Run quick tests and the language suite" @@ -30,7 +31,7 @@ test: test-simple test-es_ test-simple: PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/simple_test.py" -test-es_: test-es6 test-es7 test-es8 test-es9 test-es10 test-es11 test-es12 test-es13 test-es14 test-es15 +test-es_: test-es6 test-es7 test-es8 test-es9 test-es10 test-es11 test-es12 test-es13 test-es14 test-es15 test-es16 test-es6: PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es6.py" @@ -62,6 +63,9 @@ test-es14: test-es15: PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es15.py" +test-es16: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es16.py" + test-language: @test -f "$(ROOT)/tests/node_failed.txt" || touch "$(ROOT)/tests/node_failed.txt" cd "$(ROOT)/tests" && PYTHONPATH="$(ROOT)" $(PYTHON) run.py diff --git a/js2py/__init__.py b/js2py/__init__.py index 823bed3f..782cf13e 100644 --- a/js2py/__init__.py +++ b/js2py/__init__.py @@ -69,7 +69,7 @@ 'eval_js9', 'translate_js9', 'eval_js10', 'translate_js10', 'eval_js11', 'translate_js11', 'eval_js12', 'translate_js12', 'eval_js13', 'translate_js13', 'eval_js14', 'translate_js14', - 'eval_js15', 'translate_js15', 'drain_event_loop', + 'eval_js15', 'translate_js15', 'eval_js16', 'translate_js16', 'drain_event_loop', 'PyJsException', 'get_file_contents', 'write_file_contents', 'require' ] diff --git a/js2py/constructors/jspromise.py b/js2py/constructors/jspromise.py index 64e417ad..12d942f5 100644 --- a/js2py/constructors/jspromise.py +++ b/js2py/constructors/jspromise.py @@ -333,6 +333,23 @@ def reject(reason): return out +@Js +def promise_try(callback): + args = arguments.to_list()[1:] if len(arguments) > 1 else [] + if not callback.is_callable(): + raise MakeError('TypeError', 'Promise.try callback must be a function') + try: + result = callback.call(undefined, tuple(args)) + except PyJsException as exc: + return promise_reject(PyExceptionToJs(exc)) + except Exception as exc: + return promise_reject(PyExceptionToJs(exc)) + nested = _unwrap_promise(result) + if nested is not None: + return nested + return promise_resolve(result) + + Promise.define_own_property('resolve', { 'value': promise_resolve, 'writable': True, @@ -363,6 +380,12 @@ def reject(reason): 'enumerable': False, 'configurable': True }) +Promise.define_own_property('try', { + 'value': promise_try, + 'writable': True, + 'enumerable': False, + 'configurable': True +}) Promise.define_own_property( 'prototype', { 'value': PromisePrototype, diff --git a/js2py/constructors/jsregexp.py b/js2py/constructors/jsregexp.py index 2dfc25cb..7e63b7e1 100644 --- a/js2py/constructors/jsregexp.py +++ b/js2py/constructors/jsregexp.py @@ -1,5 +1,33 @@ from ..base import * +_REGEXP_ESCAPE_SYNTAX = set('^$\\.*+?()[]{}|') + + +@Js +def regexp_escape(string): + s = string.to_string().value + out = [] + for i, ch in enumerate(s): + if ch in _REGEXP_ESCAPE_SYNTAX: + out.append('\\' + ch) + elif i == 0: + o = ord(ch) + if (48 <= o <= 57) or (65 <= o <= 90) or (97 <= o <= 122): + out.append('\\x%02x' % o) + else: + out.append(ch) + else: + out.append(ch) + return Js(''.join(out)) + + +RegExp.define_own_property('escape', { + 'value': regexp_escape, + 'writable': True, + 'enumerable': False, + 'configurable': True +}) + RegExpPrototype.define_own_property('constructor', { 'value': RegExp, 'enumerable': False, diff --git a/js2py/es16/__init__.py b/js2py/es16/__init__.py new file mode 100644 index 00000000..61af1a87 --- /dev/null +++ b/js2py/es16/__init__.py @@ -0,0 +1,22 @@ +"""ES16 (ES2025) support: RegExp.escape, Promise.try, JSON rawJSON.""" + +import re + +_ES16_SYNTAX_RE = re.compile( + r'(?:' + r'\bRegExp\.escape\s*\(|' + r'\bPromise\.try\s*\(|' + r'\bJSON\.rawJSON\s*\(|' + r'\bJSON\.isRawJSON\s*\(' + r')', + re.MULTILINE) + + +def looks_like_es16(code): + """Return True if source likely contains ES16 syntax or APIs.""" + return bool(_ES16_SYNTAX_RE.search(code)) + + +def prepare_es16(code): + """Apply ES2025 source transforms before translation.""" + return code diff --git a/js2py/evaljs.py b/js2py/evaljs.py index 2945dfc6..5871d57e 100644 --- a/js2py/evaljs.py +++ b/js2py/evaljs.py @@ -17,6 +17,7 @@ 'eval_js10', 'translate_js10', 'eval_js11', 'translate_js11', 'eval_js12', 'translate_js12', 'eval_js13', 'translate_js13', 'eval_js14', 'translate_js14', 'eval_js15', 'translate_js15', + 'eval_js16', 'translate_js16', 'run_file', 'disable_pyimport', 'drain_event_loop', 'get_file_contents', 'write_file_contents' ] @@ -143,7 +144,7 @@ def write_file_contents(path_or_file, contents): def translate_file(input_path, output_path, es6=False, es7=False, es8=False, es9=False, es10=False, - es11=False, es12=False, es13=False, es14=False, es15=False): + es11=False, es12=False, es13=False, es14=False, es15=False, es16=False): ''' Translates input JS file to python and saves the it to the output path. It appends some convenience code at the end so that it is easy to import JS objects. @@ -158,6 +159,7 @@ def translate_file(input_path, output_path, es6=False, es7=False, es8=False, es9 es13: False, True, or 'auto' — enable ES2022 features (at, Object.hasOwn). es14: False, True, or 'auto' — enable ES2023 features (findLast, hashbang). es15: False, True, or 'auto' — enable ES2024 features (groupBy, withResolvers). + es16: False, True, or 'auto' — enable ES2025 features (RegExp.escape, Promise.try). For example we have a file 'example.js' with: var a = function(x) {return x} translate_file('example.js', 'example.py') @@ -170,7 +172,7 @@ def translate_file(input_path, output_path, es6=False, es7=False, es8=False, es9 js = get_file_contents(input_path) py_code = translate_js(js, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, - es12=es12, es13=es13, es14=es14, es15=es15) + es12=es12, es13=es13, es14=es14, es15=es15, es16=es16) lib_name = os.path.basename(output_path).split('.')[0] head = '__all__ = [%s]\n\n# Don\'t look below, you will not understand this Python code :) I don\'t.\n\n' % repr( lib_name) @@ -191,7 +193,7 @@ def run_file(path_or_file, context=None): def eval_js(js, es6=False, es7=False, es8=False, es9=False, es10=False, es11=False, es12=False, - es13=False, es14=False, es15=False): + es13=False, es14=False, es15=False, es16=False): """Just like javascript eval. Translates javascript to python, executes and returns python object. js is javascript source code @@ -206,6 +208,7 @@ def eval_js(js, es6=False, es7=False, es8=False, es9=False, es10=False, es11=Fal es13: False, True, or 'auto' — enable ES2022 features. es14: False, True, or 'auto' — enable ES2023 features. es15: False, True, or 'auto' — enable ES2024 features. + es16: False, True, or 'auto' — enable ES2025 features. EXAMPLE: >>> import js2py @@ -223,11 +226,21 @@ def eval_js(js, es6=False, es7=False, es8=False, es9=False, es10=False, es11=Fal """ e = EvalJs() result = e.eval(js, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, - es13=es13, es14=es14, es15=es15) + es13=es13, es14=es14, es15=es15, es16=es16) drain_event_loop() return result +def eval_js16(js): + """Like eval_js with ES2025 support enabled.""" + return eval_js(js, es16=True) + + +def translate_js16(js): + """Like translate_js with ES2025 support enabled.""" + return translate_js(js, es16=True) + + def eval_js15(js): """Like eval_js with ES2024 support enabled.""" return eval_js(js, es15=True) @@ -375,7 +388,7 @@ def _js_require_impl(npm_module_name): setattr(self._var, k, v) def execute(self, js=None, use_compilation_plan=False, es6=False, es7=False, es8=False, es9=False, - es10=False, es11=False, es12=False, es13=False, es14=False, es15=False): + es10=False, es11=False, es12=False, es13=False, es14=False, es15=False, es16=False): """executes javascript js in current context es6: False, True, or 'auto' — transpile ES6 via Babel before translation. @@ -388,6 +401,7 @@ def execute(self, js=None, use_compilation_plan=False, es6=False, es7=False, es8 es13: False, True, or 'auto' — enable ES2022 features. es14: False, True, or 'auto' — enable ES2023 features. es15: False, True, or 'auto' — enable ES2024 features. + es16: False, True, or 'auto' — enable ES2025 features. During initial execute() the converted js is cached for re-use. That means next time you run the same javascript snippet you save many instructions needed to parse and convert the @@ -404,29 +418,29 @@ def execute(self, js=None, use_compilation_plan=False, es6=False, es7=False, es8 except KeyError: cache = self.__dict__['cache'] = {} cache_key = (hashlib.md5(js.encode('utf-8')).digest(), es6, es7, es8, es9, es10, - es11, es12, es13, es14, es15) + es11, es12, es13, es14, es15, es16) try: compiled = cache[cache_key] except KeyError: code = translate_js( js, '', use_compilation_plan=use_compilation_plan, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, - es13=es13, es14=es14, es15=es15) + es13=es13, es14=es14, es15=es15, es16=es16) compiled = cache[cache_key] = compile(code, '', 'exec') exec (compiled, self._context) drain_event_loop() def eval(self, expression, use_compilation_plan=False, es6=False, es7=False, es8=False, es9=False, - es10=False, es11=False, es12=False, es13=False, es14=False, es15=False): + es10=False, es11=False, es12=False, es13=False, es14=False, es15=False, es16=False): """evaluates expression in current context and returns its value""" expression = _prepare_js_source( expression, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, - es13=es13, es14=es14, es15=es15) + es13=es13, es14=es14, es15=es15, es16=es16) code = _wrap_js_for_eval(expression) self.execute(code, use_compilation_plan=use_compilation_plan, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, - es13=es13, es14=es14, es15=es15) + es13=es13, es14=es14, es15=es15, es16=es16) return self['PyJsEvalResult'] def execute_debug(self, js): diff --git a/js2py/prototypes/jsjson.py b/js2py/prototypes/jsjson.py index 9f7ccbb0..e1d92f7c 100644 --- a/js2py/prototypes/jsjson.py +++ b/js2py/prototypes/jsjson.py @@ -1,6 +1,7 @@ import json from ..base import Js indent = '' +RAW_JSON_MARKER = '__PyJsRawJSON__' # python 3 support import six if six.PY3: @@ -68,6 +69,10 @@ def stringify(value, replacer, space): def Str(key, holder, replacer_function, property_list, gap, stack, space): value = holder[key] + if value.is_object(): + raw = value.get(RAW_JSON_MARKER) + if not raw.is_undefined(): + return raw.to_string().value if value.is_object(): to_json = value.get('toJSON') if to_json.is_callable(): @@ -200,6 +205,23 @@ def walk(holder, name, reviver): return reviver.call(holder, (name, val)) +def rawJSON(text): + s = text.to_string().value + try: + json.loads(s) + except Exception: + raise this.MakeError('SyntaxError', 'Invalid raw JSON text') + obj = this.Js({}) + obj.put(RAW_JSON_MARKER, this.Js(s)) + return obj + + +def isRawJSON(value): + if not value.is_object(): + return False + return not value.get(RAW_JSON_MARKER).is_undefined() + + JSON = Js({}) JSON.define_own_property( @@ -217,3 +239,19 @@ def walk(holder, name, reviver): 'writable': True, 'configurable': True }) + +JSON.define_own_property( + 'rawJSON', { + 'value': Js(rawJSON), + 'enumerable': False, + 'writable': True, + 'configurable': True + }) + +JSON.define_own_property( + 'isRawJSON', { + 'value': Js(isRawJSON), + 'enumerable': False, + 'writable': True, + 'configurable': True + }) diff --git a/js2py/translators/translator.py b/js2py/translators/translator.py index 0928cf43..02403014 100644 --- a/js2py/translators/translator.py +++ b/js2py/translators/translator.py @@ -53,6 +53,12 @@ looks_like_es13 = None prepare_es13 = None +try: + from ..es16 import looks_like_es16, prepare_es16 +except ImportError: + looks_like_es16 = None + prepare_es16 = None + try: from ..es15 import looks_like_es15, prepare_es15 except ImportError: @@ -122,8 +128,13 @@ def pyjsparser_parse_fn(code): return parser.parse(code) def _prepare_js_source(js, es6=False, es7=False, es8=False, es9=False, es10=False, es11=False, es12=False, - es13=False, es14=False, es15=False): - """Optionally downlevel ES6/ES7–ES15 source before translation.""" + es13=False, es14=False, es15=False, es16=False): + """Optionally downlevel ES6/ES7–ES16 source before translation.""" + if es16 == 'auto': + if looks_like_es16 and looks_like_es16(js): + es16 = True + else: + es16 = False if es15 == 'auto': if looks_like_es15 and looks_like_es15(js): es15 = True @@ -174,6 +185,8 @@ def _prepare_js_source(js, es6=False, es7=False, es8=False, es9=False, es10=Fals es6 = True else: es6 = False + if es16 and prepare_es16: + js = prepare_es16(js) if es15 and prepare_es15: js = prepare_es15(js) if es14 and prepare_es14: @@ -201,7 +214,7 @@ def _prepare_js_source(js, es6=False, es7=False, es8=False, es9=False, es10=Fals def translate_js(js, HEADER=DEFAULT_HEADER, use_compilation_plan=False, parse_fn=pyjsparser_parse_fn, es6=False, es7=False, es8=False, es9=False, - es10=False, es11=False, es12=False, es13=False, es14=False, es15=False): + es10=False, es11=False, es12=False, es13=False, es14=False, es15=False, es16=False): """js has to be a javascript source code. returns equivalent python code. @@ -215,9 +228,10 @@ def translate_js(js, HEADER=DEFAULT_HEADER, use_compilation_plan=False, es12: False, True, or 'auto' — enable ES2021 features (&&=, numeric separators). es13: False, True, or 'auto' — enable ES2022 features (at, Object.hasOwn). es14: False, True, or 'auto' — enable ES2023 features (findLast, hashbang). - es15: False, True, or 'auto' — enable ES2024 features (groupBy, withResolvers).""" + es15: False, True, or 'auto' — enable ES2024 features (groupBy, withResolvers). + es16: False, True, or 'auto' — enable ES2025 features (RegExp.escape, Promise.try).""" js = _prepare_js_source(js, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, - es12=es12, es13=es13, es14=es14, es15=es15) + es12=es12, es13=es13, es14=es14, es15=es15, es16=es16) if use_compilation_plan and not '//' in js and not '/*' in js: return translate_js_with_compilation_plan(js, HEADER=HEADER) diff --git a/tests/test_es16.py b/tests/test_es16.py new file mode 100644 index 00000000..03c13dbe --- /dev/null +++ b/tests/test_es16.py @@ -0,0 +1,97 @@ +"""Tests for ES16 (ES2025) JavaScript support.""" +import js2py +from js2py.es16 import looks_like_es16 +from js2py.event_loop import drain_event_loop + + +def test_looks_like_es16(): + assert looks_like_es16('RegExp.escape("x")') + assert looks_like_es16('Promise.try(function() {})') + assert looks_like_es16('JSON.rawJSON("1")') + assert looks_like_es16('JSON.isRawJSON({})') + assert not looks_like_es16('JSON.parse("{}")') + + +def test_regexp_escape_literal(): + assert js2py.eval_js('RegExp.escape("hello")', es16=True) == '\\x68ello' + + +def test_regexp_escape_syntax(): + assert js2py.eval_js('RegExp.escape("(a|b)")', es16=True) == '\\(a\\|b\\)' + + +def test_regexp_escape_match(): + assert js2py.eval_js( + 'new RegExp(RegExp.escape("a*b")).test("a*b")', es16=True) is True + + +def test_regexp_escape_auto(): + assert js2py.eval_js('RegExp.escape("x.y").indexOf("\\\\.") >= 0', es16='auto') is True + + +def test_promise_try_success(): + ctx = js2py.EvalJs() + ctx.execute(''' + var out; + Promise.try(function() { return 7; }).then(function(v) { out = v; }); + ''', es16=True) + drain_event_loop() + assert ctx.out == 7 + + +def test_promise_try_with_args(): + ctx = js2py.EvalJs() + ctx.execute(''' + var out; + Promise.try(function(a, b) { return a + b; }, 3, 4).then(function(v) { out = v; }); + ''', es16=True) + drain_event_loop() + assert ctx.out == 7 + + +def test_promise_try_reject(): + ctx = js2py.EvalJs() + ctx.execute(''' + var err; + Promise.try(function() { throw "boom"; }).catch(function(e) { err = e; }); + ''', es16=True) + drain_event_loop() + assert ctx.err == 'boom' + + +def test_json_raw_json_stringify(): + assert js2py.eval_js('JSON.stringify(JSON.rawJSON("1"))', es16=True) == '1' + assert js2py.eval_js('JSON.stringify(JSON.rawJSON("\\"hi\\""))', es16=True) == '"hi"' + + +def test_json_raw_json_embedded(): + assert js2py.eval_js( + 'JSON.stringify({x: JSON.rawJSON("\\"hi\\"")})', es16=True) == '{"x":"hi"}' + + +def test_json_is_raw_json(): + assert js2py.eval_js('JSON.isRawJSON(JSON.rawJSON("1"))', es16=True) is True + assert js2py.eval_js('JSON.isRawJSON(1)', es16=True) is False + + +def test_eval_js16(): + assert js2py.eval_js16('RegExp.escape("test")') == '\\x74est' + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0) From d90dda3ac0b225ea4f81aa016d3b6e54d37fe88b Mon Sep 17 00:00:00 2001 From: Gilead Cosman Date: Wed, 24 Jun 2026 21:39:35 -0700 Subject: [PATCH 11/11] Added ES.Next support --- Makefile | 8 +- js2py/__init__.py | 3 +- js2py/base.py | 15 +++ js2py/constructors/jsiterator.py | 58 +++++++++ js2py/esnext/__init__.py | 200 +++++++++++++++++++++++++++++++ js2py/evaljs.py | 35 ++++-- js2py/pyjs.py | 2 + js2py/translators/translator.py | 25 +++- tests/test_esnext.py | 100 ++++++++++++++++ 9 files changed, 427 insertions(+), 19 deletions(-) create mode 100644 js2py/constructors/jsiterator.py create mode 100644 js2py/esnext/__init__.py create mode 100644 tests/test_esnext.py diff --git a/Makefile b/Makefile index b5027207..42f5b52d 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ ROOT := $(CURDIR) export PYTHONPATH := $(ROOT) -.PHONY: help test test-simple test-es-all test-es6 test-es7 test-es8 test-es9 test-es10 test-es11 test-es12 test-es13 test-es14 test-es15 test-es16 test-language test-all +.PHONY: help test test-simple test-es-all test-es6 test-es7 test-es8 test-es9 test-es10 test-es11 test-es12 test-es13 test-es14 test-es15 test-es16 test-esnext test-language test-all help: @echo "Js2Py test targets:" @@ -22,6 +22,7 @@ help: @echo " make test-es14 Run tests/test_es14.py" @echo " make test-es15 Run tests/test_es15.py" @echo " make test-es16 Run tests/test_es16.py" + @echo " make test-esnext Run tests/test_esnext.py" @echo " make test-language Run ES5.1 language suite (tests/run.py, slow)" @echo " make test-all Run quick tests and the language suite" @@ -31,7 +32,7 @@ test: test-simple test-es_ test-simple: PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/simple_test.py" -test-es_: test-es6 test-es7 test-es8 test-es9 test-es10 test-es11 test-es12 test-es13 test-es14 test-es15 test-es16 +test-es_: test-es6 test-es7 test-es8 test-es9 test-es10 test-es11 test-es12 test-es13 test-es14 test-es15 test-es16 test-esnext test-es6: PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es6.py" @@ -66,6 +67,9 @@ test-es15: test-es16: PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es16.py" +test-esnext: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_esnext.py" + test-language: @test -f "$(ROOT)/tests/node_failed.txt" || touch "$(ROOT)/tests/node_failed.txt" cd "$(ROOT)/tests" && PYTHONPATH="$(ROOT)" $(PYTHON) run.py diff --git a/js2py/__init__.py b/js2py/__init__.py index 782cf13e..5ba42d48 100644 --- a/js2py/__init__.py +++ b/js2py/__init__.py @@ -69,7 +69,8 @@ 'eval_js9', 'translate_js9', 'eval_js10', 'translate_js10', 'eval_js11', 'translate_js11', 'eval_js12', 'translate_js12', 'eval_js13', 'translate_js13', 'eval_js14', 'translate_js14', - 'eval_js15', 'translate_js15', 'eval_js16', 'translate_js16', 'drain_event_loop', + 'eval_js15', 'translate_js15', 'eval_js16', 'translate_js16', + 'eval_jsnext', 'translate_jsnext', 'drain_event_loop', 'PyJsException', 'get_file_contents', 'write_file_contents', 'require' ] diff --git a/js2py/base.py b/js2py/base.py index a1c4501c..d7413bbd 100644 --- a/js2py/base.py +++ b/js2py/base.py @@ -3044,6 +3044,21 @@ def TypeError(message): for e in ERROR_NAMES: define_error_type(e + 'Error') + +@Js +def error_is_error(value): + if not value.is_object(): + return False + return value.instanceof(Error).to_boolean().value + + +Error.define_own_property('isError', { + 'value': error_is_error, + 'writable': True, + 'enumerable': False, + 'configurable': True +}) + ############################################################################## # Import and fill prototypes here. diff --git a/js2py/constructors/jsiterator.py b/js2py/constructors/jsiterator.py new file mode 100644 index 00000000..ce9d1d82 --- /dev/null +++ b/js2py/constructors/jsiterator.py @@ -0,0 +1,58 @@ +from ..base import * + + +def _iterable_kind(iterable): + obj = iterable.to_object() + next_fn = obj.get('next') + if next_fn.is_callable(): + return 'iterator', obj + length = obj.get('length') + if length.TYPE == 'Number': + return 'array', obj + raise MakeError('TypeError', 'Iterator.concat requires iterables') + + +@Js +def iterator_concat(): + specs = [] + for i in range(len(arguments)): + specs.append(_iterable_kind(arguments[i])) + state = {'spec_idx': 0, 'index': 0} + + @Js + def next_method(): + while state['spec_idx'] < len(specs): + kind, data = specs[state['spec_idx']] + if kind == 'array': + idx = state['index'] + length = data.get('length').to_uint32() + if idx < length: + if data.has_property(str(idx)): + val = data.get(str(idx)) + else: + val = undefined + state['index'] += 1 + result = PyJsObject(prototype=ObjectPrototype) + result.put('value', val) + result.put('done', false) + return result + state['spec_idx'] += 1 + state['index'] = 0 + continue + result = data.callprop('next') + if result.get('done').to_boolean().value: + state['spec_idx'] += 1 + continue + return result + result = PyJsObject(prototype=ObjectPrototype) + result.put('value', undefined) + result.put('done', true) + return result + + iterator = PyJsObject(prototype=ObjectPrototype) + iterator.put('next', next_method) + return iterator + + +Iterator = PyJsObject(prototype=ObjectPrototype) +Iterator.put('concat', Js(iterator_concat)) diff --git a/js2py/esnext/__init__.py b/js2py/esnext/__init__.py new file mode 100644 index 00000000..f632ddcc --- /dev/null +++ b/js2py/esnext/__init__.py @@ -0,0 +1,200 @@ +"""ES.Next support: staging features beyond ES16 (Error.isError, Iterator.concat, using).""" + +import re + +_ESNEXT_SYNTAX_RE = re.compile( + r'(?:' + r'\bError\.isError\s*\(|' + r'\bIterator\.concat\s*\(|' + r'\busing\s+[A-Za-z_$][\w$]*\s*=' + r')', + re.MULTILINE) + +_USING_STMT_RE = re.compile( + r'^\s*using\s+([A-Za-z_$][\w$]*)\s*=\s*', + re.MULTILINE) + + +def _scan_expression_until_semicolon(code, start): + i = start + depth_paren = depth_bracket = depth_brace = 0 + in_single = in_double = False + escape = False + while i < len(code): + ch = code[i] + if escape: + escape = False + i += 1 + continue + if in_single or in_double: + if ch == '\\': + escape = True + elif in_single and ch == "'": + in_single = False + elif in_double and ch == '"': + in_double = False + i += 1 + continue + if ch == "'": + in_single = True + i += 1 + continue + if ch == '"': + in_double = True + i += 1 + continue + if ch == '(': + depth_paren += 1 + elif ch == ')': + depth_paren = max(0, depth_paren - 1) + elif ch == '[': + depth_bracket += 1 + elif ch == ']': + depth_bracket = max(0, depth_bracket - 1) + elif ch == '{': + depth_brace += 1 + elif ch == '}': + depth_brace = max(0, depth_brace - 1) + elif (depth_paren == 0 and depth_bracket == 0 and depth_brace == 0 + and ch == ';'): + return code[start:i].strip(), i + 1 + i += 1 + return code[start:].strip(), len(code) + + +def looks_like_esnext(code): + """Return True if source likely contains ES.Next syntax or APIs.""" + return bool(_ESNEXT_SYNTAX_RE.search(code)) + + +def _desugar_block_using(inner): + pos = 0 + usings = [] + while pos < len(inner): + match = _USING_STMT_RE.match(inner, pos) + if not match: + break + name = match.group(1) + init, end = _scan_expression_until_semicolon(inner, match.end()) + usings.append((name, init)) + pos = end + while pos < len(inner) and inner[pos] in ' \t\r\n': + pos += 1 + if not usings: + return inner + body = inner[pos:] + declarations = ';\n'.join('var %s = (%s)' % (name, init) for name, init in usings) + disposals = [] + for name, _ in reversed(usings): + disposals.append( + 'if (%s != null && %s !== undefined) {' + ' var __PyJsDispose = %s.dispose;' + ' if (typeof __PyJsDispose === "function") __PyJsDispose.call(%s);' + ' }' % (name, name, name, name)) + return ( + declarations + ';\n' + 'try {\n' + body + '\n' + '} finally {\n' + '\n'.join(disposals) + '\n' + '}') + + +def _find_matching_brace(code, start): + i = start + 1 + depth = 1 + in_single = in_double = in_template = False + escape = False + while i < len(code) and depth: + ch = code[i] + if escape: + escape = False + i += 1 + continue + if in_single or in_double or in_template: + if ch == '\\': + escape = True + elif in_single and ch == "'": + in_single = False + elif in_double and ch == '"': + in_double = False + elif in_template and ch == '`': + in_template = False + i += 1 + continue + if ch == "'": + in_single = True + i += 1 + continue + if ch == '"': + in_double = True + i += 1 + continue + if ch == '`': + in_template = True + i += 1 + continue + if ch == '{': + depth += 1 + elif ch == '}': + depth -= 1 + i += 1 + return i - 1 + + +def _transform_using_blocks(code): + if 'using ' not in code: + return code + out = [] + i = 0 + in_single = in_double = in_template = False + escape = False + while i < len(code): + ch = code[i] + if escape: + escape = False + out.append(ch) + i += 1 + continue + if in_single or in_double or in_template: + if ch == '\\': + escape = True + elif in_single and ch == "'": + in_single = False + elif in_double and ch == '"': + in_double = False + elif in_template and ch == '`': + in_template = False + out.append(ch) + i += 1 + continue + if ch == "'": + in_single = True + out.append(ch) + i += 1 + continue + if ch == '"': + in_double = True + out.append(ch) + i += 1 + continue + if ch == '`': + in_template = True + out.append(ch) + i += 1 + continue + if ch == '{': + end = _find_matching_brace(code, i) + inner = code[i + 1:end] + inner = _transform_using_blocks(inner) + if re.search(r'\busing\s+', inner): + inner = _desugar_block_using(inner) + out.append('{' + inner + '}') + i = end + 1 + continue + out.append(ch) + i += 1 + return ''.join(out) + + +def prepare_esnext(code): + """Apply ES.Next source transforms before translation.""" + return _transform_using_blocks(code) diff --git a/js2py/evaljs.py b/js2py/evaljs.py index 5871d57e..868498c4 100644 --- a/js2py/evaljs.py +++ b/js2py/evaljs.py @@ -17,7 +17,7 @@ 'eval_js10', 'translate_js10', 'eval_js11', 'translate_js11', 'eval_js12', 'translate_js12', 'eval_js13', 'translate_js13', 'eval_js14', 'translate_js14', 'eval_js15', 'translate_js15', - 'eval_js16', 'translate_js16', + 'eval_js16', 'translate_js16', 'eval_jsnext', 'translate_jsnext', 'run_file', 'disable_pyimport', 'drain_event_loop', 'get_file_contents', 'write_file_contents' ] @@ -144,7 +144,7 @@ def write_file_contents(path_or_file, contents): def translate_file(input_path, output_path, es6=False, es7=False, es8=False, es9=False, es10=False, - es11=False, es12=False, es13=False, es14=False, es15=False, es16=False): + es11=False, es12=False, es13=False, es14=False, es15=False, es16=False, esnext=False): ''' Translates input JS file to python and saves the it to the output path. It appends some convenience code at the end so that it is easy to import JS objects. @@ -160,6 +160,7 @@ def translate_file(input_path, output_path, es6=False, es7=False, es8=False, es9 es14: False, True, or 'auto' — enable ES2023 features (findLast, hashbang). es15: False, True, or 'auto' — enable ES2024 features (groupBy, withResolvers). es16: False, True, or 'auto' — enable ES2025 features (RegExp.escape, Promise.try). + esnext: False, True, or 'auto' — enable ES.Next staging features (isError, using). For example we have a file 'example.js' with: var a = function(x) {return x} translate_file('example.js', 'example.py') @@ -172,7 +173,7 @@ def translate_file(input_path, output_path, es6=False, es7=False, es8=False, es9 js = get_file_contents(input_path) py_code = translate_js(js, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, - es12=es12, es13=es13, es14=es14, es15=es15, es16=es16) + es12=es12, es13=es13, es14=es14, es15=es15, es16=es16, esnext=esnext) lib_name = os.path.basename(output_path).split('.')[0] head = '__all__ = [%s]\n\n# Don\'t look below, you will not understand this Python code :) I don\'t.\n\n' % repr( lib_name) @@ -193,7 +194,7 @@ def run_file(path_or_file, context=None): def eval_js(js, es6=False, es7=False, es8=False, es9=False, es10=False, es11=False, es12=False, - es13=False, es14=False, es15=False, es16=False): + es13=False, es14=False, es15=False, es16=False, esnext=False): """Just like javascript eval. Translates javascript to python, executes and returns python object. js is javascript source code @@ -209,6 +210,7 @@ def eval_js(js, es6=False, es7=False, es8=False, es9=False, es10=False, es11=Fal es14: False, True, or 'auto' — enable ES2023 features. es15: False, True, or 'auto' — enable ES2024 features. es16: False, True, or 'auto' — enable ES2025 features. + esnext: False, True, or 'auto' — enable ES.Next staging features. EXAMPLE: >>> import js2py @@ -226,11 +228,21 @@ def eval_js(js, es6=False, es7=False, es8=False, es9=False, es10=False, es11=Fal """ e = EvalJs() result = e.eval(js, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, - es13=es13, es14=es14, es15=es15, es16=es16) + es13=es13, es14=es14, es15=es15, es16=es16, esnext=esnext) drain_event_loop() return result +def eval_jsnext(js): + """Like eval_js with ES.Next staging support enabled.""" + return eval_js(js, esnext=True) + + +def translate_jsnext(js): + """Like translate_js with ES.Next staging support enabled.""" + return translate_js(js, esnext=True) + + def eval_js16(js): """Like eval_js with ES2025 support enabled.""" return eval_js(js, es16=True) @@ -388,7 +400,7 @@ def _js_require_impl(npm_module_name): setattr(self._var, k, v) def execute(self, js=None, use_compilation_plan=False, es6=False, es7=False, es8=False, es9=False, - es10=False, es11=False, es12=False, es13=False, es14=False, es15=False, es16=False): + es10=False, es11=False, es12=False, es13=False, es14=False, es15=False, es16=False, esnext=False): """executes javascript js in current context es6: False, True, or 'auto' — transpile ES6 via Babel before translation. @@ -402,6 +414,7 @@ def execute(self, js=None, use_compilation_plan=False, es6=False, es7=False, es8 es14: False, True, or 'auto' — enable ES2023 features. es15: False, True, or 'auto' — enable ES2024 features. es16: False, True, or 'auto' — enable ES2025 features. + esnext: False, True, or 'auto' — enable ES.Next staging features. During initial execute() the converted js is cached for re-use. That means next time you run the same javascript snippet you save many instructions needed to parse and convert the @@ -418,29 +431,29 @@ def execute(self, js=None, use_compilation_plan=False, es6=False, es7=False, es8 except KeyError: cache = self.__dict__['cache'] = {} cache_key = (hashlib.md5(js.encode('utf-8')).digest(), es6, es7, es8, es9, es10, - es11, es12, es13, es14, es15, es16) + es11, es12, es13, es14, es15, es16, esnext) try: compiled = cache[cache_key] except KeyError: code = translate_js( js, '', use_compilation_plan=use_compilation_plan, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, - es13=es13, es14=es14, es15=es15, es16=es16) + es13=es13, es14=es14, es15=es15, es16=es16, esnext=esnext) compiled = cache[cache_key] = compile(code, '', 'exec') exec (compiled, self._context) drain_event_loop() def eval(self, expression, use_compilation_plan=False, es6=False, es7=False, es8=False, es9=False, - es10=False, es11=False, es12=False, es13=False, es14=False, es15=False, es16=False): + es10=False, es11=False, es12=False, es13=False, es14=False, es15=False, es16=False, esnext=False): """evaluates expression in current context and returns its value""" expression = _prepare_js_source( expression, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, - es13=es13, es14=es14, es15=es15, es16=es16) + es13=es13, es14=es14, es15=es15, es16=es16, esnext=esnext) code = _wrap_js_for_eval(expression) self.execute(code, use_compilation_plan=use_compilation_plan, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, es12=es12, - es13=es13, es14=es14, es15=es15, es16=es16) + es13=es13, es14=es14, es15=es15, es16=es16, esnext=esnext) return self['PyJsEvalResult'] def execute_debug(self, js): diff --git a/js2py/pyjs.py b/js2py/pyjs.py index 5349ec37..0d0d9d94 100644 --- a/js2py/pyjs.py +++ b/js2py/pyjs.py @@ -8,6 +8,7 @@ from .constructors.jsboolean import Boolean from .constructors.jsregexp import RegExp from .constructors.jsarray import Array +from .constructors.jsiterator import Iterator from .constructors.jspromise import Promise from .constructors.jsarraybuffer import ArrayBuffer from .constructors.jsint8array import Int8Array @@ -51,6 +52,7 @@ 'Object', 'Function', 'Array', + 'Iterator', 'Promise', 'Int8Array', 'Uint8Array', diff --git a/js2py/translators/translator.py b/js2py/translators/translator.py index 02403014..01d74dfc 100644 --- a/js2py/translators/translator.py +++ b/js2py/translators/translator.py @@ -53,6 +53,12 @@ looks_like_es13 = None prepare_es13 = None +try: + from ..esnext import looks_like_esnext, prepare_esnext +except ImportError: + looks_like_esnext = None + prepare_esnext = None + try: from ..es16 import looks_like_es16, prepare_es16 except ImportError: @@ -128,8 +134,13 @@ def pyjsparser_parse_fn(code): return parser.parse(code) def _prepare_js_source(js, es6=False, es7=False, es8=False, es9=False, es10=False, es11=False, es12=False, - es13=False, es14=False, es15=False, es16=False): - """Optionally downlevel ES6/ES7–ES16 source before translation.""" + es13=False, es14=False, es15=False, es16=False, esnext=False): + """Optionally downlevel ES6/ES7–ES16/ES.Next source before translation.""" + if esnext == 'auto': + if looks_like_esnext and looks_like_esnext(js): + esnext = True + else: + esnext = False if es16 == 'auto': if looks_like_es16 and looks_like_es16(js): es16 = True @@ -185,6 +196,8 @@ def _prepare_js_source(js, es6=False, es7=False, es8=False, es9=False, es10=Fals es6 = True else: es6 = False + if esnext and prepare_esnext: + js = prepare_esnext(js) if es16 and prepare_es16: js = prepare_es16(js) if es15 and prepare_es15: @@ -214,7 +227,7 @@ def _prepare_js_source(js, es6=False, es7=False, es8=False, es9=False, es10=Fals def translate_js(js, HEADER=DEFAULT_HEADER, use_compilation_plan=False, parse_fn=pyjsparser_parse_fn, es6=False, es7=False, es8=False, es9=False, - es10=False, es11=False, es12=False, es13=False, es14=False, es15=False, es16=False): + es10=False, es11=False, es12=False, es13=False, es14=False, es15=False, es16=False, esnext=False): """js has to be a javascript source code. returns equivalent python code. @@ -229,9 +242,11 @@ def translate_js(js, HEADER=DEFAULT_HEADER, use_compilation_plan=False, es13: False, True, or 'auto' — enable ES2022 features (at, Object.hasOwn). es14: False, True, or 'auto' — enable ES2023 features (findLast, hashbang). es15: False, True, or 'auto' — enable ES2024 features (groupBy, withResolvers). - es16: False, True, or 'auto' — enable ES2025 features (RegExp.escape, Promise.try).""" + es16: False, True, or 'auto' — enable ES2025 features (RegExp.escape, Promise.try). + esnext: False, True, or 'auto' — enable ES.Next staging features (isError, using).""" js = _prepare_js_source(js, es6=es6, es7=es7, es8=es8, es9=es9, es10=es10, es11=es11, - es12=es12, es13=es13, es14=es14, es15=es15, es16=es16) + es12=es12, es13=es13, es14=es14, es15=es15, es16=es16, + esnext=esnext) if use_compilation_plan and not '//' in js and not '/*' in js: return translate_js_with_compilation_plan(js, HEADER=HEADER) diff --git a/tests/test_esnext.py b/tests/test_esnext.py new file mode 100644 index 00000000..cb010ca6 --- /dev/null +++ b/tests/test_esnext.py @@ -0,0 +1,100 @@ +"""Tests for ES.Next (staging) JavaScript support.""" +import js2py +from js2py.esnext import looks_like_esnext, prepare_esnext + + +def test_looks_like_esnext(): + assert looks_like_esnext('Error.isError(new Error("x"))') + assert looks_like_esnext('Iterator.concat([[1]])') + assert looks_like_esnext('{ using x = foo(); }') + assert not looks_like_esnext('Error("x")') + + +def test_prepare_esnext_using(): + out = prepare_esnext('{ using r = get(); x = 1; }') + assert 'try {' in out + assert 'finally {' in out + assert 'r.dispose' in out + assert 'using ' not in out + + +def test_error_is_error_true(): + assert js2py.eval_js('Error.isError(new Error("x"))', esnext=True) is True + assert js2py.eval_js('Error.isError(new TypeError("x"))', esnext=True) is True + + +def test_error_is_error_false(): + assert js2py.eval_js('Error.isError({})', esnext=True) is False + assert js2py.eval_js('Error.isError("err")', esnext=True) is False + + +def test_iterator_concat_arrays(): + js = ''' + var it = Iterator.concat([1, 2], [3]); + var out = []; + var step; + while (!(step = it.next()).done) { out.push(step.value); } + out.length + ''' + assert js2py.eval_js(js, esnext=True) == 3 + + +def test_iterator_concat_values(): + assert js2py.eval_js( + 'Iterator.concat([10, 20]).next().value', esnext=True) == 10 + + +def test_iterator_concat_auto(): + assert js2py.eval_js( + 'Iterator.concat([5]).next().value', esnext='auto') == 5 + + +def test_using_dispose(): + ctx = js2py.EvalJs() + ctx.execute(''' + var disposed = false; + var result; + { + using r = { dispose: function() { disposed = true; } }; + result = 42; + } + ''', esnext=True) + assert ctx.result == 42 + assert ctx.disposed is True + + +def test_using_dispose_on_throw(): + ctx = js2py.EvalJs() + ctx.execute(''' + var disposed = false; + try { + { + using r = { dispose: function() { disposed = true; } }; + throw "fail"; + } + } catch (e) {} + ''', esnext=True) + assert ctx.disposed is True + + +def test_eval_jsnext(): + assert js2py.eval_jsnext('Error.isError(new Error(1))') is True + + +if __name__ == '__main__': + import inspect + import sys + + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + print('ok', name) + except Exception as exc: + failed += 1 + print('FAIL', name, exc) + import traceback + traceback.print_exc() + sys.exit(1 if failed else 0)