Initial commit

This commit is contained in:
2024-08-27 20:33:44 +02:00
commit 1f1832267d
14794 changed files with 1599592 additions and 0 deletions

View File

@@ -0,0 +1,363 @@
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

View File

@@ -0,0 +1 @@
(()=>{"use strict";var t,r,n={923:t=>{function r(t,o,i){return(r=n()?Reflect.construct:function(t,r,n){var o=[null];o.push.apply(o,r);var i=new(Function.bind.apply(t,o));return n&&e(i,n.prototype),i}).apply(null,arguments)}function n(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Date.prototype.toString.call(Reflect.construct(Date,[],(function(){}))),!0}catch(t){return!1}}function e(t,r){return(e=Object.setPrototypeOf||function(t,r){return t.__proto__=r,t})(t,r)}function o(t,r){return function(t){if(Array.isArray(t))return t}(t)||function(t,r){if("undefined"!=typeof Symbol&&Symbol.iterator in Object(t)){var n=[],e=!0,o=!1,i=void 0;try{for(var a,u=t[Symbol.iterator]();!(e=(a=u.next()).done)&&(n.push(a.value),!r||n.length!==r);e=!0);}catch(t){o=!0,i=t}finally{try{e||null==u.return||u.return()}finally{if(o)throw i}}return n}}(t,r)||i(t,r)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function i(t,r){if(t){if("string"==typeof t)return a(t,r);var n=Object.prototype.toString.call(t).slice(8,-1);return"Object"===n&&t.constructor&&(n=t.constructor.name),"Map"===n||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?a(t,r):void 0}}function a(t,r){(null==r||r>t.length)&&(r=t.length);for(var n=0,e=new Array(r);n<r;n++)e[n]=t[n];return e}function u(t){return(u="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function c(t,r){for(var n=0;n<r.length;n++){var e=r[n];e.enumerable=e.enumerable||!1,e.configurable=!0,"value"in e&&(e.writable=!0),Object.defineProperty(t,e.key,e)}}var f=function(){function t(){!function(t,r){if(!(t instanceof r))throw new TypeError("Cannot call a class as a function")}(this,t),this.constructors={}}var n,e;return n=t,(e=[{key:"register",value:function(t,r){this.constructors[t]=r}},{key:"unpack",value:function(t){var r={};return this.scanForIds(t,r),this.unpackWithRefs(t,r,{})}},{key:"scanForIds",value:function(t,r){var n=this;if(null!==t&&"object"===u(t))if(Array.isArray(t))t.forEach((function(t){return n.scanForIds(t,r)}));else{var e=!1;if("_id"in t&&(e=!0,r[t._id]=t),("_type"in t||"_val"in t||"_ref"in t)&&(e=!0),"_list"in t&&(e=!0,t._list.forEach((function(t){return n.scanForIds(t,r)}))),"_args"in t&&(e=!0,t._args.forEach((function(t){return n.scanForIds(t,r)}))),"_dict"in t){e=!0;for(var i=0,a=Object.entries(t._dict);i<a.length;i++){var c=o(a[i],2),f=(c[0],c[1]);this.scanForIds(f,r)}}if(!e)for(var s=0,l=Object.entries(t);s<l.length;s++){var y=o(l[s],2),p=(y[0],y[1]);this.scanForIds(p,r)}}}},{key:"unpackWithRefs",value:function(t,n,e){var c,f,s=this;if(null===t||"object"!==u(t))return t;if(Array.isArray(t))return t.map((function(t){return s.unpackWithRefs(t,n,e)}));if("_ref"in t)c=t._ref in e?e[t._ref]:this.unpackWithRefs(n[t._ref],n,e);else if("_val"in t)c=t._val;else if("_list"in t)c=t._list.map((function(t){return s.unpackWithRefs(t,n,e)}));else if("_dict"in t){c={};for(var l=0,y=Object.entries(t._dict);l<y.length;l++){var p=o(y[l],2),h=p[0],d=p[1];c[h]=this.unpackWithRefs(d,n,e)}}else{if(!("_type"in t)){if("_id"in t)throw new Error("telepath encountered object with _id but no type specified");c={};for(var b=0,v=Object.entries(t);b<v.length;b++){var _=o(v[b],2),m=_[0],g=_[1];c[m]=this.unpackWithRefs(g,n,e)}return c}var j=t._type;c=r(this.constructors[j],function(t){if(Array.isArray(t))return a(t)}(f=t._args.map((function(t){return s.unpackWithRefs(t,n,e)})))||function(t){if("undefined"!=typeof Symbol&&Symbol.iterator in Object(t))return Array.from(t)}(f)||i(f)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}())}return"_id"in t&&(e[t._id]=c),c}}])&&c(n.prototype,e),t}();t.exports=f}},e={};function o(t){if(e[t])return e[t].exports;var r=e[t]={exports:{}};return n[t](r,r.exports,o),r.exports}o.n=t=>{var r=t&&t.__esModule?()=>t.default:()=>t;return o.d(r,{a:r}),r},o.d=(t,r)=>{for(var n in r)o.o(r,n)&&!o.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:r[n]})},o.o=(t,r)=>Object.prototype.hasOwnProperty.call(t,r),t=o(923),r=o.n(t),window.telepath=new(r())})();

View File

@@ -0,0 +1,7 @@
INSTALLED_APPS = [
'telepath',
'django.contrib.staticfiles',
]
SECRET_KEY = 'not needed'
STATIC_URL = '/static/'

View File

@@ -0,0 +1,334 @@
import itertools
from django.utils.translation import activate, gettext_lazy
from unittest import TestCase
from telepath import Adapter, JSContext, register, StringAdapter
class Artist:
def __init__(self, name):
self.name = name
@register
class Album:
def __init__(self, title, artists):
self.title = title
self.artists = artists
def telepath_pack(self, context):
context.add_media(js='music_player.js')
return ('music.Album', [self.title, self.artists])
class ArtistAdapter(Adapter):
js_constructor = 'music.Artist'
def js_args(self, obj):
return [obj.name]
register(ArtistAdapter(), Artist)
class TestPacking(TestCase):
def test_pack_object(self):
beyonce = Artist("Beyoncé")
ctx = JSContext()
result = ctx.pack(beyonce)
self.assertEqual(result, {'_type': 'music.Artist', '_args': ["Beyoncé"]})
def test_pack_list(self):
destinys_child = [
Artist("Beyoncé"), Artist("Kelly Rowland"), Artist("Michelle Williams")
]
ctx = JSContext()
result = ctx.pack(destinys_child)
self.assertEqual(result, [
{'_type': 'music.Artist', '_args': ["Beyoncé"]},
{'_type': 'music.Artist', '_args': ["Kelly Rowland"]},
{'_type': 'music.Artist', '_args': ["Michelle Williams"]},
])
def test_pack_dict(self):
glastonbury = {
'pyramid_stage': Artist("Beyoncé"),
'acoustic_stage': Artist("Ed Sheeran"),
}
ctx = JSContext()
result = ctx.pack(glastonbury)
self.assertEqual(result, {
'pyramid_stage': {'_type': 'music.Artist', '_args': ["Beyoncé"]},
'acoustic_stage': {'_type': 'music.Artist', '_args': ["Ed Sheeran"]},
})
def test_dict_reserved_words(self):
profile = {
'_artist': Artist("Beyoncé"),
'_type': 'R&B',
}
ctx = JSContext()
result = ctx.pack(profile)
self.assertEqual(result, {
'_dict': {
'_artist': {'_type': 'music.Artist', '_args': ["Beyoncé"]},
'_type': 'R&B',
}
})
def test_recursive_arg_packing(self):
dangerously_in_love = Album("Dangerously in Love", [
Artist("Beyoncé"),
])
ctx = JSContext()
result = ctx.pack(dangerously_in_love)
self.assertEqual(result, {
'_type': 'music.Album',
'_args': [
"Dangerously in Love",
[
{'_type': 'music.Artist', '_args': ["Beyoncé"]},
]
]
})
self.assertIn('music_player.js', str(ctx.media))
def test_object_references(self):
beyonce = Artist("Beyoncé")
jay_z = Artist("Jay-Z")
discography = [
Album("Dangerously in Love", [beyonce]),
Album("Everything Is Love", [beyonce, jay_z]),
]
ctx = JSContext()
result = ctx.pack(discography)
self.assertEqual(result, [
{
'_type': 'music.Album',
'_args': [
"Dangerously in Love",
[
{'_type': 'music.Artist', '_args': ["Beyoncé"], '_id': 0},
]
]
},
{
'_type': 'music.Album',
'_args': [
"Everything Is Love",
[
{'_ref': 0},
{'_type': 'music.Artist', '_args': ["Jay-Z"]},
]
]
},
])
self.assertIn('music_player.js', str(ctx.media))
def test_list_references(self):
destinys_child = [
Artist("Beyoncé"), Artist("Kelly Rowland"), Artist("Michelle Williams")
]
discography = [
Album("Destiny's Child", destinys_child),
Album("Survivor", destinys_child),
]
ctx = JSContext()
result = ctx.pack(discography)
self.assertEqual(result, [
{
'_type': 'music.Album',
'_args': [
"Destiny's Child",
{
'_list': [
{'_type': 'music.Artist', '_args': ["Beyoncé"]},
{'_type': 'music.Artist', '_args': ["Kelly Rowland"]},
{'_type': 'music.Artist', '_args': ["Michelle Williams"]},
],
'_id': 0,
}
]
},
{
'_type': 'music.Album',
'_args': [
"Survivor",
{'_ref': 0},
]
},
])
def test_primitive_value_references(self):
beyonce_name = "Beyoncé Giselle Knowles-Carter"
beyonce = Artist(beyonce_name)
discography = [
Album("Dangerously in Love", [beyonce]),
Album(beyonce_name, [beyonce]),
]
ctx = JSContext()
result = ctx.pack(discography)
self.assertEqual(result, [
{
'_type': 'music.Album',
'_args': [
"Dangerously in Love",
[
{
'_type': 'music.Artist',
'_args': [{'_val': "Beyoncé Giselle Knowles-Carter", '_id': 0}],
'_id': 1,
},
]
]
},
{
'_type': 'music.Album',
'_args': [
{'_ref': 0},
[
{'_ref': 1},
]
]
},
])
def test_avoid_primitive_value_references_for_short_strings(self):
beyonce_name = "Beyoncé"
beyonce = Artist(beyonce_name)
discography = [
Album("Dangerously in Love", [beyonce]),
Album(beyonce_name, [beyonce]),
]
ctx = JSContext()
result = ctx.pack(discography)
self.assertEqual(result, [
{
'_type': 'music.Album',
'_args': [
"Dangerously in Love",
[
{
'_type': 'music.Artist',
'_args': ["Beyoncé"],
'_id': 1,
},
]
]
},
{
'_type': 'music.Album',
'_args': [
"Beyoncé",
[
{'_ref': 1},
]
]
},
])
def test_lazy_translation_objects(self):
yes = Artist(gettext_lazy("Yes"))
activate('en')
ctx = JSContext()
result = ctx.pack(yes)
self.assertEqual(result, {'_type': 'music.Artist', '_args': ["Yes"]})
activate('fr')
ctx = JSContext()
result = ctx.pack(yes)
self.assertEqual(result, {'_type': 'music.Artist', '_args': ["Oui"]})
class Ark:
def __init__(self, animals):
self.animals = animals
def animals_by_type(self):
return itertools.groupby(self.animals, lambda animal: animal['type'])
class ArkAdapter(Adapter):
js_constructor = 'boats.Ark'
def js_args(self, obj):
return [obj.animals_by_type()]
register(ArkAdapter(), Ark)
class TestIDCollisions(TestCase):
def test_grouper_object_collisions(self):
"""
Certain functions such as itertools.groupby will cause new objects (namely, tuples and
custom itertools._grouper iterables) to be created in the course of iterating over the
object tree. If we're not careful, these will be released and the memory reallocated to
new objects while we're still iterating, leading to ID collisions.
"""
# create 100 Ark objects all with distinct animals (no object references are re-used)
arks = [
Ark([
{'type': 'lion', 'name': 'Simba %i' % i}, {'type': 'lion', 'name': 'Nala %i' % i},
{'type': 'dog', 'name': 'Lady %i' % i}, {'type': 'dog', 'name': 'Tramp %i' % i},
])
for i in range(0, 100)
]
ctx = JSContext()
result = ctx.pack(arks)
self.assertEqual(len(result), 100)
for i, ark in enumerate(result):
# each object should be represented in full, with no _id or _ref keys
self.assertEqual(ark, {
'_type': 'boats.Ark',
'_args': [
[
['lion', [{'type': 'lion', 'name': 'Simba %i' % i}, {'type': 'lion', 'name': 'Nala %i' % i}]],
['dog', [{'type': 'dog', 'name': 'Lady %i' % i}, {'type': 'dog', 'name': 'Tramp %i' % i}]],
]
]
})
class StringLike():
def __init__(self, val):
self.val = val.upper()
def __str__(self):
return self.val
class StringLikeAdapter(StringAdapter):
def build_node(self, obj, context):
return super().build_node(str(obj), context)
register(StringLikeAdapter(), StringLike)
class TestPackingToString(TestCase):
def test_pack_to_string(self):
val = [
"real string",
StringLike("stringlike"),
]
ctx = JSContext()
result = ctx.pack(val)
self.assertEqual(result, ["real string", "STRINGLIKE"])