BlueprintPluginManager   A
last analyzed

Complexity

Total Complexity 3

Size/Duplication

Total Lines 24
Duplicated Lines 0 %

Importance

Changes 3
Bugs 1 Features 1
Metric Value
c 3
b 1
f 1
dl 0
loc 24
rs 10
wmc 3

2 Methods

Rating   Name   Duplication   Size   Complexity  
A register_blueprint() 0 13 2
A __init__() 0 3 1
1
#!/usr/bin/env python
2
# -*- coding: UTF-8 -*-
3
4
import re
5
import sys
6
import argparse
7
import warnings
8
import collections
9
10
from flask import current_app
11
from werkzeug.utils import cached_property
12
13
from . import mimetype
14
from . import compat
15
from .compat import deprecated, usedoc
16
17
18
def defaultsnamedtuple(name, fields, defaults=None):
19
    '''
20
    Generate namedtuple with default values.
21
22
    :param name: name
23
    :param fields: iterable with field names
24
    :param defaults: iterable or mapping with field defaults
25
    :returns: defaultdict with given fields and given defaults
26
    :rtype: collections.defaultdict
27
    '''
28
    nt = collections.namedtuple(name, fields)
29
    nt.__new__.__defaults__ = (None,) * len(nt._fields)
30
    if isinstance(defaults, collections.Mapping):
31
        nt.__new__.__defaults__ = tuple(nt(**defaults))
32
    elif defaults:
33
        nt.__new__.__defaults__ = tuple(nt(*defaults))
34
    return nt
35
36
37
class PluginNotFoundError(ImportError):
38
    pass
39
40
41
class WidgetException(Exception):
42
    pass
43
44
45
class WidgetParameterException(WidgetException):
46
    pass
47
48
49
class InvalidArgumentError(ValueError):
50
    pass
51
52
53
class PluginManagerBase(object):
54
    '''
55
    Base plugin manager for plugin module loading and Flask extension logic.
56
    '''
57
58
    @property
59
    def namespaces(self):
60
        '''
61
        List of plugin namespaces taken from app config.
62
        '''
63
        return self.app.config['plugin_namespaces'] if self.app else []
64
65
    def __init__(self, app=None):
66
        if app is None:
67
            self.clear()
68
        else:
69
            self.init_app(app)
70
71
    def init_app(self, app):
72
        '''
73
        Initialize this Flask extension for given app.
74
        '''
75
        self.app = app
76
        if not hasattr(app, 'extensions'):
77
            app.extensions = {}
78
        app.extensions['plugin_manager'] = self
79
        self.reload()
80
81
    def reload(self):
82
        '''
83
        Clear plugin manager state and reload plugins.
84
85
        This method will make use of :meth:`clear` and :meth:`load_plugin`,
86
        so all internal state will be cleared, and all plugins defined in
87
        :data:`self.app.config['plugin_modules']` will be loaded.
88
        '''
89
        self.clear()
90
        for plugin in self.app.config.get('plugin_modules', ()):
91
            self.load_plugin(plugin)
92
93
    def clear(self):
94
        '''
95
        Clear plugin manager state.
96
        '''
97
        pass
98
99
    def import_plugin(self, plugin):
100
        '''
101
        Import plugin by given name, looking at :attr:`namespaces`.
102
103
        :param plugin: plugin module name
104
        :type plugin: str
105
        :raises PluginNotFoundError: if not found on any namespace
106
        '''
107
        names = [
108
            '%s%s%s' % (namespace, '' if namespace[-1] == '_' else '.', plugin)
109
            if namespace else
110
            plugin
111
            for namespace in self.namespaces
112
            ]
113
114
        for name in names:
115
            if name in sys.modules:
116
                return sys.modules[name]
117
118
        for name in names:
119
            try:
120
                __import__(name)
121
                return sys.modules[name]
122
            except (ImportError, KeyError):
123
                pass
124
125
        raise PluginNotFoundError(
126
            'No plugin module %r found, tried %r' % (plugin, names),
127
            plugin, names)
128
129
    def load_plugin(self, plugin):
130
        '''
131
        Import plugin (see :meth:`import_plugin`) and load related data.
132
133
        :param plugin: plugin module name
134
        :type plugin: str
135
        :raises PluginNotFoundError: if not found on any namespace
136
        '''
137
        return self.import_plugin(plugin)
138
139
140
class RegistrablePluginManager(PluginManagerBase):
141
    '''
142
    Base plugin manager for plugin registration via :func:`register_plugin`
143
    functions at plugin module level.
144
    '''
145
    def load_plugin(self, plugin):
146
        '''
147
        Import plugin (see :meth:`import_plugin`) and load related data.
148
149
        If available, plugin's module-level :func:`register_plugin` function
150
        will be called with current plugin manager instance as first argument.
151
152
        :param plugin: plugin module name
153
        :type plugin: str
154
        :raises PluginNotFoundError: if not found on any namespace
155
        '''
156
        module = super(RegistrablePluginManager, self).load_plugin(plugin)
157
        if hasattr(module, 'register_plugin'):
158
            module.register_plugin(self)
159
        return module
160
161
162
class BlueprintPluginManager(RegistrablePluginManager):
163
    '''
164
    Manager for blueprint registration via :meth:`register_plugin` calls.
165
166
    Note: blueprints are not removed on `clear` nor reloaded on `reload`
167
    as flask does not allow it.
168
    '''
169
    def __init__(self, app=None):
170
        self._blueprint_known = set()
171
        super(BlueprintPluginManager, self).__init__(app=app)
172
173
    def register_blueprint(self, blueprint):
174
        '''
175
        Register given blueprint on curren app.
176
177
        This method is provided for using inside plugin's module-level
178
        :func:`register_plugin` functions.
179
180
        :param blueprint: blueprint object with plugin endpoints
181
        :type blueprint: flask.Blueprint
182
        '''
183
        if blueprint not in self._blueprint_known:
184
            self.app.register_blueprint(blueprint)
185
            self._blueprint_known.add(blueprint)
186
187
188
class WidgetPluginManager(RegistrablePluginManager):
189
    '''
190
    Plugin manager for widget registration.
191
192
    This class provides a dictionary of widget types at its
193
    :attr:`widget_types` attribute. They can be referenced by their keys on
194
    both :meth:`create_widget` and :meth:`register_widget` methods' `type`
195
    parameter, or instantiated directly and passed to :meth:`register_widget`
196
    via `widget` parameter.
197
    '''
198
    widget_types = {
199
        'base': defaultsnamedtuple(
200
            'Widget',
201
            ('place', 'type')),
202
        'link': defaultsnamedtuple(
203
            'Link',
204
            ('place', 'type', 'css', 'icon', 'text', 'endpoint', 'href'),
205
            {
206
                'type': 'link',
207
                'text': lambda f: f.name,
208
                'icon': lambda f: f.category
209
            }),
210
        'button': defaultsnamedtuple(
211
            'Button',
212
            ('place', 'type', 'css', 'text', 'endpoint', 'href'),
213
            {'type': 'button'}),
214
        'upload': defaultsnamedtuple(
215
            'Upload',
216
            ('place', 'type', 'css', 'text', 'endpoint', 'action'),
217
            {'type': 'upload'}),
218
        'stylesheet': defaultsnamedtuple(
219
            'Stylesheet',
220
            ('place', 'type', 'endpoint', 'filename', 'href'),
221
            {'type': 'stylesheet'}),
222
        'script': defaultsnamedtuple(
223
            'Script',
224
            ('place', 'type', 'endpoint', 'filename', 'src'),
225
            {'type': 'script'}),
226
        'html': defaultsnamedtuple(
227
            'Html',
228
            ('place', 'type', 'html'),
229
            {'type': 'html'}),
230
    }
231
232
    def clear(self):
233
        '''
234
        Clear plugin manager state.
235
236
        Registered widgets will be disposed after calling this method.
237
        '''
238
        self._widgets = []
239
        super(WidgetPluginManager, self).clear()
240
241
    def get_widgets(self, file=None, place=None):
242
        '''
243
        List registered widgets, optionally matching given criteria.
244
245
        :param file: optional file object will be passed to widgets' filter
246
                     functions.
247
        :type file: browsepy.file.Node or None
248
        :param place: optional template place hint.
249
        :type place: str
250
        :returns: list of widget instances
251
        :rtype: list of objects
252
        '''
253
        return list(self.iter_widgets(file, place))
254
255
    @classmethod
256
    def _resolve_widget(cls, file, widget):
257
        '''
258
        Resolve widget callable properties into static ones.
259
260
        :param file: file will be used to resolve callable properties.
261
        :type file: browsepy.file.Node
262
        :param widget: widget instance optionally with callable properties
263
        :type widget: object
264
        :returns: a new widget instance of the same type as widget parameter
265
        :rtype: object
266
        '''
267
        return widget.__class__(*[
268
            value(file) if callable(value) else value
269
            for value in widget
270
            ])
271
272
    def iter_widgets(self, file=None, place=None):
273
        '''
274
        Iterate registered widgets, optionally matching given criteria.
275
276
        :param file: optional file object will be passed to widgets' filter
277
                     functions.
278
        :type file: browsepy.file.Node or None
279
        :param place: optional template place hint.
280
        :type place: str
281
        :yields: widget instances
282
        :ytype: object
283
        '''
284
        for filter, dynamic, cwidget in self._widgets:
285
            try:
286
                if file and filter and not filter(file):
287
                    continue
288
            except BaseException as e:
289
                # Exception is handled  as this method execution is deffered,
290
                # making hard to debug for plugin developers.
291
                warnings.warn(
292
                    'Plugin action filtering failed with error: %s' % e,
293
                    RuntimeWarning
294
                    )
295
                continue
296
            if place and place != cwidget.place:
297
                continue
298
            if file and dynamic:
299
                cwidget = self._resolve_widget(file, cwidget)
300
            yield cwidget
301
302
    def create_widget(self, place, type, file=None, **kwargs):
303
        '''
304
        Create a widget object based on given arguments.
305
306
        If file object is provided, callable arguments will be resolved:
307
        its return value will be used after calling them with file as first
308
        parameter.
309
310
        All extra `kwargs` parameters will be passed to widget constructor.
311
312
        :param place: place hint where widget should be shown.
313
        :type place: str
314
        :param type: widget type name as taken from :attr:`widget_types` dict
315
                     keys.
316
        :type type: str
317
        :param file: optional file object for widget attribute resolving
318
        :type type: browsepy.files.Node or None
319
        :returns: widget instance
320
        :rtype: object
321
        '''
322
        widget_class = self.widget_types.get(type, self.widget_types['base'])
323
        kwargs.update(place=place, type=type)
324
        try:
325
            element = widget_class(**kwargs)
326
        except TypeError as e:
327
            message = e.args[0] if e.args else ''
328
            if (
329
              'unexpected keyword argument' in message or
330
              'required positional argument' in message
331
              ):
332
                raise WidgetParameterException(
333
                    'type %s; %s; available: %r'
334
                    % (type, message, widget_class._fields)
335
                    )
336
            raise e
337
        if file and any(map(callable, element)):
338
            return self._resolve_widget(file, element)
339
        return element
340
341
    def register_widget(self, place=None, type=None, widget=None, filter=None,
342
                        **kwargs):
343
        '''
344
        Create (see :meth:`create_widget`) or use provided widget and register
345
        it.
346
347
        This method provides this dual behavior in order to simplify widget
348
        creation-registration on an functional single step without sacrifycing
349
        the reusability of a object-oriented approach.
350
351
        :param place: where widget should be placed. This param conflicts
352
                      with `widget` argument.
353
        :type place: str or None
354
        :param type: widget type name as taken from :attr:`widget_types` dict
355
                     keys. This param conflicts with `widget` argument.
356
        :type type: str or None
357
        :param widget: optional widget object will be used as is. This param
358
                       conflicts with both place and type arguments.
359
        :type widget: object or None
360
        :raises TypeError: if both widget and place or type are provided at
361
                           the same time (they're mutually exclusive).
362
        :returns: created or given widget object
363
        :rtype: object
364
        '''
365
        if bool(widget) == bool(place or type):
366
            raise InvalidArgumentError(
367
                'register_widget takes either place and type or widget'
368
                )
369
        widget = widget or self.create_widget(place, type, **kwargs)
370
        dynamic = any(map(callable, widget))
371
        self._widgets.append((filter, dynamic, widget))
372
        return widget
373
374
375
class MimetypePluginManager(RegistrablePluginManager):
376
    '''
377
    Plugin manager for mimetype-function registration.
378
    '''
379
    _default_mimetype_functions = (
380
        mimetype.by_python,
381
        mimetype.by_file,
382
        mimetype.by_default,
383
    )
384
385
    def clear(self):
386
        '''
387
        Clear plugin manager state.
388
389
        Registered mimetype functions will be disposed after calling this
390
        method.
391
        '''
392
        self._mimetype_functions = list(self._default_mimetype_functions)
393
        super(MimetypePluginManager, self).clear()
394
395
    def get_mimetype(self, path):
396
        '''
397
        Get mimetype of given path calling all registered mime functions (and
398
        default ones).
399
400
        :param path: filesystem path of file
401
        :type path: str
402
        :returns: mimetype
403
        :rtype: str
404
        '''
405
        for fnc in self._mimetype_functions:
406
            mime = fnc(path)
407
            if mime:
408
                return mime
409
        return mimetype.by_default(path)
410
411
    def register_mimetype_function(self, fnc):
412
        '''
413
        Register mimetype function.
414
415
        Given function must accept a filesystem path as string and return
416
        a mimetype string or None.
417
418
        :param fnc: callable accepting a path string
419
        :type fnc: callable
420
        '''
421
        self._mimetype_functions.insert(0, fnc)
422
423
424
class ArgumentPluginManager(PluginManagerBase):
425
    '''
426
    Plugin manager for command-line argument registration.
427
428
    This function is used by browsepy's :mod:`__main__` module in order
429
    to attach extra arguments at argument-parsing time.
430
431
    This is done by :meth:`load_arguments` which imports all plugin modules
432
    and calls their respective :func:`register_arguments` module-level
433
    function.
434
    '''
435
    _argparse_kwargs = {'add_help': False}
436
    _argparse_arguments = argparse.Namespace()
437
438
    def extract_plugin_arguments(self, plugin):
439
        '''
440
        Given a plugin name, extracts its registered_arguments as an
441
        iterable of (args, kwargs) tuples.
442
443
        :param plugin: plugin name
444
        :type plugin: str
445
        :returns: iterable if (args, kwargs) tuples.
446
        :rtype: iterable
447
        '''
448
        module = self.import_plugin(plugin)
449
        if hasattr(module, 'register_arguments'):
450
            manager = ArgumentPluginManager()
451
            module.register_arguments(manager)
452
            return manager._argparse_argkwargs
453
        return ()
454
455
    def load_arguments(self, argv, base=None):
456
        '''
457
        Process given argument list based on registered arguments and given
458
        optional base :class:`argparse.ArgumentParser` instance.
459
460
        This method saves processed arguments on itself, and this state won't
461
        be lost after :meth:`clean` calls.
462
463
        Processed argument state will be available via :meth:`get_argument`
464
        method.
465
466
        :param argv: command-line arguments (without command itself)
467
        :type argv: iterable of str
468
        :param base: optional base :class:`argparse.ArgumentParser` instance.
469
        :type base: argparse.ArgumentParser or None
470
        :returns: argparse.Namespace instance with processed arguments as
471
                  given by :meth:`argparse.ArgumentParser.parse_args`.
472
        :rtype: argparse.Namespace
473
        '''
474
        plugin_parser = argparse.ArgumentParser(add_help=False)
475
        plugin_parser.add_argument('--plugin', action='append', default=[])
476
        parent = base or plugin_parser
477
        parser = argparse.ArgumentParser(
478
            parents=(parent,),
479
            add_help=False,
480
            **getattr(parent, 'defaults', {})
481
            )
482
        plugins = [
483
            plugin
484
            for plugins in plugin_parser.parse_known_args(argv)[0].plugin
485
            for plugin in plugins.split(',')
486
            ]
487
        for plugin in sorted(set(plugins), key=plugins.index):
488
            arguments = self.extract_plugin_arguments(plugin)
489
            if arguments:
490
                group = parser.add_argument_group('%s arguments' % plugin)
491
                for argargs, argkwargs in arguments:
492
                    group.add_argument(*argargs, **argkwargs)
493
        self._argparse_arguments = parser.parse_args(argv)
494
        return self._argparse_arguments
495
496
    def clear(self):
497
        '''
498
        Clear plugin manager state.
499
500
        Registered command-line arguments will be disposed after calling this
501
        method.
502
        '''
503
        self._argparse_argkwargs = []
504
        super(ArgumentPluginManager, self).clear()
505
506
    def register_argument(self, *args, **kwargs):
507
        '''
508
        Register command-line argument.
509
510
        All given arguments will be passed directly to
511
        :meth:`argparse.ArgumentParser.add_argument` calls by
512
        :meth:`load_arguments` method.
513
514
        See :meth:`argparse.ArgumentParser.add_argument` documentation for
515
        further information.
516
        '''
517
        self._argparse_argkwargs.append((args, kwargs))
518
519
    def get_argument(self, name, default=None):
520
        '''
521
        Get argument value from last :meth:`load_arguments` call.
522
523
        Keep in mind :meth:`argparse.ArgumentParser.parse_args` generates
524
        its own command-line arguments if `dest` kwarg is not provided,
525
        so ie. `--my-option` became available as `my_option`.
526
527
        :param name: command-line argument name
528
        :type name: str
529
        :param default: default value if parameter is not found
530
        :returns: command-line argument or default value
531
        '''
532
        return getattr(self._argparse_arguments, name, default)
533
534
535
class MimetypeActionPluginManager(WidgetPluginManager, MimetypePluginManager):
536
    '''
537
    Deprecated plugin API
538
    '''
539
540
    _deprecated_places = {
541
        'javascript': 'scripts',
542
        'style': 'styles',
543
        'button': 'entry-actions',
544
        'link': 'entry-link',
545
        }
546
547
    @classmethod
548
    def _mimetype_filter(cls, mimetypes):
549
        widget_mimetype_re = re.compile(
550
            '^%s$' % '$|^'.join(
551
                map(re.escape, mimetypes)
552
                ).replace('\\*', '[^/]+')
553
            )
554
555
        def handler(f):
556
            return widget_mimetype_re.match(f.type) is not None
557
558
        return handler
559
560
    def _widget_attrgetter(self, widget, name):
561
        def handler(f):
562
            app = f.app or self.app or current_app
563
            with app.app_context():
564
                return getattr(widget.for_file(f), name)
565
        return handler
566
567
    def _widget_props(self, widget, endpoint=None, mimetypes=(),
568
                      dynamic=False):
569
        type = getattr(widget, '_type', 'base')
570
        fields = self.widget_types[type]._fields
571
        with self.app.app_context():
572
            props = {
573
                name: self._widget_attrgetter(widget, name)
574
                for name in fields
575
                if hasattr(widget, name)
576
                }
577
        props.update(
578
            type=type,
579
            place=self._deprecated_places.get(widget.place),
580
            )
581
        if dynamic:
582
            props['filter'] = self._mimetype_filter(mimetypes)
583
        if 'endpoint' in fields:
584
            props['endpoint'] = endpoint
585
        return props
586
587
    @usedoc(WidgetPluginManager.__init__)
588
    def __init__(self, app=None):
589
        self._action_widgets = []
590
        super(MimetypeActionPluginManager, self).__init__(app=app)
591
592
    @usedoc(WidgetPluginManager.clear)
593
    def clear(self):
594
        self._action_widgets[:] = ()
595
        super(MimetypeActionPluginManager, self).clear()
596
597
    @cached_property
598
    def _widget(self):
599
        with warnings.catch_warnings():
600
            warnings.filterwarnings('ignore', category=DeprecationWarning)
601
            from . import widget
602
        return widget
603
604
    @cached_property
605
    @deprecated('Deprecated attribute action_class')
606
    def action_class(self):
607
        return collections.namedtuple(
608
            'MimetypeAction',
609
            ('endpoint', 'widget')
610
            )
611
612
    @cached_property
613
    @deprecated('Deprecated attribute style_class')
614
    def style_class(self):
615
        return self._widget.StyleWidget
616
617
    @cached_property
618
    @deprecated('Deprecated attribute button_class')
619
    def button_class(self):
620
        return self._widget.ButtonWidget
621
622
    @cached_property
623
    @deprecated('Deprecated attribute javascript_class')
624
    def javascript_class(self):
625
        return self._widget.JavascriptWidget
626
627
    @cached_property
628
    @deprecated('Deprecated attribute link_class')
629
    def link_class(self):
630
        return self._widget.LinkWidget
631
632
    @deprecated('Deprecated method register_action')
633
    def register_action(self, endpoint, widget, mimetypes=(), **kwargs):
634
        props = self._widget_props(widget, endpoint, mimetypes, True)
635
        self.register_widget(**props)
636
        self._action_widgets.append((widget, props['filter'], endpoint))
637
638
    @deprecated('Deprecated method get_actions')
639
    def get_actions(self, file):
640
        return [
641
            self.action_class(endpoint, deprecated.for_file(file))
642
            for deprecated, filter, endpoint in self._action_widgets
643
            if endpoint and filter(file)
644
            ]
645
646
    @usedoc(WidgetPluginManager.register_widget)
647
    def register_widget(self, place=None, type=None, widget=None, filter=None,
648
                        **kwargs):
649
        if isinstance(place or widget, self._widget.WidgetBase):
650
            warnings.warn(
651
                'Deprecated use of register_widget',
652
                category=DeprecationWarning
653
                )
654
            widget = place or widget
655
            props = self._widget_props(widget)
656
            self.register_widget(**props)
657
            self._action_widgets.append((widget, None, None))
658
            return
659
        return super(MimetypeActionPluginManager, self).register_widget(
660
            place=place, type=type, widget=widget, filter=filter, **kwargs)
661
662
    @usedoc(WidgetPluginManager.get_widgets)
663
    def get_widgets(self, file=None, place=None):
664
        if isinstance(file, compat.basestring) or \
665
          place in self._deprecated_places:
666
            warnings.warn(
667
                'Deprecated use of get_widgets',
668
                category=DeprecationWarning
669
                )
670
            place = file or place
671
            return [
672
                widget
673
                for widget, filter, endpoint in self._action_widgets
674
                if not (filter or endpoint) and place == widget.place
675
                ]
676
        return super(MimetypeActionPluginManager, self).get_widgets(
677
            file=file, place=place)
678
679
680
class PluginManager(MimetypeActionPluginManager,
681
                    BlueprintPluginManager, WidgetPluginManager,
682
                    MimetypePluginManager, ArgumentPluginManager):
683
    '''
684
    Main plugin manager
685
686
    Provides:
687
        * Plugin module loading and Flask extension logic.
688
        * Plugin registration via :func:`register_plugin` functions at plugin
689
          module level.
690
        * Plugin blueprint registration via :meth:`register_plugin` calls.
691
        * Widget registration via :meth:`register_widget` method.
692
        * Mimetype function registration via :meth:`register_mimetype_function`
693
          method.
694
        * Command-line argument registration calling :func:`register_arguments`
695
          at plugin module level and providing :meth:`register_argument`
696
          method.
697
698
    This class also provides a dictionary of widget types at its
699
    :attr:`widget_types` attribute. They can be referenced by their keys on
700
    both :meth:`create_widget` and :meth:`register_widget` methods' `type`
701
    parameter, or instantiated directly and passed to :meth:`register_widget`
702
    via `widget` parameter.
703
    '''
704
    def clear(self):
705
        '''
706
        Clear plugin manager state.
707
708
        Registered widgets will be disposed after calling this method.
709
710
        Registered mimetype functions will be disposed after calling this
711
        method.
712
713
        Registered command-line arguments will be disposed after calling this
714
        method.
715
        '''
716
        super(PluginManager, self).clear()
717