Source code for jy.bridge

"""Python portals to JS"""

from typing import Any, Optional
from functools import partial
from inspect import Parameter
from keyword import iskeyword

from dol.signatures import Sig

from jy.js_parse import dflt_py_to_js_value_trans, func_name_and_params_pairs


# TODO: Add value transformer (routing). For example, booleans, True->true
def _js_func_call(
    *args,
    __sig,
    __func_call_template,
    __value_trans=dflt_py_to_js_value_trans,
    __apply_defaults=True,
    **kwargs,
):
    _kwargs = __sig.map_arguments(args, kwargs, apply_defaults=__apply_defaults)
    inputs = map(__value_trans, _kwargs.values())
    inputs = ', '.join(map(str, inputs))
    return __func_call_template.format(inputs=inputs)


def _demote_keyword_named_params(params):
    """Mark Python-keyword-named params (and any preceding them) positional-only.

    A JS parameter may be named after a Python keyword (e.g. ``and``) -- a
    perfectly valid JS identifier.  Python's :class:`inspect.Parameter` rejects
    such a name *unless* the parameter is positional-only (the same exemption
    CPython makes for C functions).  This is also semantically correct: you can
    never pass ``and=...`` by keyword from Python (it is a syntax error), so the
    parameter can only ever be supplied positionally.  Since positional-only
    parameters must precede positional-or-keyword ones, every parameter up to and
    including the last keyword-named one is demoted.

    ``params`` is a list of parameter dicts (``{'name', 'default', ...}``) as
    produced by :func:`jy.js_parse.func_name_and_params_pairs`.  A new list is
    returned; the input is left untouched.  Without any keyword-named parameter
    the params pass through unchanged.

    >>> _demote_keyword_named_params([{'name': 'a'}, {'name': 'b', 'default': 1}])
    [{'name': 'a'}, {'name': 'b', 'default': 1}]
    >>> demoted = _demote_keyword_named_params(
    ...     [{'name': 'green'}, {'name': 'and', 'default': True}, {'name': 'ham'}]
    ... )
    >>> [(p['name'], p.get('kind')) for p in demoted] == [
    ...     ('green', Parameter.POSITIONAL_ONLY),
    ...     ('and', Parameter.POSITIONAL_ONLY),
    ...     ('ham', None),
    ... ]
    True
    """
    params = list(params)
    keyword_indices = [i for i, p in enumerate(params) if iskeyword(p['name'])]
    if not keyword_indices:
        return params
    last_keyword_index = max(keyword_indices)
    return [
        {**p, 'kind': Parameter.POSITIONAL_ONLY} if i <= last_keyword_index else dict(p)
        for i, p in enumerate(params)
    ]


# TODO: value_trans only sees value. Might want to transform according to other
#  contextural information, like the parameter name, or the function name, etc.
def mk_py_binder_func(
    name,
    params,
    *,
    prefix='',
    suffix='',
    doc='',
    value_trans=dflt_py_to_js_value_trans,
    apply_defaults=True,
):
    *_, func_name = name.split('.')  # e.g. object.containing.func --> func
    func_call_template = prefix + name + '({inputs})' + suffix

    sig = Sig.from_params(_demote_keyword_named_params(params))
    js_func_call = partial(
        _js_func_call,
        __sig=sig,
        __func_call_template=func_call_template,
        __value_trans=value_trans,
        __apply_defaults=apply_defaults,
    )
    js_func_call = sig(js_func_call)
    js_func_call.__name__ = func_name
    js_func_call.__doc__ = doc

    return js_func_call


[docs] class JsBridge: """ The default class to make instances that will contain methods that mirror JS function calls """
[docs] def add_js_funcs( js_code: str, *, obj: Any = None, name: Optional[str] = None, encoding: Optional[str] = None, forbidden_method_names=(), apply_defaults=True, value_trans=dflt_py_to_js_value_trans, ): ''' Add js call functions as attributes to an object. If object is not given, ``add_js_funcs`` will use a new ``JsBridge`` instance. >>> js_code = """ ... function foo(a, b="hello", c= 3) { ... return a + b.length * c ... } ... const bar = (y, z = 1) => y * z ... func.assigned.to.nested.prop = function (x) { ... return x + 3 ... } ... """ >>> js = add_js_funcs(js_code) >>> from inspect import signature >>> list(vars(js)) ['foo', 'bar', 'prop'] >>> signature(js.foo) <Sig (a, b='hello', c=3)> >>> js.foo(1, 'hi') 'foo(1, "hi", 3)' >>> js.prop('up') 'func.assigned.to.nested.prop("up")' ''' if obj is None: obj = JsBridge() forbidden_method_names = set(forbidden_method_names) for full_func_ref, params in func_name_and_params_pairs(js_code, encoding=encoding): *_, func_name = full_func_ref.split('.') # e.g. object.containing.func --> func # TODO: Could specify a recovery function that finds another name instead of # raising an error if func_name in forbidden_method_names: raise ValueError( f'This func name was already used, or mentioned in ' f'the forbidden_method_names argument: {func_name}' ) setattr( obj, func_name, mk_py_binder_func( full_func_ref, params, value_trans=value_trans, apply_defaults=apply_defaults, ), ) if name: obj.__name__ = name return obj