From 46a18998a400e04044605867403ecc56b9b0c4bd Mon Sep 17 00:00:00 2001 From: Gilead Cosman Date: Wed, 24 Jun 2026 09:11:53 -0700 Subject: [PATCH 1/3] 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 2/3] 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 121a8081145766f0f71717c05ca03235340789bd Mon Sep 17 00:00:00 2001 From: Gilead Cosman Date: Wed, 24 Jun 2026 12:15:22 -0700 Subject: [PATCH 3/3] Added ES7 support --- Makefile | 8 +- js2py/__init__.py | 2 +- js2py/es7/__init__.py | 113 ++++++++++++++++++++++++++++ js2py/evaljs.py | 40 ++++++---- js2py/prototypes/jsarray.py | 26 +++++++ js2py/translators/friendly_nodes.py | 6 +- js2py/translators/translator.py | 25 ++++-- setup.py | 3 +- tests/test_es7.py | 61 +++++++++++++++ 9 files changed, 261 insertions(+), 23 deletions(-) create mode 100644 js2py/es7/__init__.py create mode 100644 tests/test_es7.py diff --git a/Makefile b/Makefile index 3d567254..21ba0bf7 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-es7 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-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-es7 @: test-simple: @@ -23,6 +24,9 @@ test-simple: test-es6: PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es6.py" +test-es7: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_es7.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..5c4822f3 100644 --- a/js2py/__init__.py +++ b/js2py/__init__.py @@ -65,7 +65,7 @@ __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_js7', 'translate_js7', 'PyJsException', 'get_file_contents', 'write_file_contents', 'require' ] diff --git a/js2py/es7/__init__.py b/js2py/es7/__init__.py new file mode 100644 index 00000000..c06d5406 --- /dev/null +++ b/js2py/es7/__init__.py @@ -0,0 +1,113 @@ +"""ES7 (ES2016) support: exponentiation operator and related syntax detection.""" + +import re + +_ES7_SYNTAX_RE = re.compile( + r'(?:' + r'(? stack_prec: + return False + if stack_op == '**' or new_op == '**': + return False + return True + + _orig_parse_binary = parser.PyJsParser.parseBinaryExpression + + def parse_binary_expression(self): + marker = self.lookahead + left = self.inheritCoverGrammar(self.parseUnaryExpression) + + token = self.lookahead + prec = self.binaryPrecedence(token, self.state['allowIn']) + if prec == 0: + return left + self.isAssignmentTarget = self.isBindingElement = parser.false + token['prec'] = prec + self.lex() + + markers = [marker, self.lookahead] + right = self.isolateCoverGrammar(self.parseUnaryExpression) + + stack = [left, token, right] + + while True: + prec = self.binaryPrecedence(self.lookahead, self.state['allowIn']) + if not prec > 0: + break + new_op = self.lookahead['value'] + while len(stack) > 2: + stack_prec = stack[len(stack) - 2]['prec'] + stack_op = stack[len(stack) - 2]['value'] + if not _should_reduce(prec, stack_prec, stack_op, new_op): + break + right = stack.pop() + operator = stack.pop()['value'] + left = stack.pop() + markers.pop() + expr = parser.WrappingNode( + markers[len(markers) - 1]).finishBinaryExpression( + operator, left, right) + stack.append(expr) + + token = self.lex() + token['prec'] = prec + stack.append(token) + markers.append(self.lookahead) + expr = self.isolateCoverGrammar(self.parseUnaryExpression) + stack.append(expr) + + i = len(stack) - 1 + expr = stack[i] + markers.pop() + while i > 1: + expr = parser.WrappingNode(markers.pop()).finishBinaryExpression( + stack[i - 1]['value'], stack[i - 2], expr) + i -= 2 + return expr + + parser.PyJsParser.scanPunctuator = scan_punctuator + parser.PyJsParser.parseBinaryExpression = parse_binary_expression + _PATCHED = True diff --git a/js2py/evaljs.py b/js2py/evaljs.py index 06a37a48..019ca462 100644 --- a/js2py/evaljs.py +++ b/js2py/evaljs.py @@ -11,8 +11,8 @@ __all__ = [ 'EvalJs', 'translate_js', 'import_js', 'eval_js', 'translate_file', - 'eval_js6', 'translate_js6', 'run_file', 'disable_pyimport', - 'get_file_contents', 'write_file_contents' + 'eval_js6', 'translate_js6', 'eval_js7', 'translate_js7', 'run_file', + 'disable_pyimport', 'get_file_contents', 'write_file_contents' ] DEBUG = False @@ -57,12 +57,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, es7=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 (e.g. **). For example we have a file 'example.js' with: var a = function(x) {return x} translate_file('example.js', 'example.py') @@ -74,7 +75,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, es7=es7) 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 +95,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, es7=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 (e.g. **). EXAMPLE: >>> import js2py @@ -116,17 +118,27 @@ 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) + return e.eval(js, es6=es6, es7=es7) 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(_prepare_js_source(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(_prepare_js_source(js, es6=True)) + + +def eval_js7(js): + """Like eval_js with ES7 (ES2016) support enabled.""" + return eval_js(js, es7=True) + + +def translate_js7(js): + """Like translate_js with ES7 (ES2016) support enabled.""" + return translate_js(js, es7=True) class EvalJs(object): @@ -175,10 +187,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, es7=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 (e.g. **). 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,19 +207,20 @@ 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, es7) 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, + es7=es7) compiled = cache[cache_key] = compile(code, '', 'exec') exec (compiled, self._context) - def eval(self, expression, use_compilation_plan=False, es6=False): + def eval(self, expression, use_compilation_plan=False, es6=False, es7=False): """evaluates expression in current context and returns its value""" - expression = _prepare_js_source(expression, es6) + expression = _prepare_js_source(expression, es6, es7) code = 'PyJsEvalResult = eval(%s)' % json.dumps(expression) self.execute(code, use_compilation_plan=use_compilation_plan) return self['PyJsEvalResult'] diff --git a/js2py/prototypes/jsarray.py b/js2py/prototypes/jsarray.py index d02e62b2..f0c3fcca 100644 --- a/js2py/prototypes/jsarray.py +++ b/js2py/prototypes/jsarray.py @@ -283,6 +283,32 @@ def indexOf(searchElement): k += 1 return -1 + def includes(searchElement): + array = this.to_object() + arr_len = array.get('length').to_uint32() + if arr_len == 0: + return False + if len(arguments) > 1: + n = arguments[1].to_int() + else: + n = 0 + if n >= arr_len: + return False + if n >= 0: + k = n + else: + k = arr_len - abs(n) + 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)): + return True + k += 1 + return False + def lastIndexOf(searchElement): array = this.to_object() arr_len = array.get('length').to_uint32() diff --git a/js2py/translators/friendly_nodes.py b/js2py/translators/friendly_nodes.py index 370f85d8..c5780ff4 100644 --- a/js2py/translators/friendly_nodes.py +++ b/js2py/translators/friendly_nodes.py @@ -221,6 +221,10 @@ def js_mod(a, b): return '(' + a + '%' + b + ')' +def js_pow(a, b): + return 'Js((%s).to_number().value ** (%s).to_number().value)' % (a, b) + + def js_typeof(a): cand = list(bracket_split(a, ('()', ))) if len(cand) == 2 and cand[0] == 'var.get': @@ -347,7 +351,7 @@ def js_post_dec(a): ADDS = {'+': js_add, '-': js_sub} -MULTS = {'*': js_mul, '/': js_div, '%': js_mod} +MULTS = {'*': js_mul, '/': js_div, '%': js_mod, '**': js_pow} BINARY = {} BINARY.update(ADDS) BINARY.update(MULTS) diff --git a/js2py/translators/translator.py b/js2py/translators/translator.py index dfa2d52d..e32e16e1 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 ..es7 import looks_like_es7, ensure_pyjsparser_es7 +except ImportError: + looks_like_es7 = None + ensure_pyjsparser_es7 = None + # Enable Js2Py exceptions and pyimport in parser pyjsparser.parser.ENABLE_PYIMPORT = True @@ -67,13 +73,21 @@ 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, es7=False): + """Optionally enable ES7 parsing and downlevel ES6 source before translation.""" + 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 es7 or es6: + if ensure_pyjsparser_es7: + ensure_pyjsparser_es7() if es6: if js6_to_js5 is None: raise RuntimeError('ES6 support is not available') @@ -82,13 +96,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, es7=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). + es7: False, True, or 'auto' — enable ES2016 features (e.g. **).""" + js = _prepare_js_source(js, es6, es7) if use_compilation_plan and not '//' in js and not '/*' in js: return translate_js_with_compilation_plan(js, HEADER=HEADER) diff --git a/setup.py b/setup.py index b496e614..1a2219d2 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,8 @@ version='0.74', packages=['js2py', 'js2py.utils', 'js2py.prototypes', 'js2py.translators', - 'js2py.constructors', 'js2py.host', 'js2py.es6', 'js2py.internals', + 'js2py.constructors', 'js2py.host', 'js2py.es6', 'js2py.es7', + 'js2py.internals', 'js2py.internals.prototypes', 'js2py.internals.constructors', 'js2py.py_node_modules'], url='https://github.com/PiotrDabkowski/Js2Py', install_requires = ['tzlocal>=1.2', 'six>=1.10', 'pyjsparser>=2.5.1'], diff --git a/tests/test_es7.py b/tests/test_es7.py new file mode 100644 index 00000000..2c2cfaf5 --- /dev/null +++ b/tests/test_es7.py @@ -0,0 +1,61 @@ +"""Tests for ES7 (ES2016) JavaScript support.""" +import js2py +from js2py.es7 import looks_like_es7 + + +def test_looks_like_es7(): + assert looks_like_es7('2 ** 3') + assert looks_like_es7('[1].includes(1)') + assert not looks_like_es7('var a = 1; Math.pow(2, 3)') + + +def test_exponentiation_basic(): + assert js2py.eval_js('2 ** 3', es7=True) == 8 + + +def test_exponentiation_right_associative(): + assert js2py.eval_js('2 ** 3 ** 2', es7=True) == 512 + + +def test_exponentiation_precedence(): + assert js2py.eval_js('2 * 3 ** 2', es7=True) == 18 + assert js2py.eval_js('2 ** 3 + 1', es7=True) == 9 + + +def test_eval_js_auto_es7(): + assert js2py.eval_js('2 ** 10', es7='auto') == 1024 + + +def test_eval_js7(): + assert js2py.eval_js7('3 ** 4') == 81 + + +def test_array_includes(): + assert js2py.eval_js('[1, 2, 3].includes(2)') is True + assert js2py.eval_js('[1, 2, 3].includes(4)') is False + + +def test_array_includes_from_index(): + assert js2py.eval_js('[1, 2, 3].includes(2, 2)') is False + assert js2py.eval_js('[1, 2, 3].includes(3, 2)') is True + + +def test_array_includes_nan(): + assert js2py.eval_js('[1, NaN, 3].includes(NaN)') 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) + sys.exit(1 if failed else 0)