durusmail: quixote-users: Accept-aware functions via metaclasses
Accept-aware functions via metaclasses
2003-09-25
2003-09-26
Re: Accept-aware functions via metaclasses
2003-09-26
2003-09-26
[PATCH] http response code enumeration
2003-09-26
2003-09-26
2003-09-26
Accept-aware functions via metaclasses
Graham Fawcett
2003-09-25
Hi everyone,

I've been writing a Quixote app in a somewhat RESTful style, that needs
to do content negotiation on certain requests. By 'content negotation',
I mean that I need to examine the Accept header in the request, and
return an appropriate response in one of the MIME types declared there.

A recurrent pattern was cropping up in my code:

def _q_index(req):
        acceptlist = req.environ['HTTP_ACCEPT'].split(',')
        if 'text/html' in acceptlist:
                # return a text/html representation
        elif 'text/xml' in acceptlist:
                # return a text/xml representation
        ...

Ugly, hard to maintain, and inaccurate. Aside from the obvious
repetition, the Accept matching was not as expressive as it should have
been. For example, I wanted to accommodate relative quality factors (RFC
2616, sec. 14.1). So that the Accept directive:

        text/plain;q=.5, text/xml, */*;q=.1

would resolve to a 'text/xml' response, because it has a default quality
of 1 and is therefore favourable to the client.

What I really needed, then, was a function or method that could
self-resolve the appropriate Content-Type to return, and act accordingly.

I came up with a plan, incanted the forbidden secret phrases and
descended into the realm of metaclass hackery. (Beware, my code is alpha
0.1, unoptimized and likely shows the shakiness of one who does little
metaclass stuff.)

The enclosed module, 'qclasses', contains a metaclass type,
'acceptfunction', that allows you to write Quixote code like this PTL
example:

        from qclasses import acceptfunction
        from somewhere import some_header, some_footer

        _q_exports = []

        class _q_index:

                __metaclass__ = acceptfunction

                def text_html [html] (cls, req):
                        some_header(req)
                        '

An HTML response!

' some_footer(req) def text_xml [html] (cls, req): '' def text_ [plain] (cls, req): 'A response to text/* requests' def default [plain] (cls, req): req.response.set_content_type('text/plain') 'A response to */* requests' Above, _q_index is a class, but it acts like a function. The metaclass injects a replacement __new__ method (the "callable" interface of the class) that does the dispatching. The decision re: which method to pick is based on relative-quality weightings, as described above. The 'text_html()' method matches 'text/html' requests, 'text_()' matches 'text/*' requests and default() matches '*/*' requests. All of these are optional, and if no handler can be found, a somewhat helpful 406 Not Acceptable response is returned. Note also that punctuation in MIME names is flattened into underscores, so 'application/xhtml+xml' is handled by 'application_xthml_xml()'. There is very little magic in the metaclass. If the request is Accept: text/html, and you don't have a text_html() method, then it will fail. It won't fail over to the 'default()', that's only for '*/*'; nor to 'text_()', that's only for 'text/*'. The only magic I added was related to a common use case... look for 'magic' in the docstrings for more info. There are copious notes in the docstrings about the implementation, so I'll stop there. ;-) Oh, there's another metaclass, qx_namespace, that fixes a problem when using a class as a Quixote namespace (a '' request tries to "call" the class, rather than redirecting to PATH_URI + '/'). This is not related to the accessfunction metaclass, but might be helpful to some. You can use it like this: _q_exports = ['bar'] ... class bar: __metaclass__ = qx_namespace def _q_index (cls, req): # becomes a class method ... and if you visit '/bar' in your browser, you'll be redirected to '/bar/' instead of getting an undesirable response (e.g. a str(bar()) representation). Back to acceptfunction. A similar approach could be used to make 'function classes' for functions that need to dispatch based on method (GET, PUT, ...), user agent, locale, etc. (Though I dread to think about having to write a _q_index.text_html.put.en_us() function and its hundred-odd companions!) One last note: HTTP_ACCEPT support /may/ be inconsistent across all known Quixote handlers. Enjoy! -- Graham
"""
Metaclasses for doing neat tricks with Quixote namespaces and callables.
An early alpha release.
Graham Fawcett, September 2003.
"""

from quixote.errors import PublishError
import types
import re

DEBUG = 0
ALLOW_HTML_DEFAULT = 1

class qx_namespace(type):
    """
    A Quixote-specific metaclass for classes used as Quixote namespaces.
    Supplies a __new__ method that prevents the class from being called
    as a "callable", and instead redirects with a suffixed '/'.

    Example: if your request path is '/foo/bar', and Quixote maps
    'bar' onto a class, then it will try to "call" the class by invoking
    its constructor. The desired behaviour, however, is that the client
    is redirected to '/foo/bar/'. Adding a __new__ method to the class
    short-circuits the constructor call, and returns a Quixote redirect
    instead of a class instance.
    """

    def __init__(cls, name, supers, dct):
        def _redirect(cls, req):
            return req.redirect(req.environ['PATH_INFO'] + '/')
        cls.__new__ = staticmethod(_redirect)




class acceptfunction(type):
    """
    A Quixote-specific metaclass for a class that behaves like a function.
    The function expects a 'request' parameter, and based on
    request.environ['HTTP_ACCEPT'], delegates the response to an appropriate
    handler method within the class.

    The goal is to facilitate content negotiation in RESTful applications.

    Important Note: It is assumed that your request handler is correctly
    setting the HTTP_ACCEPT value in your request.environ dictionary. If
    not found, a meaningful KeyError will be raised. At present, I believe that
support for HTTP_ACCEPT is inconsistent across all Quixote frontends.

    The handler lookup is done as follows:

    - HTTP_ACCEPT is decomposed into MIME types sorted in quality order.
      See RFC 2616 sec 14.1 for more info on quality values.

    - for each MIME type in turn, a suitable handler is requested from the
      class. The lookup is based on method names that match the MIME type
      name. For example, for the MIME type 'text/html', a handler called
      'text_html()' is requested.

    - To handle requests of type 'text/*' you can register a handler with
      the signature 'text_()'.

    - To handle '*/*' requests, you can register a handler for 'default()'.

      Note: I added a bit of magic (just a bit), so that if you do not
      specify a default handler, but you have a handler capable of dealing
      with Accept: text/html, then that processor will be used. This is to
      deal with the common case where (a) most of your uses will be to handle
      text/html and one other format, and (b) where Internet Explorer is used,
      which frequently sends Accept: */* and nothing else.

    - the 'default()' handler is ONLY used to match '*/*'. It is not a
      failover handler.

    - if no handler can be found, then a 406 Not Acceptable error is returned.

    The methods are all automatically converted into class methods,
    so your handler signatures must be in the form 'text_html(cls, request)'.

    Automatic Content-Type setting

      For explicit handlers (like 'text_html()') the class will automatically
      set the MIME type via req.response.set_content_type().

      For wildcard types (like 'text_()' or 'default'), the MIME type will
      be set to class._default_mimetype. If this attribute is not found,
      then 'application/octet-stream' is used as a last resort.

      Of course, you can still set the Content-Type explicitly in your
      handler if you prefer.
    """

    def __init__(cls, name, supers, dct):
        # first set all the attributes.
        # all methods become class methods.
        methods = {}
        for k, v in dct.items():
            if type(v) is types.FunctionType:
                setattr(cls, k, classmethod(v))
                cmeth = getattr(cls, k)
                methods[k] = cmeth
            else:
                setattr(cls, k, v)
        # build a lookup table for methods, based on the
        # MIME types they are intended to support.
        lookup = {}
        for mname, meth in methods.items():
            if not mname.startswith('_') and '_' in mname:
                # this is the signature of a MIME-handling method.
                mtype, msubtype = [part and part or '*' \
                                   for part in mname.split('_', 1)]
                if DEBUG:
                    print 'acceptfunction: adding lookup to %s for %s' % \
                                            (name, (mtype, msubtype))
                lookup[(mtype, msubtype)] = meth

        # try to put the default handler in the lookup
        try:
            lookup[('*','*')] = methods['default']
            if DEBUG:
                print 'acceptfunction: adding default lookup for */*'
        except KeyError:
            # no default handler found.
            if ALLOW_HTML_DEFAULT:
                # you can change this if you want, but this will
                # set the default to the most likely text/html handler,
                # if one can be found. Why is this here? Frankly, because
                # of InternetExplorer, which frequently sends
                # Accept: */* as its directive.
                try:
                    lookup[('*','*')] = \
                                get_best_handler('text/html', lookup)[0]
                    if not hasattr(cls, '_default_mimetype'):
                        cls._default_mimetype = 'text/html'
                except NoHandlerFor:
                    pass

        # "calling" a class looks like an instantiation requst.
        # So, we create a __new__ staticmethod that dispatches
        # requests to the appropriate method.
        cls.__new__ = staticmethod(_accept_dispatch)
        cls._dispatch_lookup = lookup
        return cls

def _accept_dispatch(cls, req):
    """
    A drop-in '__new__' method for acceptfunction-derived classes.
    Does the actual dispatching to the best class method for the
    request's Accept directive.
    """
    if DEBUG:
        print '_accept_dispatch: ', '-' * 50

    try:
        accept = req.environ['HTTP_ACCEPT']
    except KeyError:
        raise KeyError, 'HTTP_ACCEPT not found in request environ!'

    handler, helement = get_best_handler(accept, cls._dispatch_lookup)
    # as a convenience, set the content-type on the response
    # based on the mime type of the handler.
    # if using a wildcard handler, then use
    # cls._default_mimetype, or a suitable catch-all.
    mimetype = str(helement)
    if '*' in mimetype:
        mimetype = getattr(cls, '_default_mimetype',
                                'application/octet-stream')
    req.response.set_content_type(mimetype)
    if DEBUG:
        print '_accept_dispatch: mimetype is', mimetype
    return handler(req)




def get_best_handler(accept, lookup):
    """
    Given an Accept directiveand a lookup table, find the best handler
    for the Accept directive.
    """
    acceptlist = _formats_accepted(accept)
    handler = None
    best_element = None
    if DEBUG:
        print 'get_best_handler: acceptlist is', acceptlist
    for acc in acceptlist:
        if DEBUG:
            print 'get_best_handler: searching for', acc.key()
        if lookup.has_key(acc.key()):
            best_element = acc
            break
    else:
        raise NoHandlerFor(accept, lookup.keys())
    handler = lookup[best_element.key()]
    if DEBUG:
        print 'get_best_handler: best for %s is %s' % (accept, best_element)
    return (handler, best_element)




def _formats_accepted(accept):
    """
    Given an HTTP Accept header value (see RFC 2616, section 14.1),
    return a sorted list of allowed types, in quality order.

    Return value is a list of tuples of ('type', 'subtype', 'subkey').
    Subkey is just the subtype with punctuation characters replaced with
    underscores, to facilitate lookup.
    """
    # add defaults, and sort the list.
    sortlist = []
    if DEBUG:
        print '_formats_accepted: examining', accept
    elements = [_accept_element(e) for e in accept.split(',')]
    elements.sort()
    return elements



class _accept_element:
    """
    An element in an Accept string.
    Examples in text would include 'text/html', 'text/*', '*/*;q=.3'
    This class provides attributes/methods to support sorting and
    lookups based on Accept elements.
    """
    def __init__(self, acceptstring):
        if ';' in acceptstring:
            mimetype, qstring = acceptstring.split(';')
            self.quality = float(qstring.split('=')[1])
        else:
            mimetype = acceptstring
            self.quality = 1.0 # the RFC 2616 default
        self.mimetype = mimetype.replace(' ', '')
        self.supertype, self.subtype = self.mimetype.split('/')
        self.subkey = re.sub('[+-]', '_', self.subtype)
    def __repr__(self):
        return self.mimetype

    def key(self):
        """
        Return a key for looking up an appropriate handler method.
        """
        return (self.supertype, self.subkey)

    def __cmp__(self, other):
        """
        When sorting a list of elements, we want the high-quality
        values at the front of the list.
        """
        return cmp(other.quality, self.quality)




class NoHandlerFor(PublishError):
    """
    A not-very-good 406 Not Acceptable error.
    It's okay in that it gives detail about what handlers are available,
    but not in a machine readable format; so it doesn't work as a
    discovery method. See RFC 2616, sec 10.4.7 for more info.
    """
    status_code = 406 # Not Acceptable
    title = 'Not Acceptable'
    private_msg = ''
    def __init__(self, acceptstring, accepted_types):
        self.description = ('Could not satisfy the requested '
                            'content-types: %s' % acceptstring)
        self.accepted_types = accepted_types
        self.public_msg = ','.join(map(str, self.accepted_types))
    def format (self, request):
        return self.public_msg
reply