Completed
Push — file-actions ( 5064d9 )
by Felipe A.
26s
created

PluginManagerBase.import_plugin()   F

Complexity

Conditions 10

Size

Total Lines 31

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 31
rs 3.1304
cc 10

How to fix   Complexity   

Complexity

Complex classes like PluginManagerBase.import_plugin() often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

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