363 lines
12 KiB
Python
363 lines
12 KiB
Python
from django import forms
|
|
from django.forms import MediaDefiningClass
|
|
from django.utils.functional import cached_property, Promise
|
|
|
|
|
|
DICT_RESERVED_KEYS = ['_type', '_args', '_dict', '_list', '_val', '_id', '_ref']
|
|
STRING_REF_MIN_LENGTH = 20 # do not turn strings shorter than this into references
|
|
|
|
|
|
class UnpackableTypeError(TypeError):
|
|
pass
|
|
|
|
|
|
class Node:
|
|
"""
|
|
Intermediate representation of a packed value. Subclasses represent a particular value
|
|
type, and implement emit_verbose (returns a dict representation of a value that can have
|
|
an _id attached) and emit_compact (returns a compact representation of the value, in any
|
|
JSON-serialisable type).
|
|
|
|
If this node is assigned an id, emit() will return the verbose representation with the
|
|
id attached on first call, and a reference on subsequent calls. To disable this behaviour
|
|
(e.g. for small primitive values where the reference representation adds unwanted overhead),
|
|
set self.use_id = False.
|
|
"""
|
|
def __init__(self):
|
|
self.id = None
|
|
self.seen = False
|
|
self.use_id = True
|
|
|
|
def emit(self):
|
|
if self.use_id and self.seen and self.id is not None:
|
|
# Have already emitted this value, so emit a reference instead
|
|
return {'_ref': self.id}
|
|
else:
|
|
self.seen = True
|
|
if self.use_id and self.id is not None:
|
|
# emit this value in long form including an ID
|
|
result = self.emit_verbose()
|
|
result['_id'] = self.id
|
|
return result
|
|
else:
|
|
return self.emit_compact()
|
|
|
|
|
|
class ValueNode(Node):
|
|
"""Represents a primitive value; int, bool etc"""
|
|
def __init__(self, value):
|
|
super().__init__()
|
|
self.value = value
|
|
self.use_id = False
|
|
|
|
def emit_verbose(self):
|
|
return {'_val': self.value}
|
|
|
|
def emit_compact(self):
|
|
return self.value
|
|
|
|
|
|
class StringNode(Node):
|
|
def __init__(self, value):
|
|
super().__init__()
|
|
self.value = value
|
|
self.use_id = len(value) >= STRING_REF_MIN_LENGTH
|
|
|
|
def emit_verbose(self):
|
|
return {'_val': self.value}
|
|
|
|
def emit_compact(self):
|
|
return self.value
|
|
|
|
|
|
class ListNode(Node):
|
|
def __init__(self, value):
|
|
super().__init__()
|
|
self.value = value
|
|
|
|
def emit_verbose(self):
|
|
return {'_list': [item.emit() for item in self.value]}
|
|
|
|
def emit_compact(self):
|
|
return [item.emit() for item in self.value]
|
|
|
|
|
|
class DictNode(Node):
|
|
def __init__(self, value):
|
|
super().__init__()
|
|
self.value = value
|
|
|
|
def emit_verbose(self):
|
|
return {'_dict': {key: val.emit() for key, val in self.value.items()}}
|
|
|
|
def emit_compact(self):
|
|
if any(reserved_key in self.value for reserved_key in DICT_RESERVED_KEYS):
|
|
# compact representation is not valid as this dict contains reserved keys
|
|
# that would clash with the verbose representation
|
|
return self.emit_verbose()
|
|
else:
|
|
return {key: val.emit() for key, val in self.value.items()}
|
|
|
|
|
|
class ObjectNode(Node):
|
|
def __init__(self, constructor, args):
|
|
super().__init__()
|
|
self.constructor = constructor
|
|
self.args = args
|
|
|
|
def emit_verbose(self):
|
|
return {
|
|
'_type': self.constructor,
|
|
'_args': [arg.emit() for arg in self.args]
|
|
}
|
|
|
|
def emit_compact(self):
|
|
# objects always use verbose representation
|
|
return self.emit_verbose()
|
|
|
|
|
|
class BaseAdapter:
|
|
"""Handles serialisation of a specific object type"""
|
|
def build_node(self, obj, context):
|
|
"""
|
|
Translates obj into a node that we can call emit() on to obtain the final serialisable
|
|
form. Any media declarations that will be required for deserialisation of the object should
|
|
be passed to context.add_media().
|
|
|
|
This base implementation handles simple JSON-serialisable values such as integers, and
|
|
wraps them as a ValueNode.
|
|
"""
|
|
return ValueNode(obj)
|
|
|
|
|
|
class StringAdapter(BaseAdapter):
|
|
def build_node(self, obj, context):
|
|
return StringNode(obj)
|
|
|
|
|
|
class DictAdapter(BaseAdapter):
|
|
"""Handles serialisation of dicts"""
|
|
def build_node(self, obj, context):
|
|
return DictNode({
|
|
str(key): context.build_node(val)
|
|
for key, val in obj.items()
|
|
})
|
|
|
|
|
|
class Adapter(BaseAdapter, metaclass=MediaDefiningClass):
|
|
"""
|
|
Handles serialisation of custom types.
|
|
Subclasses should define:
|
|
- js_constructor: namespaced identifier for the JS constructor function that will unpack this
|
|
object
|
|
- js_args(obj): returns a list of (telepath-packable) arguments to be passed to the constructor
|
|
- get_media(obj) or class Media: media definitions necessary for unpacking
|
|
|
|
The adapter should then be registered with register(adapter, cls).
|
|
"""
|
|
|
|
def get_media(self, obj):
|
|
return self.media
|
|
|
|
def pack(self, obj, context):
|
|
context.add_media(self.get_media(obj))
|
|
return (self.js_constructor, self.js_args(obj))
|
|
|
|
def build_node(self, obj, context):
|
|
constructor, args = self.pack(obj, context)
|
|
return ObjectNode(
|
|
constructor, [context.build_node(arg) for arg in args]
|
|
)
|
|
|
|
|
|
class AutoAdapter(Adapter):
|
|
"""
|
|
Adapter for objects that define their own telepath_pack method that we can simply delegate to.
|
|
"""
|
|
def pack(self, obj, context):
|
|
return obj.telepath_pack(context)
|
|
|
|
|
|
class JSContextBase:
|
|
"""
|
|
Base class for JSContext classes obtained through AdapterRegistry.js_context_class.
|
|
Subclasses of this are assigned the following class attributes:
|
|
registry - points to the associated AdapterRegistry
|
|
telepath_js_path - path to telepath.js (as per standard Django staticfiles conventions)
|
|
|
|
A JSContext handles packing a set of values to be used in the same request; calls to
|
|
JSContext.pack will return the packed representation and also update the JSContext's media
|
|
property to include all JS needed to unpack the values seen so far.
|
|
"""
|
|
def __init__(self):
|
|
self.media = self.base_media
|
|
|
|
# Keep track of media declarations that have already added to self.media - ones that
|
|
# exactly match a previous one can be ignored, as they will not affect the result
|
|
self.media_fragments = set([str(self.media)])
|
|
|
|
@property
|
|
def base_media(self):
|
|
return forms.Media(js=[self.telepath_js_path])
|
|
|
|
def add_media(self, media=None, js=None, css=None):
|
|
media_objects = []
|
|
if media:
|
|
media_objects.append(media)
|
|
if js or css:
|
|
if isinstance(js, str):
|
|
# allow passing a single JS file name as equivalent to a singleton list
|
|
js = [js]
|
|
media_objects.append(forms.Media(js=js, css=css))
|
|
|
|
for media_obj in media_objects:
|
|
media_str = str(media_obj)
|
|
if media_str not in self.media_fragments:
|
|
self.media += media_obj
|
|
self.media_fragments.add(media_str)
|
|
|
|
def pack(self, obj):
|
|
return ValueContext(self).build_node(obj).emit()
|
|
|
|
|
|
class AdapterRegistry:
|
|
"""
|
|
Manages the mapping of Python types to their corresponding adapter implementations.
|
|
"""
|
|
js_context_base_class = JSContextBase
|
|
|
|
def __init__(self, telepath_js_path='telepath/js/telepath.js'):
|
|
self.telepath_js_path = telepath_js_path
|
|
self.adapters = {
|
|
# Primitive value types that are unchanged on serialisation
|
|
type(None): BaseAdapter(),
|
|
bool: BaseAdapter(),
|
|
int: BaseAdapter(),
|
|
float: BaseAdapter(),
|
|
str: StringAdapter(),
|
|
|
|
# Container types to be serialised recursively
|
|
dict: DictAdapter(),
|
|
# Iterable types (list, tuple, odict_values...) do not have a reliably recognisable
|
|
# superclass, so will be handled as a special case
|
|
}
|
|
|
|
def register(self, *args, **kwargs):
|
|
if len(args) == 2 and not kwargs:
|
|
# called as register(adapter, cls)
|
|
adapter, cls = args
|
|
if not isinstance(adapter, BaseAdapter):
|
|
raise TypeError("register expected a BaseAdapter instance, got %r" % adapter)
|
|
|
|
self.adapters[cls] = adapter
|
|
|
|
elif not args:
|
|
# called as a class decorator: @register() or @register(adapter=MyAdapter()) -
|
|
# the return value here is the function that will receive the class definition
|
|
adapter = kwargs.get('adapter') or AutoAdapter()
|
|
if not isinstance(adapter, BaseAdapter):
|
|
raise TypeError("register expected a BaseAdapter instance, got %r" % adapter)
|
|
|
|
def wrapper(cls):
|
|
# register the class and return it unchanged
|
|
self.adapters[cls] = adapter
|
|
return cls
|
|
|
|
return wrapper
|
|
|
|
elif len(args) == 1 and isinstance(args[0], type):
|
|
# called as a class decorator @register without parentheses -
|
|
# we are passed the class definition here
|
|
cls = args[0]
|
|
self.adapters[cls] = AutoAdapter()
|
|
return cls
|
|
|
|
else:
|
|
raise TypeError(
|
|
"register must be called as register(adapter, cls) or as a class decorator - "
|
|
"@register or @register(adapter=MyAdapter())"
|
|
)
|
|
|
|
|
|
def find_adapter(self, cls):
|
|
for base in cls.__mro__:
|
|
adapter = self.adapters.get(base)
|
|
if adapter is not None:
|
|
return adapter
|
|
|
|
@cached_property
|
|
def js_context_class(self):
|
|
return type('JSContext', (self.js_context_base_class,), {
|
|
'registry': self,
|
|
'telepath_js_path': self.telepath_js_path
|
|
})
|
|
|
|
|
|
class ValueContext:
|
|
"""
|
|
A context instantiated for each top-level value that JSContext.pack is called on. Results from
|
|
this context's build_node method will be kept in a lookup table. If, over the course of
|
|
building the node tree for the top level value, we encounter multiple references to the same
|
|
value, a reference to the existing node will be generated rather than building it again. Calls
|
|
to add_media are passed back to the parent context so that multiple calls to pack() will have
|
|
their media combined in a single bundle.
|
|
"""
|
|
def __init__(self, parent_context):
|
|
self.parent_context = parent_context
|
|
self.registry = parent_context.registry
|
|
self.raw_values = {}
|
|
self.nodes = {}
|
|
self.next_id = 0
|
|
|
|
def add_media(self, *args, **kwargs):
|
|
self.parent_context.add_media(*args, **kwargs)
|
|
|
|
def build_node(self, val):
|
|
obj_id = id(val)
|
|
try:
|
|
existing_node = self.nodes[obj_id]
|
|
except KeyError:
|
|
# not seen this value before, so build a new node for it and store in self.nodes
|
|
node = self._build_new_node(val)
|
|
self.nodes[obj_id] = node
|
|
# Also keep a reference to the original value to stop it from getting deallocated
|
|
# and the ID being recycled
|
|
self.raw_values[obj_id] = val
|
|
|
|
return node
|
|
|
|
if existing_node.id is None:
|
|
# Assign existing_node an ID so that we can create references to it
|
|
existing_node.id = self.next_id
|
|
self.next_id += 1
|
|
|
|
return existing_node
|
|
|
|
def _build_new_node(self, obj):
|
|
adapter = self.registry.find_adapter(type(obj))
|
|
if adapter:
|
|
return adapter.build_node(obj, self)
|
|
|
|
# No adapter found; try special-case fallbacks
|
|
|
|
if isinstance(obj, Promise):
|
|
# object is a lazy object (e.g. gettext_lazy result);
|
|
# handle as a string, translated to the currently active locale
|
|
return StringNode(str(obj))
|
|
|
|
# try handling as an iterable
|
|
try:
|
|
items = iter(obj)
|
|
except TypeError: # obj is not iterable
|
|
raise UnpackableTypeError("don't know how to pack object: %r" % obj)
|
|
else:
|
|
return ListNode([self.build_node(item) for item in items])
|
|
|
|
|
|
# define a default registry of adapters. Typically this will be the only instance of
|
|
# AdapterRegistry in use, although packages may define their own 'private' registry if they
|
|
# have a set of adapters customised for their own use (e.g. with a custom JS path).
|
|
|
|
registry = AdapterRegistry()
|
|
JSContext = registry.js_context_class
|
|
register = registry.register
|