angrybeanie_wagtail/env/lib/python3.12/site-packages/telepath/__init__.py
2025-07-25 21:32:16 +10:00

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