-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathrenderer.py
More file actions
533 lines (438 loc) · 18.1 KB
/
Copy pathrenderer.py
File metadata and controls
533 lines (438 loc) · 18.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
import inspect
import warnings
from collections import deque
from content_editor.contents import contents_for_item
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
from django.template import Context, Engine
from django.utils.functional import SimpleLazyObject
from django.utils.html import mark_safe
__all__ = (
"PluginNotRegisteredError",
"RegionRenderer",
"TemplatePluginRenderer",
"default_context",
"render_in_context",
"template_renderer",
)
class PluginNotRegisteredError(Exception):
"""
Exception raised when encountering a plugin which isn't known to the
renderer.
"""
# Backwards compatibility
PluginNotRegistered = PluginNotRegisteredError
def default_context(plugin, context):
"""
Return the default context for plugins rendered with a template, which
simply is a single variable named ``plugin`` containing the plugin
instance.
"""
return {"plugin": plugin}
def render_in_context(context, template, local_context=None):
"""Render using a template rendering context
This utility avoids the problem of ``render_to_string`` requiring a
``dict`` and not a full-blown ``Context`` instance which would needlessly
burn CPU cycles."""
if context is None:
context = Context()
if not hasattr(template, "render"): # Quacks like a template?
try:
engine = context.template.engine
except AttributeError:
engine = Engine.get_default()
if isinstance(template, (list, tuple)):
template = engine.select_template(template)
else:
template = engine.get_template(template)
with context.push(local_context):
return template.render(context)
def template_renderer(template_name, local_context=default_context, /):
"""
Build a renderer for the region renderer which uses a template (or a list
of templates) and optionally a local context function. The context contains
the site-wide context variables too when invoked via ``{% render_region %}``
"""
return lambda plugin, context: render_in_context(
context, template_name, local_context(plugin, context)
)
# See RegionRenderer.register()
_default_marks = {"default"}
_plugin = 0
_renderer = 1
_subregion = 2
_marks = 3
_fetch = 4
_CLOSE_SECTION = "_close_section"
class RegionRenderer:
"""
The region renderer knows how to render single plugins and also complete
regions.
The basic usage is to instantiate the region renderer, register plugins
and render a full region with it.
"""
def __init__(self):
self._plugins = {}
self._handlers = {
key[7:]: getattr(self, key)
for key in dir(self)
if key.startswith("handle_")
}
def copy(self):
"""
Return a shallow copy of the renderer
"""
obj = self.__class__()
obj._plugins = dict(self._plugins)
return obj
def unregister(self, *plugins, keep=()):
"""
Unregister plugins
You can either pass a list of plugins which should be unregistered:
.. code-block:: python
renderer.unregister(HTML, RichText)
Or you can specify which plugins should be kept:
.. code-block:: python
renderer.unregister(keep=(HTML, RichText))
You cannot do both at the same time.
Plugins can either be the plugin classes themselves or base classes.
"""
if bool(plugins) == bool(keep):
raise ImproperlyConfigured(
"Only ever provide either a list of plugins or a list of plugins to keep."
)
test = (
(lambda cls: not issubclass(cls, plugins))
if plugins
else (lambda cls: issubclass(cls, tuple(keep)))
)
self._plugins = {
plugin: cfg for plugin, cfg in self._plugins.items() if test(plugin)
}
def register(
self,
plugin,
renderer,
/,
*,
subregion="default",
marks=_default_marks,
fetch=True,
):
"""
Register a plugin class
The renderer is either a static value or a function which always
receives two arguments: The plugin instance and the context. When using
``{% render_region %}`` and the Django template language the context
will be a Django template ``Context`` (or even ``RequestContext``)
instance.
The three optional keyword arguments' are:
- ``subregion: str = "default"``: The subregion for this plugin as a
string or as a callable accepting a single plugin instance. A
matching ``handle_<subregion>`` callable has to exist on the region
renderer instance or rendering **will** crash loudly.
- ``marks: Set[str] = {"default"}``: The marks of this plugin. Marks
only have the meaning you assign to them. Marks are preferrable to
running ``isinstance`` on plugin instances especially when using the
same region renderer class for different content types (e.g. pages
and blog articles).
- ``fetch = True``: By default a plugin is fetched from the database;
setting this to ``False`` allows registering plugin classes which
shouldn't be fetched from the database.
"""
if callable(renderer) and len(inspect.signature(renderer).parameters) < 2:
raise ImproperlyConfigured(
f"The renderer function {renderer} has less than the two required arguments."
)
if not isinstance(plugin, (list, tuple)):
plugin = [plugin]
for p in plugin:
if p in self._plugins:
warnings.warn(
f"The plugin {p} has already been registered with {self.__class__} before.",
stacklevel=2,
)
if p._meta.proxy and fetch:
warnings.warn(
f'The plugin {p} is a proxy but is also registered with the default "fetch=True". This can cause plugins to be fetched (and rendered) twice.',
stacklevel=2,
)
self._plugins[p] = (p, renderer, subregion, marks, fetch)
def plugins(self, *, fetch=True):
"""
Return a list of all registered plugin classes
By default and because of backwards compatibility concerns the method
only returns plugins which have been registered for fetching.
"""
return [
cfg[_plugin]
for cfg in self._plugins.values()
if (cfg[_fetch] if fetch else True)
]
@property
def handlers(self):
warnings.warn(
"The handlers aren't really supposed to be public. Access _handlers if you know what you're doing or propose a better API instead.",
DeprecationWarning,
stacklevel=2,
)
return self._handlers
def render_plugin(self, plugin, context):
"""
Render a single plugin using the registered renderer
"""
try:
renderer = self._plugins[plugin.__class__][_renderer]
except KeyError as exc:
raise PluginNotRegisteredError(
f"Plugin {plugin._meta.label_lower} is not registered"
) from exc
if callable(renderer):
return renderer(plugin, context)
return renderer
def subregion(self, plugin):
"""
Return the subregion of a plugin instance
"""
try:
subregion = self._plugins[plugin.__class__][_subregion]
except KeyError as exc:
raise PluginNotRegisteredError(
f"Plugin {plugin._meta.label_lower} is not registered"
) from exc
if callable(subregion):
return subregion(plugin)
return subregion
def takewhile_subregion(self, plugins, subregion):
"""
Yield all plugins from the head of the ``plugins`` deque as long as
their subregion equals ``subregion``.
"""
while plugins and self.subregion(plugins[0]) == subregion:
yield plugins.popleft()
def marks(self, plugin):
"""
Return the marks of a plugin instance
"""
try:
marks = self._plugins[plugin.__class__][_marks]
except KeyError as exc:
raise PluginNotRegisteredError(
f"Plugin {plugin._meta.label_lower} is not registered"
) from exc
if callable(marks):
return marks(plugin)
return marks
def takewhile_mark(self, plugins, mark):
"""
Yield all plugins from the head of the ``plugins`` deque as long as
their marks include ``mark``.
"""
while plugins and mark in self.marks(plugins[0]):
yield plugins.popleft()
def handle(self, plugins, context):
"""
Runs the ``handle_<subregion>`` handler for the head of the ``plugins``
deque.
This method requires that a matching handler for all values returned by
``self.subregion()`` exists.
You probably want to call this method when overriding the rendering of
a complete region.
"""
plugins = deque(plugins)
while plugins:
yield from self._handlers[self.subregion(plugins[0])](plugins, context)
def handle_default(self, plugins, context):
"""
Renders plugins from the queue as long as there are plugins belonging
to the ``default`` subregion.
"""
for plugin in self.takewhile_subregion(plugins, "default"):
yield self.render_plugin(plugin, context)
def render_region(self, *, region, contents, context):
"""
Render one region.
"""
return mark_safe("".join(self.handle(contents[region.key], context)))
def render_regions(self, *, regions, contents, context):
"""
Render multiple regions.
This method should return a dictionary.
"""
return {
region.key: self.render_region(
region=region, contents=contents, context=context
)
for region in regions
}
# Sections support
def render_section_plugins(self, section, plugins, context):
"""
Helper for implementing section rendering with section supporting
optional nesting etc.
You need:
- A close section plugin
- One or more open section plugins
Here's some example code to hopefully get you started:
.. code-block:: python
from django.utils.html import conditional_escape, mark_safe
from feincms3.renderer import render_in_context
class SectionRenderer(RegionRenderer):
def handle_section(self, plugins, context):
section = plugins.popleft()
content = self.render_section_plugins(section, plugins, context)
yield render_in_context(
context,
# That's just an example:
f"sections/{section.__class__.__name__.lower()}.html",
{
"section": section,
"content": mark_safe("".join(map(conditional_escape, content))),
},
)
renderer = SectionRenderer()
renderer.register_section_close(models.CloseSection)
renderer.register(models.Accordion, "", subregion="section")
This code automatically determines the template name from the class
name, making it easier to reuse for different section types.
Subregions are automatically closed when subregions change. Sections
only end when encountering an explicit section closing plugin or when
there are no more plugins in the current region at all. Sections can
contain other sections and subregions making them quite powerful when
for organizing and grouping content.
"""
out = []
while plugins:
subregion = self.subregion(plugins[0])
if subregion is _CLOSE_SECTION:
plugins.popleft()
break
elif subregion is not None:
out.extend(self._handlers[subregion](plugins, context))
else:
out.append(self.render_plugin(plugins.popleft(), context))
return out
def register_section_close(self, plugin, renderer="", **kwargs):
kwargs["subregion"] = _CLOSE_SECTION
self.register(plugin, renderer, **kwargs)
def handle__close_section(self, plugins, context):
plugins.popleft()
yield from ()
# Main external rendering API
def regions_from_contents(self, contents, **kwargs):
"""
Return an opaque object encapsulating
:mod:`content_editor.contents.Contents` and the logic required to
render them.
All you need to know is that the return value has a ``regions``
attribute containing a list of regions and a ``render`` method
accepting a region key and a context instance.
"""
return _Regions(contents=contents, renderer=self, **kwargs)
def regions_from_item(self, item, /, *, inherit_from=None, timeout=None, **kwargs):
"""
Return an opaque object, see
:func:`~feincms3.renderer.RegionRenderer.regions_from_contents`
Automatically caches the return value if ``timeout`` is truthy. The
default cache key only takes the ``item``'s class and primary key into
account. You may have to override the cache key by passing
``cache_key`` if you're doing strange^Wadvanced things.
"""
if timeout and kwargs.get("cache_key") is None:
kwargs["cache_key"] = f"regions-{item._meta.label_lower}-{item.pk}"
contents = SimpleLazyObject(
lambda: contents_for_item(item, self.plugins(), inherit_from=inherit_from)
)
return self.regions_from_contents(contents, timeout=timeout, **kwargs)
# TemplatePluginRenderer compatibility
def register_string_renderer(self, plugin, renderer):
"""Backwards compatibility for ``TemplatePluginRenderer``. It is
deprecated, don't use in new code."""
warnings.warn(
"register_string_renderer is deprecated. Use register instead."
" (Hint: register_string_renderer(plugin, renderer) can be replaced by"
" register(plugin, renderer) most of the time. The renderer has to be"
" changed to accept an additional `context` argument.)",
DeprecationWarning,
stacklevel=2,
)
if callable(renderer):
self.register(plugin, lambda plugin, context: renderer(plugin))
else:
self.register(plugin, renderer)
def register_template_renderer(
self, plugin, template_name, context=default_context
):
"""Backwards compatibility for ``TemplatePluginRenderer``. It is
deprecated, don't use in new code."""
warnings.warn(
"register_template_renderer is deprecated. Use register instead."
" (Hint: register_template_renderer(plugin, template_name, local_context)"
" can be replaced by register(plugin, template_renderer(template_name, local_context))"
" most of the time.)",
DeprecationWarning,
stacklevel=2,
)
self.register(plugin, _compat_template_renderer(template_name, context))
def render_plugin_in_context(self, plugin, context=None):
"""Backwards compatibility for ``TemplatePluginRenderer``. It is
deprecated, don't use in new code."""
warnings.warn(
"render_plugin_in_context is deprecated. Use render_plugin instead."
" (Hint: render_plugin works exactly the same except that the `context` argument"
" is required.)",
DeprecationWarning,
stacklevel=2,
)
return self.render_plugin(plugin, context)
class _Regions:
"""
Opaque object implementing the following interface:
- ``regions``: A list of regions used for the content editor ``Contents``
instance.
- ``render(region_key, context)``: A function which actually runs the
renderer.
"""
def __init__(self, *, contents, renderer, cache_key=None, timeout=None):
self._contents = contents
self._renderer = renderer
self._cache_key = cache_key
self._timeout = timeout
def _rendered(self, context):
caching = self._cache_key and self._timeout
if caching and (result := cache.get(self._cache_key)):
return result
result = self._renderer.render_regions(
regions=self.regions, contents=self._contents, context=context
)
if caching:
cache.set(self._cache_key, result, timeout=self._timeout)
return result
@property
def regions(self):
return self._contents.regions
def render(self, region_key, context):
rendered = self._rendered(context)
return rendered.get(region_key, "")
def _compat_template_renderer(_tpl, _ctx=default_context, /):
"""Compatibility implementation which accepts an optionally callable
template and an optionally callable context function."""
def renderer(plugin, context):
template_name = _tpl(plugin) if callable(_tpl) else _tpl
local_context = _ctx(plugin, context) if callable(_ctx) else _ctx
return render_in_context(context, template_name, local_context)
return renderer
class TemplatePluginRenderer(RegionRenderer):
"""
TemplatePluginRenderer is a deprecated alias for
:class:`~feincms3.renderer.RegionRenderer`.
"""
def __init__(self, *args, **kwargs):
warnings.warn(
"TemplatePluginRenderer is a deprecated alias for the new RegionRenderer."
" (Hint: An incremental upgrade is supported. You can start by"
" replacing TemplatePluginRenderer with RegionRenderer.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)