Source code for score.jsapi._endpoint

import abc
import inspect
import logging
from .exc2json import exc2json
import sys
import time
import json
import functools

log = logging.getLogger('score.jsapi')


class EndpointOperation:
    """
    Wrapper class for operations registered on an endpoint.
    """

    def __init__(self, name, endpoint, callback, *,
                 version='', first_version=None):
        self.score_jsapi_op_name = name
        self.score_jsapi_op_version = str(version)
        self.__endpoint = endpoint
        if first_version:
            self.first_version = first_version
            self.first_version.__versions.append(self)
        else:
            self.first_version = self
            self.__versions = []
        # The next call will store the callback as self.__wrapped__
        functools.update_wrapper(self, callback)
        # Register this operation with the endpoint
        self.__endpoint._register_op(self)

    def __call__(self, *args, **kwargs):
        """
        Invoke wrapped callback.
        """
        return self.__wrapped__(*args, **kwargs)

    def score_jsapi_create_version(self, name):
        """
        Create a wrapper function for a newer version of this operation.

        The alias of this function is just `version`, so you can create newer
        versions of your operations with the following code:

        .. code-block:: python

            @endpoint.op
            def op(ctx):
                pass

            @op.version(2)
            def op(ctx):
                pass

        The version *name* will be converted to a string and increasing version
        numbers should be sortable as strings. Within these constraints, the
        following version names are valid:

        .. code-block:: python

            @op.version(2)
            def op(ctx):
                pass

            @op.version(3)
            def op(ctx):
                pass

            @op.version("3.1")
            def op(ctx):
                pass

        The following usage will not work as expected, since the version "ham"
        will be interpreted as an earlier version as "spam" (since
        "ham" < "spam") and the generated javascript will call the "spam"
        version by default (since it is considered the latest version because of
        that ordering):

        .. code-block:: python

            @op.version("spam")
            def op(ctx):
                pass

            @op.version("ham")
            def op(ctx):
                pass

        """
        def version_annotation(callback):
            return EndpointOperation(
                self.score_jsapi_op_name, self.__endpoint, callback,
                version=name, first_version=self.first_version)
        return version_annotation

    version = score_jsapi_create_version

    @property
    def score_jsapi_op_versions(self):
        return tuple(self.first_version.__versions)


[docs]class Endpoint(metaclass=abc.ABCMeta): """ An endpoint capable of handling requests from javascript. """ def __init__(self, name): self.name = name self.ops = {}
[docs] def op(self, func): """ Registers an operation with this Endpoint. It will be available with the same name and the same number of arguments in javascript. Note that javascript has no support for keyword arguments and :ref:`keyword-only parameters <python:keyword-only_parameter>` will confuse this function. """ return EndpointOperation(func.__name__, self, func)
def _register_op(self, operation): """ Registers an operation. This function is called from the constructor of :class:`EndpointOperation`. """ name = operation.score_jsapi_op_name for argname in inspect.signature(operation).parameters: if argname in ('self', 'cls'): continue if argname != 'ctx': raise ValueError("First argument must be the context 'ctx'") break if name in self.ops: raise ValueError('Operation "%s" already registered' % name) self.ops[(name, operation.score_jsapi_op_version)] = operation
[docs] def call(self, name, version, arguments, ctx_members={}): """ Calls function with given *name* and the given `list` of *arguments*. It is also possible to set some :term:`context members <context member>` before calling the actual handler for the operation. Will return a tuple consisting of a boolean success indicator and the actual response. The response depends on two factors: - If the call was successfull (i.e. no exception), it will contain the return value of the function. - If a non-safe exception was caught (i.e. one that does not derive from :class:`SafeException`) and the module was configured to expose internal data (via the init configuration value "expose"), the response will consist of the json-convertible representation of the exception, which is achievede with the help of :func:`exc2json` - If a :class:`.SafeException` was caught and the module was configured *not* to expose internal data, it will convert the exception type and message only (again via :func:`exc2json`). Thus, the javascript part will not receive a stack trace. - The last case (non-safe exception, expose is `False`), the *result* part will be `None`. """ try: with self.conf.ctx.Context() as ctx: for member, value in ctx_members.items(): setattr(ctx, member, value) return True, self.ops[(name, version)](ctx, *arguments) except Exception as e: if not isinstance(e, SafeException): log.exception(e) if self.conf.expose: result = exc2json(sys.exc_info(), [__file__]) elif isinstance(e, SafeException): result = exc2json([type(e), str(e)]) else: result = None return False, result
def _render_ops_js(self): op_defs = [] for key in sorted(self.ops): funcname, version = key func = self.ops[key] minargs = 0 maxargs = 0 argnames = [] skipped_ctx = False for name, param in inspect.signature(func).parameters.items(): if not skipped_ctx: skipped_ctx = True continue argnames.append(name) maxargs += 1 if param.default == inspect.Parameter.empty: minargs += 1 op_defs.append({ "name": funcname, "version": version, "minargs": minargs, "maxargs": maxargs, "argnames": argnames }) return json.dumps(op_defs) @abc.abstractmethod def render_js(self, conf): return ''
[docs]class UrlEndpoint(Endpoint): """ An Endpoint, which can be accessed via AJAX from javascript. """ template = ''' // Universal Module Loader // https://github.com/umdjs/umd // https://github.com/umdjs/umd/blob/v1.0.0/returnExports.js (function (root, factory) { if (typeof define === 'function' && define.amd) { // AMD. Register as an anonymous module. define(['../endpoint/url'], factory); } else if (typeof module === 'object' && module.exports) { // Node. Does not work with strict CommonJS, but // only CommonJS-like environments that support module.exports, // like Node. module.exports = factory(require('../endpoint/url')); } else { factory(root.score.jsapi.UrlEndpoint); } })(this, function(UrlEndpoint) { return new UrlEndpoint("%s", %s, "%s", "%s"); }); ''' def __init__(self, name, *, method="POST"): super().__init__(name) self.url = '/jsapi/' + name self.method = method
[docs] def handle(self, requests, ctx_members={}): """ Handles all functions calls passed with a request. The provided *requests* variable needs to be a list of "calls", where each call is a list containing the function name as first entry, the version of the operation as second entry, and the arguments for the invocation as the rest of the list. Example value for *requests* with a call to the initial version of an addition function and a call to the second version of a division function:: [["add", "", 40, 2], ["divide", "2", 42, 0]] The return value will be a list with two elements, one containing success values, the other containing results:: [[True, False], [42, None]] See :meth:`Endpoint.call` for details on the result values, especially the explanation of the `None` value, above. The input and output is already in the correct format for communication with the javascript part, so the result can be sent as "application/json"-encoded response to the calling javascript function. """ responses = [] for r in requests: name = r[0] version = r[1] args = r[2:] if log.isEnabledFor(logging.DEBUG): start = time.time() success, result = self.call( name, version, args, ctx_members=ctx_members) if log.isEnabledFor(logging.DEBUG): desc = name if version is not None: desc = '%s/v%s' % (name, version) log.debug( 'Handled call to `%s` in %dms: %s', desc, 1000 * (time.time() - start), 'success' if success else 'error', ) responses.append({ 'success': success, 'result': result, }) return responses
def render_js(self, conf): url = conf.http.url(None, 'score.jsapi:' + self.name) return self.template % ( self.name, self._render_ops_js(), url, self.method)
[docs]class SafeException(Exception): """ An Exception type, which indicates that the exception is safe to be transmitted to the client—even in production. The javascript API will reject the call promise with an instance of score.jsapi.Exception, or its equivalent "score/jsapi/Exception" in AMD and CommonJS. Example in python … .. code-block:: python class CustomError(SafeException): pass @endpoint.op def divide(ctx, dividend, divisor): if not divisor: raise CustomError('Cannot divide by zero') return dividend / divisor … and javascript: .. code-block:: javascript api.divide(1, 0).then(function(result) { console.log('1 / 0 = ' + result); }).catch(function(exc) { console.error('Error (' + exc.name + '): ' + exc.message); // will output: // Error (CustomError): Cannot divide by zero }); """