diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..a90191ab --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +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-es7 test-es8 test-async 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-async Run tests/test_async.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-es7 test-es8 test-async + @: + +test-simple: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/simple_test.py" + +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-async: + PYTHONPATH="$(ROOT)" $(PYTHON) "$(ROOT)/tests/test_async.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/__init__.py b/js2py/__init__.py index a7fe5979..b1e40cf6 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_js7', 'translate_js7', 'eval_js8', 'translate_js8', + 'eval_js_async', 'translate_js_async', 'drain_event_loop', 'PyJsException', 'get_file_contents', 'write_file_contents', 'require' ] diff --git a/js2py/async_js/__init__.py b/js2py/async_js/__init__.py new file mode 100644 index 00000000..b8983a22 --- /dev/null +++ b/js2py/async_js/__init__.py @@ -0,0 +1,5 @@ +"""Async/await support for Js2Py.""" + +from .transform import downlevel_async_await, looks_like_async + +__all__ = ['downlevel_async_await', 'looks_like_async'] diff --git a/js2py/async_js/transform.py b/js2py/async_js/transform.py new file mode 100644 index 00000000..9697aec5 --- /dev/null +++ b/js2py/async_js/transform.py @@ -0,0 +1,712 @@ +"""Downlevel async/await to Promise-based ES5.""" + +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__' + +_ASYNC_SYNTAX_RE = re.compile( + r'(?:' + r'\basync\s+(?:function|\()|' + r'\bawait\b' + r')', + re.MULTILINE) + +_FOR_LOOP_ID = 0 + + +def looks_like_async(code): + masked = re.sub(CP_STRING, lambda m: ' ', code) + return bool(_ASYNC_SYNTAX_RE.search(masked)) + + +def downlevel_async_await(code): + """Transform async/await into Promise chains understood by pyjsparser.""" + if not looks_like_async(code): + return code + 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_async_arrows(masked) + masked = _transform_async_functions(masked) + for index, value in enumerate(matches): + masked = masked.replace(CP_STRING_PLACEHOLDER % index, value, 1) + return masked + + +def _transform_async_arrows(code): + pattern = re.compile( + r'\basync\s+(\([^)]*\)|[\w$]+)\s*=>', + re.MULTILINE) + pos = 0 + out = [] + for match in pattern.finditer(code): + out.append(code[pos:match.start()]) + params = match.group(1) + body_start = match.end() + body, body_end = _read_arrow_body(code, body_start) + if body.startswith('{') and body.endswith('}'): + inner = body[1:-1] + transformed = _desugar_async_body(inner) + out.append('function %s { %s }' % (params, transformed)) + else: + expr = _desugar_await_in_expression(body.strip()) + out.append('function %s { return %s; }' % (params, expr)) + pos = body_end + out.append(code[pos:]) + return ''.join(out) + + +def _transform_async_functions(code): + pattern = re.compile(r'\basync\s+function(\s+[\w$]+)?\s*\(', re.MULTILINE) + pos = 0 + out = [] + for match in pattern.finditer(code): + out.append(code[pos:match.start()]) + name = match.group(1) or '' + params_start = match.end() + params_end = _find_matching_paren(code, params_start - 1) + params = code[params_start:params_end] + body_start = params_end + 1 + while body_start < len(code) and code[body_start].isspace(): + body_start += 1 + if body_start >= len(code) or code[body_start] != '{': + out.append(code[match.start():params_end + 1]) + pos = params_end + 1 + continue + body_end = _find_matching_brace(code, body_start) + body = code[body_start + 1:body_end] + transformed = _desugar_async_body(body) + out.append('function%s(%s) { %s }' % (name, params, transformed)) + pos = body_end + 1 + out.append(code[pos:]) + return ''.join(out) + + +def _desugar_async_body(body): + body = body.strip() + if not body: + return 'return Promise.resolve();' + stmts = _parse_statements(body) + if not stmts: + return 'return Promise.resolve();' + if not _statements_contain_await(stmts) and not _needs_promise_wrap(stmts): + if len(stmts) == 1 and stmts[0]['type'] == 'return': + arg = stmts[0].get('argument') + if arg: + return 'return Promise.resolve(%s);' % arg + return 'return Promise.resolve();' + return body + chain = _desugar_statements(stmts) + return 'return %s;' % chain + + +def _needs_promise_wrap(stmts): + """Async functions must return a Promise even without await.""" + return True + + +def _statements_contain_await(stmts): + for stmt in stmts: + if stmt.get('await'): + return True + if stmt['type'] == 'block': + if _statements_contain_await(stmt['body']): + return True + elif stmt['type'] == 'if': + if (_statements_contain_await(stmt['consequent']) + or _statements_contain_await(stmt.get('alternate') or [])): + return True + elif stmt['type'] in ('for', 'while', 'do'): + if _statements_contain_await(stmt['body']): + return True + elif stmt['type'] == 'try': + if (_statements_contain_await(stmt['block']) + or _statements_contain_await(stmt.get('handler', {}).get('body', [])) + or _statements_contain_await(stmt.get('finalizer') or [])): + return True + return False + + +def _desugar_statements(stmts, tail='Promise.resolve()'): + chain = tail + for stmt in reversed(stmts): + chain = _wrap_statement(stmt, chain) + return chain + + +def _wrap_statement(stmt, tail): + stype = stmt['type'] + if stype == 'return': + arg = stmt.get('argument') + if not arg: + if tail == 'Promise.resolve()': + return 'Promise.resolve()' + return 'Promise.resolve().then(function() { return %s; })' % tail + if stmt.get('await'): + expr = _desugar_await_in_expression(arg) + if tail == 'Promise.resolve()': + return expr + return '%s.then(function(__ret) { return %s; })' % (expr, tail) + if tail == 'Promise.resolve()': + return 'Promise.resolve(%s)' % arg + return 'Promise.resolve(%s).then(function(__ret) { return %s; })' % ( + arg, tail) + if stype == 'throw': + arg = stmt['argument'] + if stmt.get('await'): + expr = _desugar_await_in_expression(arg) + return '%s.then(function(__v) { throw __v; })' % expr + return 'Promise.resolve().then(function() { throw %s; })' % arg + if stype == 'var': + if stmt.get('await'): + expr = _desugar_await_in_expression(stmt['init']) + return '%s.then(function(%s) { return %s; })' % ( + expr, stmt['name'], tail) + return ('Promise.resolve().then(function() { %s %s = %s; return %s; })' + % (stmt['kind'], stmt['name'], stmt['init'], tail)) + if stype == 'assign': + if stmt.get('await'): + expr = _desugar_await_in_expression(stmt['init']) + return ('%s.then(function(__tmp) { %s = __tmp; return %s; })' + % (expr, stmt['name'], tail)) + return ('Promise.resolve().then(function() { %s = %s; return %s; })' + % (stmt['name'], stmt['init'], tail)) + if stype == 'expr': + src = stmt['source'] + if stmt.get('await'): + m = re.match( + r'([\w$]+)\s*(\+\=|-=|\*=|/=|%=|\|=|&=|\^=|<<=|>>=|>>>=)\s*await\s+(.+)$', + src) + if m: + name, op, rhs = m.group(1), m.group(2), m.group(3).strip() + return ('Promise.resolve(%s).then(function(__v) { %s %s __v; ' + 'return %s; })' % (rhs, name, op, tail)) + expr = _desugar_await_in_expression(src) + return '%s.then(function() { return %s; })' % (expr, tail) + return 'Promise.resolve().then(function() { %s; return %s; })' % ( + src, tail) + if stype == 'block': + inner = _desugar_statements(stmt['body'], tail) + return 'Promise.resolve().then(function() { return %s; })' % inner + if stype == 'if': + cons = _desugar_statements(stmt['consequent'], tail) + alt_stmts = stmt.get('alternate') or [] + if alt_stmts: + alt = _desugar_statements(alt_stmts, tail) + branch = 'if (%s) { return %s; } else { return %s; }' % ( + stmt['test'], cons, alt) + else: + branch = 'if (%s) { return %s; } return %s;' % ( + stmt['test'], cons, tail) + return 'Promise.resolve().then(function() { %s })' % branch + if stype == 'for': + return _wrap_for_loop(stmt, tail) + if stype == 'while': + return _wrap_while_loop(stmt, tail) + if stype == 'try': + return _wrap_try(stmt, tail) + return tail + + +def _wrap_for_loop(stmt, tail): + global _FOR_LOOP_ID + _FOR_LOOP_ID += 1 + name = '__for_%d' % _FOR_LOOP_ID + init = stmt.get('init') or '' + test = stmt.get('test') or 'true' + update = stmt.get('update') or '' + body_chain = _desugar_statements(stmt['body']) + parts = [] + if init: + parts.append(init + ';') + parts.append('return (function %s() {' % name) + parts.append(' if (!(%s)) return %s;' % (test, tail)) + parts.append(' return (%s).then(function() {' % body_chain) + if update: + parts.append(' %s;' % update) + parts.append(' return %s();' % name) + parts.append(' });') + parts.append('})();') + return 'Promise.resolve().then(function() { %s })' % '\n'.join(parts) + + +def _wrap_while_loop(stmt, tail): + global _FOR_LOOP_ID + _FOR_LOOP_ID += 1 + name = '__while_%d' % _FOR_LOOP_ID + test = stmt['test'] + body_chain = _desugar_statements(stmt['body']) + code = ( + 'return (function %s() {\n' + ' if (!(%s)) return %s;\n' + ' return (%s).then(function() { return %s(); });\n' + '})();' % (name, test, tail, body_chain, name)) + return 'Promise.resolve().then(function() { %s })' % code + + +def _wrap_try(stmt, tail): + try_chain = _desugar_statements(stmt['block'], tail) + handler = stmt.get('handler') + if handler: + param = handler.get('param') or '__err' + catch_chain = _desugar_statements(handler['body'], tail) + result = '%s.catch(function(%s) { return %s; })' % ( + try_chain, param, catch_chain) + else: + result = try_chain + finalizer = stmt.get('finalizer') + if finalizer: + fin_chain = _desugar_statements(finalizer, tail) + result = ('%s.then(function(__v) { return (%s).then(function() ' + '{ return __v; }); }, function(__e) { return (%s).then(' + 'function() { throw __e; }); })' + % (result, fin_chain, fin_chain)) + return result + + +def _desugar_await_in_expression(expr): + expr = expr.strip() + while True: + match = re.search(r'\bawait\b', expr) + if not match: + break + start = match.start() + operand_start = match.end() + while operand_start < len(expr) and expr[operand_start].isspace(): + operand_start += 1 + operand_end = _scan_expression_end(expr, operand_start) + operand = expr[operand_start:operand_end].strip() + before = expr[:start].rstrip() + after = expr[operand_end:].lstrip() + if not before and not after: + return 'Promise.resolve(%s)' % operand + tmp = '__await_%d' % start + if before.endswith('return '): + inner = 'return %s' % tmp + elif before: + inner = '%s; return %s' % (before.rstrip(';'), tmp) + else: + inner = 'return %s' % tmp + if after: + if after.startswith('.'): + inner = 'return (%s)%s' % (tmp, after) + elif after[0] in '+-*/%&|^': + inner = 'return %s%s' % (tmp, after) + else: + inner = 'return %s + %s' % (tmp, after) + expr = 'Promise.resolve(%s).then(function(%s) { %s; })' % ( + operand, tmp, inner) + return expr + + +def _parse_statements(code): + code = code.strip() + stmts = [] + i = 0 + n = len(code) + while i < n: + while i < n and code[i].isspace(): + i += 1 + if i >= n: + break + stmt, i = _parse_statement(code, i) + if stmt is not None: + stmts.append(stmt) + return stmts + + +def _parse_statement(code, i): + i = _skip_ws(code, i) + if i >= len(code): + return None, i + + for kw, stype in ( + ('for', 'for'), ('while', 'while'), ('if', 'if'), + ('try', 'try'), ('return', 'return'), ('throw', 'throw'), + ): + if _starts_word(code, i, kw): + parser = globals().get('_parse_' + stype) + return parser(code, i) + + for kw in ('var', 'let', 'const'): + if _starts_word(code, i, kw): + return _parse_var(code, i, kw) + + if code[i] == '{': + end = _find_matching_brace(code, i) + body = _parse_statements(code[i + 1:end]) + return {'type': 'block', 'body': body}, end + 1 + + end = _find_statement_end(code, i) + src = code[i:end].strip() + if not src: + return None, end + if src.endswith(';'): + src = src[:-1].strip() + return _parse_simple_statement(src), end + + +def _parse_return(code, i): + i = _skip_ws(code, i + 6) + end = _find_statement_end(code, i) + arg = code[i:end].strip() + if arg.endswith(';'): + arg = arg[:-1].strip() + await_flag = bool(re.search(r'\bawait\b', arg)) + if not arg: + return {'type': 'return', 'argument': None, 'await': False}, end + return {'type': 'return', 'argument': arg, 'await': await_flag}, end + + +def _parse_throw(code, i): + i = _skip_ws(code, i + 5) + end = _find_statement_end(code, i) + arg = code[i:end].strip().rstrip(';') + await_flag = bool(re.search(r'\bawait\b', arg)) + return {'type': 'throw', 'argument': arg, 'await': await_flag}, end + + +def _parse_var(code, i, kind): + start = i + end = _find_statement_end(code, i) + src = code[i:end].strip().rstrip(';') + m = re.match(r'(?:var|let|const)\s+([\w$]+)\s*=\s*(.+)$', src) + if not m: + return {'type': 'expr', 'source': code[start:end].strip()}, end + name, init = m.group(1), m.group(2).strip() + await_flag = bool(re.search(r'\bawait\b', init)) + return {'type': 'var', 'kind': kind, 'name': name, 'init': init, + 'await': await_flag}, end + + +def _parse_if(code, i): + i = _skip_ws(code, i + 2) + if code[i] != '(': + raise SyntaxError('Expected ( after if') + test_end = _find_matching_paren(code, i) + test = code[i + 1:test_end].strip() + i = _skip_ws(code, test_end + 1) + cons, i = _parse_statement(code, i) + if cons['type'] == 'block': + cons = cons['body'] + else: + cons = [cons] + alternate = [] + i = _skip_ws(code, i) + if _starts_word(code, i, 'else'): + i = _skip_ws(code, i + 4) + alt, i = _parse_statement(code, i) + if alt['type'] == 'block': + alternate = alt['body'] + else: + alternate = [alt] + return {'type': 'if', 'test': test, 'consequent': cons, + 'alternate': alternate}, i + + +def _parse_for(code, i): + i = _skip_ws(code, i + 3) + if code[i] != '(': + raise SyntaxError('Expected ( after for') + paren_end = _find_matching_paren(code, i) + inner = code[i + 1:paren_end] + init, test, update = _split_for_header(inner) + i = _skip_ws(code, paren_end + 1) + body_stmt, i = _parse_statement(code, i) + if body_stmt['type'] == 'block': + body = body_stmt['body'] + else: + body = [body_stmt] + return {'type': 'for', 'init': init, 'test': test, 'update': update, + 'body': body}, i + + +def _parse_while(code, i): + i = _skip_ws(code, i + 5) + if code[i] != '(': + raise SyntaxError('Expected ( after while') + test_end = _find_matching_paren(code, i) + test = code[i + 1:test_end].strip() + i = _skip_ws(code, test_end + 1) + body_stmt, i = _parse_statement(code, i) + if body_stmt['type'] == 'block': + body = body_stmt['body'] + else: + body = [body_stmt] + return {'type': 'while', 'test': test, 'body': body}, i + + +def _parse_try(code, i): + i = _skip_ws(code, i + 3) + block_stmt, i = _parse_statement(code, i) + if block_stmt['type'] == 'block': + block = block_stmt['body'] + else: + block = [block_stmt] + handler = None + i = _skip_ws(code, i) + if _starts_word(code, i, 'catch'): + i = _skip_ws(code, i + 5) + if code[i] != '(': + raise SyntaxError('Expected ( after catch') + param_end = _find_matching_paren(code, i) + param = code[i + 1:param_end].strip() + i = _skip_ws(code, param_end + 1) + hstmt, i = _parse_statement(code, i) + if hstmt['type'] == 'block': + hbody = hstmt['body'] + else: + hbody = [hstmt] + handler = {'param': param, 'body': hbody} + finalizer = None + i = _skip_ws(code, i) + if _starts_word(code, i, 'finally'): + i = _skip_ws(code, i + 7) + fstmt, i = _parse_statement(code, i) + if fstmt['type'] == 'block': + finalizer = fstmt['body'] + else: + finalizer = [fstmt] + return {'type': 'try', 'block': block, 'handler': handler, + 'finalizer': finalizer}, i + + +def _parse_simple_statement(src): + src = src.strip() + m = re.match(r'(?:var|let|const)\s+([\w$]+)\s*=\s*(.+)$', src) + if m: + init = m.group(2).strip() + await_flag = bool(re.search(r'\bawait\b', init)) + kind = src.split()[0] + return {'type': 'var', 'kind': kind, 'name': m.group(1), 'init': init, + 'await': await_flag} + m = re.match(r'([\w$]+)\s*=\s*(.+)$', src) + if m: + init = m.group(2).strip() + await_flag = bool(re.search(r'\bawait\b', init)) + return {'type': 'assign', 'name': m.group(1), 'init': init, + 'await': await_flag} + await_flag = bool(re.search(r'\bawait\b', src)) + return {'type': 'expr', 'source': src, 'await': await_flag} + + +def _split_for_header(inner): + parts = [] + buf = [] + depth = 0 + for ch in inner: + if ch in '([{': + depth += 1 + elif ch in ')]}': + depth = max(0, depth - 1) + if ch == ';' and depth == 0: + parts.append(''.join(buf).strip()) + buf = [] + else: + buf.append(ch) + parts.append(''.join(buf).strip()) + while len(parts) < 3: + parts.append('') + return parts[0], parts[1], parts[2] + + +def _find_statement_end(code, i): + depth = 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 ch in '({[': + depth += 1 + elif ch in ')}]': + depth = max(0, depth - 1) + if depth == 0: + if ch == ';': + return i + 1 + if ch == '{' and _starts_word(code, _word_start(code, i - 1), 'for'): + pass + if ch == '{' and i > 0: + word = _word_at(code, i) + if word in ('for', 'while', 'if', 'try', 'else', 'do', 'function'): + return i + i += 1 + return len(code) + + +def _word_start(code, i): + while i >= 0 and (code[i].isalnum() or code[i] in '_$'): + i -= 1 + return i + 1 + + +def _word_at(code, i): + i = _skip_ws(code, i) + if i >= len(code): + return '' + if code[i] == '{': + j = i - 1 + while j >= 0 and code[j].isspace(): + j -= 1 + end = j + 1 + start = end + while start > 0 and (code[start - 1].isalnum() or code[start - 1] in '_$'): + start -= 1 + return code[start:end] + return '' + + +def _starts_word(code, i, word): + return code.startswith(word, i) and ( + i + len(word) >= len(code) + or not (code[i + len(word)].isalnum() or code[i + len(word)] in '_$')) + + +def _skip_ws(code, i): + while i < len(code) and code[i].isspace(): + i += 1 + return i + + +def _read_arrow_body(code, start): + while start < len(code) and code[start].isspace(): + start += 1 + if start >= len(code): + return '', start + if code[start] == '{': + end = _find_matching_brace(code, start) + return code[start:end + 1], end + 1 + end = start + depth = 0 + while end < len(code): + ch = code[end] + if ch in '({[': + depth += 1 + elif ch in ')}]': + depth = max(0, depth - 1) + elif ch == ',' and depth == 0: + break + elif ch == ')' and depth == 0: + break + elif ch == ';' and depth == 0: + end += 1 + break + end += 1 + return code[start:end], end + + +def _find_matching_paren(code, open_index): + if code[open_index] != '(': + raise ValueError('expected (') + depth = 0 + in_str = None + for i in range(open_index, len(code)): + ch = code[i] + if in_str: + if ch == '\\': + continue + if ch == in_str: + in_str = None + continue + if ch in '\'"': + in_str = ch + continue + if ch == '(': + depth += 1 + elif ch == ')': + depth -= 1 + if depth == 0: + return i + return len(code) - 1 + + +def _find_matching_brace(code, open_index): + if code[open_index] != '{': + raise ValueError('expected {') + depth = 0 + in_str = None + for i in range(open_index, len(code)): + ch = code[i] + if in_str: + if ch == '\\': + continue + if ch == in_str: + in_str = None + continue + if ch in '\'"': + in_str = ch + continue + if ch == '{': + depth += 1 + elif ch == '}': + depth -= 1 + if depth == 0: + return i + return len(code) - 1 + + +def _scan_expression_end(code, start): + i = start + while i < len(code) and code[i].isspace(): + i += 1 + if i >= len(code): + return i + if code[i] in '\'"': + quote = code[i] + i += 1 + while i < len(code): + if code[i] == '\\': + i += 2 + continue + if code[i] == quote: + return i + 1 + i += 1 + return i + if code[i] == '(': + return _find_matching_paren(code, i) + 1 + if code[i] == '{': + return _find_matching_brace(code, i) + 1 + depth = 0 + while i < len(code): + ch = code[i] + if ch in '\'"': + end = _scan_expression_end(code, i) + i = end + continue + if ch in '({[': + depth += 1 + elif ch in ')}]': + if depth == 0: + break + depth -= 1 + elif depth == 0 and ch in ',;': + break + elif depth == 0 and ch in '+-*' and i > start: + prev = i - 1 + while prev >= start and code[prev].isspace(): + prev -= 1 + if prev >= start and (code[prev] in ')]}' or code[prev].isalnum() + or code[prev] in '_$'): + break + elif depth == 0 and code.startswith('await', i): + break + i += 1 + return i 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/constructors/jsobject.py b/js2py/constructors/jsobject.py index c4e0ada3..f7784e53 100644 --- a/js2py/constructors/jsobject.py +++ b/js2py/constructors/jsobject.py @@ -145,6 +145,37 @@ 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') + obj = obj.to_object() + return [ + obj.get(name) + for name, desc in six.iteritems(obj.own) + if desc.get('enumerable') + ] + + def entries(obj): + if not obj.is_object(): + raise MakeError('TypeError', 'Object.entries called on non-object') + obj = obj.to_object() + return [ + [Js(name), obj.get(name)] + for name, desc in six.iteritems(obj.own) + if desc.get('enumerable') + ] + + def getOwnPropertyDescriptors(obj): + if not obj.is_object(): + raise MakeError( + 'TypeError', + 'Object.getOwnPropertyDescriptors called on non-object') + obj = obj.to_object() + result = PyJsObject(prototype=ObjectPrototype) + for name, desc in six.iteritems(obj.own): + result.put(name, FromPropertyDescriptor(desc)) + return result + # add methods attached to Object constructor fill_prototype(Object, ObjectMethods, default_attrs) @@ -196,3 +227,25 @@ def ToPropertyDescriptor(obj): # page 38 (50 absolute) 'Invalid property. A property cannot both have accessors and be writable or have a value.' ) return desc + + +def FromPropertyDescriptor(desc): + if desc is None: + return undefined + obj = PyJsObject(prototype=ObjectPrototype) + fields = [] + if is_data_descriptor(desc): + fields.extend(('value', 'writable')) + elif is_accessor_descriptor(desc): + fields.extend(('get', 'set')) + fields.extend(('enumerable', 'configurable')) + for key in fields: + if key in desc: + val = desc[key] if key in ('value', 'get', 'set') else Js(desc[key]) + obj.define_own_property(key, { + 'value': val, + 'writable': True, + 'enumerable': True, + 'configurable': True + }) + return obj diff --git a/js2py/constructors/jspromise.py b/js2py/constructors/jspromise.py new file mode 100644 index 00000000..862e03ae --- /dev/null +++ b/js2py/constructors/jspromise.py @@ -0,0 +1,217 @@ +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) + + +@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) 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/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/es8/__init__.py b/js2py/es8/__init__.py new file mode 100644 index 00000000..f4321ae7 --- /dev/null +++ b/js2py/es8/__init__.py @@ -0,0 +1,44 @@ +"""ES8 (ES2017) support: trailing commas 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__' + +_ES8_SYNTAX_RE = re.compile( + r'(?:' + r'\bObject\.values\s*\(|' + r'\bObject\.entries\s*\(|' + r'\bObject\.getOwnPropertyDescriptors\s*\(|' + r'\.padStart\s*\(|' + r'\.padEnd\s*\(|' + r'function\s+[^(]*\([^)]*,\s*\)|' # trailing comma in params + r'\([^)]*,\s*\)\s*=>' # trailing comma in arrow params + 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.""" + matches = [] + + def mask(match): + matches.append(match.group(0)) + return CP_STRING_PLACEHOLDER % (len(matches) - 1) + + masked = re.sub(CP_STRING, mask, code) + prev = None + while prev != masked: + prev = masked + masked = re.sub(r',(\s*)\)', r'\1)', 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 f4649c4d..f8d9a1a2 100644 --- a/js2py/evaljs.py +++ b/js2py/evaljs.py @@ -1,6 +1,7 @@ # coding=utf-8 from .translators import translate_js, DEFAULT_HEADER -from .es6 import js6_to_js5 +from .translators.translator import _prepare_js_source +from .event_loop import drain_event_loop import sys import time import json @@ -11,8 +12,10 @@ __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', 'eval_js8', + 'translate_js8', 'eval_js_async', 'translate_js_async', 'drain_event_loop', + 'run_file', 'disable_pyimport', 'get_file_contents', + 'write_file_contents' ] DEBUG = False @@ -57,11 +60,15 @@ 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, es7=False, es8=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. **). + es8: False, True, or 'auto' — enable ES2017 features (e.g. trailing commas). + For example we have a file 'example.js' with: var a = function(x) {return x} translate_file('example.js', 'example.py') @@ -72,7 +79,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, es7=es7, es8=es8) 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 +99,16 @@ def run_file(path_or_file, context=None): return eval_value, context -def eval_js(js): +def eval_js(js, es6=False, es7=False, es8=False, async_js='auto'): """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. **). + es8: False, True, or 'auto' — enable ES2017 features (e.g. trailing commas). + async_js: False, True, or 'auto' — downlevel async/await to Promises. + EXAMPLE: >>> import js2py >>> add = js2py.eval_js('function add(a, b) {return a + b}') @@ -112,17 +124,47 @@ 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, es7=es7, es8=es8, async_js=async_js) 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(_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(js6_to_js5(js)) + 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) + + +def eval_js8(js): + """Like eval_js with ES8 (ES2017) support enabled.""" + return eval_js(js, es8=True) + + +def translate_js8(js): + """Like translate_js with ES8 (ES2017) support enabled.""" + return translate_js(js, es8=True) + + +def eval_js_async(js): + """Like eval_js with async/await support enabled.""" + return eval_js(js, async_js=True) + + +def translate_js_async(js): + """Like translate_js with async/await support enabled.""" + return translate_js(js, async_js=True) class EvalJs(object): @@ -171,9 +213,15 @@ 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, es7=False, + es8=False, async_js=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. **). + es8: False, True, or 'auto' — enable ES2017 features (e.g. trailing commas). + async_js: False, True, or 'auto' — downlevel async/await to Promises. + 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 +236,30 @@ 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, es7, es8, + async_js) 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, + es7=es7, es8=es8, async_js=async_js) + compiled = cache[cache_key] = compile(code, '', 'exec') exec (compiled, self._context) + drain_event_loop() + + def drain(self, timeout=None): + """Run pending Promise microtasks and due timers.""" + drain_event_loop(timeout=timeout) - def eval(self, expression, use_compilation_plan=False): + def eval(self, expression, use_compilation_plan=False, es6=False, es7=False, + es8=False, async_js=False): """evaluates expression in current context and returns its value""" + expression = _prepare_js_source(expression, es6, es7, es8, async_js) 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, es7=es7, es8=es8, async_js=async_js) return self['PyJsEvalResult'] def execute_debug(self, js): @@ -219,7 +277,7 @@ def execute_debug(self, js): with open(filename, "r") as f: pyCode = compile(f.read(), filename, 'exec') exec(pyCode, self._context) - + drain_event_loop() except Exception as err: raise err finally: diff --git a/js2py/event_loop.py b/js2py/event_loop.py new file mode 100644 index 00000000..177671f0 --- /dev/null +++ b/js2py/event_loop.py @@ -0,0 +1,96 @@ +"""JavaScript-style event loop: microtasks (Promises) and macrotasks (timers).""" + +import heapq +import time + +_microtasks = [] +_macrotasks = [] # (due_time, seq, callback) +_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=0): + global _macrotask_seq + due = time.monotonic() + max(0.0, float(delay_ms)) / 1000.0 + _macrotask_seq += 1 + heapq.heappush(_macrotasks, (due, _macrotask_seq, callback)) + + +def schedule_timer(callback, delay_ms, 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 info is None 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 = time.monotonic() + timeout if timeout is not None else None + iterations = 0 + while iterations < _MAX_DRAIN_ITERATIONS: + iterations += 1 + while _microtasks: + batch = _microtasks[:] + _microtasks.clear() + for callback in batch: + callback() + + 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/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/host/jstimers.py b/js2py/host/jstimers.py new file mode 100644 index 00000000..22573153 --- /dev/null +++ b/js2py/host/jstimers.py @@ -0,0 +1,51 @@ +from ..base import * +from ..event_loop import schedule_timer, clear_timer + + +@Js +def setTimeout(callback, delay): + if not callback.is_callable(): + raise MakeError('TypeError', + 'setTimeout callback must be a function') + delay_ms = 0 if len(arguments) < 2 else arguments[1].to_number().value + if delay_ms != delay_ms: # NaN + delay_ms = 0 + delay_ms = max(0, delay_ms) + + @Js + def run(): + callback.call(undefined, ()) + return undefined + + return Js(schedule_timer(run, delay_ms)) + + +@Js +def setInterval(callback, delay): + if not callback.is_callable(): + raise MakeError('TypeError', + 'setInterval callback must be a function') + if len(arguments) < 2: + raise MakeError('TypeError', 'setInterval requires a delay') + delay_ms = arguments[1].to_number().value + if delay_ms != delay_ms or delay_ms < 0: + delay_ms = 0 + + @Js + def run(): + callback.call(undefined, ()) + return undefined + + return Js(schedule_timer(run, delay_ms, repeat_ms=delay_ms)) + + +@Js +def clearTimeout(handle): + if len(arguments) and not handle.is_undefined(): + clear_timer(handle.to_number().value) + + +@Js +def clearInterval(handle): + if len(arguments) and not handle.is_undefined(): + clear_timer(handle.to_number().value) 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/prototypes/jsstring.py b/js2py/prototypes/jsstring.py index a313bfb9..5aab5d75 100644 --- a/js2py/prototypes/jsstring.py +++ b/js2py/prototypes/jsstring.py @@ -299,6 +299,42 @@ def trim(): this.cok() return this.Js(this.to_string().value.strip(WHITE)) + def padStart(maxLength, fillString): + this.cok() + s = this.to_string().value + target = maxLength.to_int() + if target < 0: + target = 0 + if len(s) >= target: + return this.Js(s) + if len(arguments) < 2 or fillString.is_undefined(): + fill = ' ' + else: + fill = fillString.to_string().value + if fill == '': + fill = ' ' + pad_len = target - len(s) + repeated = (fill * (pad_len // len(fill) + 1))[:pad_len] + return this.Js(repeated + s) + + def padEnd(maxLength, fillString): + this.cok() + s = this.to_string().value + target = maxLength.to_int() + if target < 0: + target = 0 + if len(s) >= target: + return this.Js(s) + if len(arguments) < 2 or fillString.is_undefined(): + fill = ' ' + else: + fill = fillString.to_string().value + if fill == '': + fill = ' ' + pad_len = target - len(s) + repeated = (fill * (pad_len // len(fill) + 1))[:pad_len] + return this.Js(s + repeated) + 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/pyjs.py b/js2py/pyjs.py index 98e106a0..ae4093a9 100644 --- a/js2py/pyjs.py +++ b/js2py/pyjs.py @@ -1,6 +1,7 @@ from .base import * from .constructors.jsmath import Math from .constructors.jsdate import Date +from .constructors.jspromise import Promise from .constructors.jsobject import Object from .constructors.jsfunction import Function from .constructors.jsstring import String @@ -21,6 +22,7 @@ from .prototypes.jsjson import JSON from .host.console import console from .host.jseval import Eval +from .host.jstimers import setTimeout, setInterval, clearTimeout, clearInterval from .host.jsfunctions import parseFloat, parseInt, isFinite, \ isNaN, escape, unescape, encodeURI, decodeURI, encodeURIComponent, decodeURIComponent @@ -47,6 +49,7 @@ 'RegExp', 'Math', 'Date', + 'Promise', 'Object', 'Function', 'Array', @@ -70,6 +73,10 @@ 'decodeURI', 'encodeURIComponent', 'decodeURIComponent', + 'setTimeout', + 'setInterval', + 'clearTimeout', + 'clearInterval', ) #Array, Function, JSON, Error is done later :) 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/translating_nodes.py b/js2py/translators/translating_nodes.py index 4e2b5760..d4215036 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') @@ -543,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 @@ -603,12 +626,27 @@ 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 '' + code = '' + for i, (param, py_name) in enumerate(zip(params, used_vars)): + 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 + + 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 +681,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 +697,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 +735,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..39145fdc 100644 --- a/js2py/translators/translator.py +++ b/js2py/translators/translator.py @@ -5,6 +5,30 @@ import hashlib import re +try: + from ..es6 import js6_to_js5, looks_like_es6 +except ImportError: + 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 + +try: + from ..es8 import looks_like_es8, prepare_es8 +except ImportError: + looks_like_es8 = None + prepare_es8 = None + +try: + from ..async_js import downlevel_async_await, looks_like_async +except ImportError: + downlevel_async_await = None + looks_like_async = None + # Enable Js2Py exceptions and pyimport in parser pyjsparser.parser.ENABLE_PYIMPORT = True @@ -61,9 +85,54 @@ 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, es7=False, es8=False, async_js=False): + """Optionally enable ES7/ES8 parsing and downlevel ES6/async source.""" + if async_js == 'auto': + if looks_like_async and looks_like_async(js): + async_js = True + else: + async_js = 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 es7 or es6: + if ensure_pyjsparser_es7: + ensure_pyjsparser_es7() + if es8 and prepare_es8: + js = prepare_es8(js) + if async_js and downlevel_async_await: + js = downlevel_async_await(js) + 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, es7=False, es8=False, + async_js=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). + es7: False, True, or 'auto' — enable ES2016 features (e.g. **). + es8: False, True, or 'auto' — enable ES2017 features (e.g. trailing commas). + async_js: False, True, or 'auto' — downlevel async/await to Promises.""" + js = _prepare_js_source(js, es6, es7, es8, async_js) 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..977ab0d7 100644 --- a/js2py/utils/injector.py +++ b/js2py/utils/injector.py @@ -24,7 +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 - 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, @@ -34,6 +39,98 @@ def fix_js_args(func): return result +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): co_varnames = code_obj.co_varnames # Old locals co_names = code_obj.co_names # Old globals @@ -261,7 +358,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/setup.py b/setup.py index b496e614..fbc73e65 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.es8', 'js2py.async_js', '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_async.py b/tests/test_async.py new file mode 100644 index 00000000..42f18379 --- /dev/null +++ b/tests/test_async.py @@ -0,0 +1,201 @@ +"""Tests for async/await support.""" +import js2py +from js2py.async_js import looks_like_async, downlevel_async_await +from js2py.event_loop import reset_event_loop + + +def _await_promise(ctx, expr, *args): + arg_str = ', '.join(str(a).lower() if isinstance(a, bool) else repr(a) + for a in args) + call = '%s(%s)' % (expr, arg_str) if arg_str else '%s()' % expr + ctx.eval('var __v; %s.then(function(x){ __v = x; });' % call, async_js=True) + return ctx.__v + + +def test_looks_like_async(): + assert looks_like_async('async function f() {}') + assert looks_like_async('await Promise.resolve(1)') + assert not looks_like_async('function f() { return 1; }') + + +def test_downlevel_return_await(): + code = downlevel_async_await( + 'async function f() { return await Promise.resolve(1); }') + assert 'async' not in code + assert 'Promise.resolve' in code + + +def test_promise_resolve(): + ctx = js2py.EvalJs() + ctx.execute('var v; Promise.resolve(42).then(function(x){ v = x; });') + assert ctx.v == 42 + + +def test_promise_microtask_order(): + log = [] + ctx = js2py.EvalJs({'log': log.append}) + ctx.execute(''' +log("sync1"); +Promise.resolve().then(function(){ log("micro"); }); +log("sync2"); +''') + assert log == ['sync1', 'sync2', 'micro'] + + +def test_setTimeout_defers(): + log = [] + ctx = js2py.EvalJs({'log': log.append}) + ctx.execute(''' +log("a"); +setTimeout(function(){ log("b"); }, 0); +log("c"); +''') + assert log == ['a', 'c', 'b'] + + +def test_async_return_await(): + ctx = js2py.EvalJs() + ctx.execute( + 'async function f() { return await Promise.resolve(42); }', async_js=True) + assert _await_promise(ctx, 'f') == 42 + + +def test_async_sequential_await(): + ctx = js2py.EvalJs() + ctx.execute(''' +async function f() { + var a = await Promise.resolve(10); + var b = await Promise.resolve(32); + return a + b; +} +''', async_js=True) + assert _await_promise(ctx, 'f') == 42 + + +def test_async_for_loop_await(): + ctx = js2py.EvalJs() + ctx.execute(''' +async function f() { + var sum = 0; + for (var i = 0; i < 4; i++) { + sum += await Promise.resolve(i); + } + return sum; +} +''', async_js=True) + assert _await_promise(ctx, 'f') == 6 + + +def test_async_while_loop_await(): + ctx = js2py.EvalJs() + ctx.execute(''' +async function f() { + var n = 3, sum = 0; + while (n--) { + sum += await Promise.resolve(1); + } + return sum; +} +''', async_js=True) + assert _await_promise(ctx, 'f') == 3 + + +def test_async_try_catch_await(): + ctx = js2py.EvalJs() + ctx.execute(''' +async function f() { + try { + return await Promise.reject("fail"); + } catch (e) { + return 99; + } +} +''', async_js=True) + assert _await_promise(ctx, 'f') == 99 + + +def test_async_try_catch_success(): + ctx = js2py.EvalJs() + ctx.execute(''' +async function f() { + try { + return await Promise.resolve(12); + } catch (e) { + return -1; + } +} +''', async_js=True) + assert _await_promise(ctx, 'f') == 12 + + +def test_async_if_await(): + ctx = js2py.EvalJs() + ctx.execute(''' +async function f(flag) { + var r = 0; + if (flag) { + r = await Promise.resolve(5); + } else { + r = await Promise.resolve(1); + } + return r; +} +''', async_js=True) + assert _await_promise(ctx, 'f', True) == 5 + assert _await_promise(ctx, 'f', False) == 1 + + +def test_async_nested(): + ctx = js2py.EvalJs() + ctx.execute(''' +async function inner(x) { return await Promise.resolve(x * 2); } +async function f() { return await inner(21); } +''', async_js=True) + assert _await_promise(ctx, 'f') == 42 + + +def test_async_deeply_nested(): + ctx = js2py.EvalJs() + ctx.execute(''' +async function c() { return await Promise.resolve(3); } +async function b() { return await c() + 1; } +async function a() { return await b() + 1; } +''', async_js=True) + assert _await_promise(ctx, 'a') == 5 + + +def test_async_arrow(): + ctx = js2py.EvalJs() + ctx.execute( + 'var g = async function() { return await Promise.resolve(7); };', + async_js=True) + assert _await_promise(ctx, 'g') == 7 + + +def test_eval_js_async(): + ctx = js2py.EvalJs() + ctx.execute( + 'async function f(){ return await Promise.resolve(5);} ' + 'var v; f().then(function(x){ v = x; });', + async_js=True) + assert ctx.v == 5 + + +if __name__ == '__main__': + import inspect + import sys + + reset_event_loop() + failed = 0 + for name, func in sorted(inspect.getmembers(sys.modules[__name__], inspect.isfunction)): + if not name.startswith('test_'): + continue + try: + func() + reset_event_loop() + print('ok', name) + except Exception as exc: + failed += 1 + reset_event_loop() + print('FAIL', name, exc) + sys.exit(1 if failed else 0) diff --git a/tests/test_es6.py b/tests/test_es6.py new file mode 100644 index 00000000..a844d216 --- /dev/null +++ b/tests/test_es6.py @@ -0,0 +1,87 @@ +"""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(): + 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(): + 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 + + +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) 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) diff --git a/tests/test_es8.py b/tests/test_es8.py new file mode 100644 index 00000000..87d20cdb --- /dev/null +++ b/tests/test_es8.py @@ -0,0 +1,69 @@ +"""Tests for ES8 (ES2017) JavaScript support.""" +import js2py +from js2py.es8 import looks_like_es8 + + +def test_looks_like_es8(): + assert looks_like_es8('Object.values({})') + assert looks_like_es8('"x".padStart(3)') + assert looks_like_es8('function f(a,) { return a; }') + assert not looks_like_es8('var a = 1; Object.keys({})') + + +def test_object_values(): + assert list(js2py.eval_js('Object.values({a: 1, b: 2})')) == [1, 2] + + +def test_object_entries(): + result = js2py.eval_js('Object.entries({a: 1, b: 2})') + assert list(result) == [['a', 1], ['b', 2]] + + +def test_object_get_own_property_descriptors(): + descs = js2py.eval_js( + 'Object.getOwnPropertyDescriptors({a: 1, get b() { return 2; }})') + assert descs.a.value == 1 + assert descs.b.get is not None + + +def test_string_pad_start(): + assert js2py.eval_js('"5".padStart(3, "0")') == '005' + assert js2py.eval_js('"hello".padStart(8)') == ' hello' + + +def test_string_pad_end(): + assert js2py.eval_js('"5".padEnd(3, "0")') == '500' + assert js2py.eval_js('"hello".padEnd(8)') == 'hello ' + + +def test_trailing_comma_params(): + ctx = js2py.EvalJs() + ctx.execute('function f(a, b,) { return a + b; }', es8=True) + assert ctx.f(1, 2) == 3 + + +def test_trailing_comma_auto(): + ctx = js2py.EvalJs() + ctx.execute('function g(x,) { return x * 2; }', es8='auto') + assert ctx.g(5) == 10 + + +def test_eval_js8(): + assert js2py.eval_js8('Object.values({x: 9})')[0] == 9 + + +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)