Source code for jy.bridge

"""Python portals to JS"""

from typing import Any
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: str | None = None, encoding: str | None = 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