Completed
Push — dev-4.1-unstable ( 426fcf...0bd0f2 )
by Felipe A.
01:04
created

MimetypeActionPluginManager.clear()   A

Complexity

Conditions 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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