Completed
Push — master ( 1806d1...053f07 )
by Nicolas
01:42
created

GlancesPlugin   F

Complexity

Total Complexity 129

Size/Duplication

Total Lines 631
Duplicated Lines 0 %

Importance

Changes 6
Bugs 0 Features 0
Metric Value
c 6
b 0
f 0
dl 0
loc 631
rs 1.481
wmc 129

41 Methods

Rating   Name   Duplication   Size   Complexity  
B __init__() 0 31 1
B reset_stats_history() 0 6 5
B load_limits() 0 12 5
A set_stats() 0 3 1
A add_item_history() 0 6 2
D get_stats_snmp() 0 54 11
A get_key() 0 3 1
A limits() 0 4 1
A get_stats_history() 0 3 1
A __repr__() 0 3 1
A get_raw() 0 3 1
A get_stats() 0 3 1
A input_method() 0 4 1
A get_items_history_list() 0 3 1
A __str__() 0 3 1
C update_stats_history() 0 18 8
A exit() 0 3 1
A get_export() 0 3 1
A set_views() 0 3 1
A short_system_name() 0 4 1
B get_stats_item() 0 21 5
B get_stats_value() 0 16 6
A get_views() 0 21 4
B init_stats_history() 0 8 5
F update_views() 0 37 9
A is_hide() 0 10 3
B curse_add_line() 0 29 1
F get_alert() 0 81 15
A get_conf_value() 0 17 4
A __get_limit() 0 13 2
A __get_limit_log() 0 17 3
A _log_result_decorator() 0 10 2
A has_alias() 0 6 2
A msg_curse() 0 3 1
A __get_limit_action() 0 13 2
D auto_unit() 0 47 8
A wrapper() 0 7 1
A align() 0 4 1
B get_stats_display() 0 26 4
A get_alert_log() 0 3 1
A curse_new_line() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like GlancesPlugin 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
# -*- coding: utf-8 -*-
2
#
3
# This file is part of Glances.
4
#
5
# Copyright (C) 2015 Nicolargo <[email protected]>
6
#
7
# Glances is free software; you can redistribute it and/or modify
8
# it under the terms of the GNU Lesser General Public License as published by
9
# the Free Software Foundation, either version 3 of the License, or
10
# (at your option) any later version.
11
#
12
# Glances is distributed in the hope that it will be useful,
13
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
# GNU Lesser General Public License for more details.
16
#
17
# You should have received a copy of the GNU Lesser General Public License
18
# along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20
"""
21
I am your father...
22
23
...for all Glances plugins.
24
"""
25
26
import re
27
import json
28
from datetime import datetime
29
from operator import itemgetter
30
31
from glances.compat import iterkeys, itervalues, listkeys, map
32
from glances.actions import GlancesActions
33
from glances.logger import logger
34
from glances.logs import glances_logs
35
36
37
class GlancesPlugin(object):
38
39
    """Main class for Glances plugin."""
40
41
    def __init__(self, args=None, items_history_list=None):
42
        """Init the plugin of plugins class."""
43
        # Plugin name (= module name without glances_)
44
        self.plugin_name = self.__class__.__module__[len('glances_'):]
45
        # logger.debug("Init plugin %s" % self.plugin_name)
46
47
        # Init the args
48
        self.args = args
49
50
        # Init the default alignement (for curses)
51
        self._align = 'left'
52
53
        # Init the input method
54
        self._input_method = 'local'
55
        self._short_system_name = None
56
57
        # Init the stats list
58
        self.stats = None
59
60
        # Init the history list
61
        self.items_history_list = items_history_list
62
        self.stats_history = self.init_stats_history()
63
64
        # Init the limits dictionnary
65
        self._limits = dict()
66
67
        # Init the actions
68
        self.actions = GlancesActions(args=args)
69
70
        # Init the views
71
        self.views = dict()
72
73
    def exit(self):
74
        """Method to be called when Glances exit"""
75
        logger.debug("Stop the {0} plugin".format(self.plugin_name))
76
77
    def __repr__(self):
78
        """Return the raw stats."""
79
        return self.stats
80
81
    def __str__(self):
82
        """Return the human-readable stats."""
83
        return str(self.stats)
84
85
    def get_key(self):
86
        """Return the key of the list."""
87
        return None
88
89
    def add_item_history(self, key, value):
90
        """Add an new item (key, value) to the current history."""
91
        try:
92
            self.stats_history[key].append(value)
93
        except KeyError:
94
            self.stats_history[key] = [value]
95
96
    def init_stats_history(self):
97
        """Init the stats history (dict of list)."""
98
        ret = None
99
        if self.args is not None and self.args.enable_history and self.get_items_history_list() is not None:
100
            init_list = [i['name'] for i in self.get_items_history_list()]
101
            logger.debug("Stats history activated for plugin {0} (items: {1})".format(self.plugin_name, init_list))
102
            ret = {}
103
        return ret
104
105
    def reset_stats_history(self):
106
        """Reset the stats history (dict of list)."""
107
        if self.args is not None and self.args.enable_history and self.get_items_history_list() is not None:
108
            reset_list = [i['name'] for i in self.get_items_history_list()]
109
            logger.debug("Reset history for plugin {0} (items: {1})".format(self.plugin_name, reset_list))
110
            self.stats_history = {}
111
112
    def update_stats_history(self, item_name=''):
113
        """Update stats history."""
114
        if (self.stats and self.args is not None and
115
                self.args.enable_history and
116
                self.get_items_history_list() is not None):
117
            self.add_item_history('date', datetime.now())
118
            for i in self.get_items_history_list():
119
                if isinstance(self.stats, list):
120
                    # Stats is a list of data
121
                    # Iter throught it (for exemple, iter throught network
122
                    # interface)
123
                    for l in self.stats:
124
                        self.add_item_history(
125
                            l[item_name] + '_' + i['name'], l[i['name']])
126
                else:
127
                    # Stats is not a list
128
                    # Add the item to the history directly
129
                    self.add_item_history(i['name'], self.stats[i['name']])
130
131
    def get_stats_history(self):
132
        """Return the stats history."""
133
        return self.stats_history
134
135
    def get_items_history_list(self):
136
        """Return the items history list."""
137
        return self.items_history_list
138
139
    @property
140
    def input_method(self):
141
        """Get the input method."""
142
        return self._input_method
143
144
    @input_method.setter
145
    def input_method(self, input_method):
146
        """Set the input method.
147
148
        * local: system local grab (psutil or direct access)
149
        * snmp: Client server mode via SNMP
150
        * glances: Client server mode via Glances API
151
        """
152
        self._input_method = input_method
153
154
    @property
155
    def short_system_name(self):
156
        """Get the short detected OS name (SNMP)."""
157
        return self._short_system_name
158
159
    @short_system_name.setter
160
    def short_system_name(self, short_name):
161
        """Set the short detected OS name (SNMP)."""
162
        self._short_system_name = short_name
163
164
    def set_stats(self, input_stats):
165
        """Set the stats to input_stats."""
166
        self.stats = input_stats
167
168
    def get_stats_snmp(self, bulk=False, snmp_oid=None):
169
        """Update stats using SNMP.
170
171
        If bulk=True, use a bulk request instead of a get request.
172
        """
173
        snmp_oid = snmp_oid or {}
174
175
        from glances.snmp import GlancesSNMPClient
176
177
        # Init the SNMP request
178
        clientsnmp = GlancesSNMPClient(host=self.args.client,
179
                                       port=self.args.snmp_port,
180
                                       version=self.args.snmp_version,
181
                                       community=self.args.snmp_community)
182
183
        # Process the SNMP request
184
        ret = {}
185
        if bulk:
186
            # Bulk request
187
            snmpresult = clientsnmp.getbulk_by_oid(0, 10, itervalues(*snmp_oid))
188
189
            if len(snmp_oid) == 1:
190
                # Bulk command for only one OID
191
                # Note: key is the item indexed but the OID result
192
                for item in snmpresult:
193
                    if iterkeys(item)[0].startswith(itervalues(snmp_oid)[0]):
194
                        ret[iterkeys(snmp_oid)[0] + iterkeys(item)
195
                            [0].split(itervalues(snmp_oid)[0])[1]] = itervalues(item)[0]
196
            else:
197
                # Build the internal dict with the SNMP result
198
                # Note: key is the first item in the snmp_oid
199
                index = 1
200
                for item in snmpresult:
201
                    item_stats = {}
202
                    item_key = None
203
                    for key in iterkeys(snmp_oid):
204
                        oid = snmp_oid[key] + '.' + str(index)
205
                        if oid in item:
206
                            if item_key is None:
207
                                item_key = item[oid]
208
                            else:
209
                                item_stats[key] = item[oid]
210
                    if item_stats:
211
                        ret[item_key] = item_stats
212
                    index += 1
213
        else:
214
            # Simple get request
215
            snmpresult = clientsnmp.get_by_oid(itervalues(*snmp_oid))
216
217
            # Build the internal dict with the SNMP result
218
            for key in iterkeys(snmp_oid):
219
                ret[key] = snmpresult[snmp_oid[key]]
220
221
        return ret
222
223
    def get_raw(self):
224
        """Return the stats object."""
225
        return self.stats
226
227
    def get_export(self):
228
        """Return the stats object to export."""
229
        return self.get_raw()
230
231
    def get_stats(self):
232
        """Return the stats object in JSON format."""
233
        return json.dumps(self.stats)
234
235
    def get_stats_item(self, item):
236
        """Return the stats object for a specific item in JSON format.
237
238
        Stats should be a list of dict (processlist, network...)
239
        """
240
        if isinstance(self.stats, dict):
241
            try:
242
                return json.dumps({item: self.stats[item]})
243
            except KeyError as e:
244
                logger.error("Cannot get item {0} ({1})".format(item, e))
245
                return None
246
        elif isinstance(self.stats, list):
247
            try:
248
                # Source:
249
                # http://stackoverflow.com/questions/4573875/python-get-index-of-dictionary-item-in-list
250
                return json.dumps({item: map(itemgetter(item), self.stats)})
251
            except (KeyError, ValueError) as e:
252
                logger.error("Cannot get item {0} ({1})".format(item, e))
253
                return None
254
        else:
255
            return None
256
257
    def get_stats_value(self, item, value):
258
        """Return the stats object for a specific item=value in JSON format.
259
260
        Stats should be a list of dict (processlist, network...)
261
        """
262
        if not isinstance(self.stats, list):
263
            return None
264
        else:
265
            if value.isdigit():
266
                value = int(value)
267
            try:
268
                return json.dumps({value: [i for i in self.stats if i[item] == value]})
269
            except (KeyError, ValueError) as e:
270
                logger.error(
271
                    "Cannot get item({0})=value({1}) ({2})".format(item, value, e))
272
                return None
273
274
    def update_views(self):
275
        """Default builder fo the stats views.
276
277
        The V of MVC
278
        A dict of dict with the needed information to display the stats.
279
        Example for the stat xxx:
280
        'xxx': {'decoration': 'DEFAULT',
281
                'optional': False,
282
                'additional': False,
283
                'splittable': False}
284
        """
285
        ret = {}
286
287
        if (isinstance(self.get_raw(), list) and
288
                self.get_raw() is not None and
289
                self.get_key() is not None):
290
            # Stats are stored in a list of dict (ex: NETWORK, FS...)
291
            for i in self.get_raw():
292
                ret[i[self.get_key()]] = {}
293
                for key in listkeys(i):
294
                    value = {'decoration': 'DEFAULT',
295
                             'optional': False,
296
                             'additional': False,
297
                             'splittable': False}
298
                    ret[i[self.get_key()]][key] = value
299
        elif isinstance(self.get_raw(), dict) and self.get_raw() is not None:
300
            # Stats are stored in a dict (ex: CPU, LOAD...)
301
            for key in listkeys(self.get_raw()):
302
                value = {'decoration': 'DEFAULT',
303
                         'optional': False,
304
                         'additional': False,
305
                         'splittable': False}
306
                ret[key] = value
307
308
        self.views = ret
309
310
        return self.views
311
312
    def set_views(self, input_views):
313
        """Set the views to input_views."""
314
        self.views = input_views
315
316
    def get_views(self, item=None, key=None, option=None):
317
        """Return the views object.
318
319
        If key is None, return all the view for the current plugin
320
        else if option is None return the view for the specific key (all option)
321
        else return the view fo the specific key/option
322
323
        Specify item if the stats are stored in a dict of dict (ex: NETWORK, FS...)
324
        """
325
        if item is None:
326
            item_views = self.views
327
        else:
328
            item_views = self.views[item]
329
330
        if key is None:
331
            return item_views
332
        else:
333
            if option is None:
334
                return item_views[key]
335
            else:
336
                return item_views[key][option]
337
338
    def load_limits(self, config):
339
        """Load limits from the configuration file, if it exists."""
340
        if (hasattr(config, 'has_section') and
341
                config.has_section(self.plugin_name)):
342
            for level, _ in config.items(self.plugin_name):
343
                # Read limits
344
                limit = '_'.join([self.plugin_name, level])
345
                try:
346
                    self._limits[limit] = config.get_float_value(self.plugin_name, level)
347
                except ValueError:
348
                    self._limits[limit] = config.get_value(self.plugin_name, level).split(",")
349
                logger.debug("Load limit: {0} = {1}".format(limit, self._limits[limit]))
350
351
    @property
352
    def limits(self):
353
        """Return the limits object."""
354
        return self._limits
355
356
    @limits.setter
357
    def limits(self, input_limits):
358
        """Set the limits to input_limits."""
359
        self._limits = input_limits
360
361
    def get_alert(self, current=0, minimum=0, maximum=100, header="", log=False):
362
        """Return the alert status relative to a current value.
363
364
        Use this function for minor stats.
365
366
        If current < CAREFUL of max then alert = OK
367
        If current > CAREFUL of max then alert = CAREFUL
368
        If current > WARNING of max then alert = WARNING
369
        If current > CRITICAL of max then alert = CRITICAL
370
371
        If defined 'header' is added between the plugin name and the status.
372
        Only useful for stats with several alert status.
373
374
        If log=True than add log if necessary
375
        elif log=False than do not log
376
        elig log=None than apply the config given in the conf file
377
        """
378
        # Compute the %
379
        try:
380
            value = (current * 100) / maximum
381
        except ZeroDivisionError:
382
            return 'DEFAULT'
383
        except TypeError:
384
            return 'DEFAULT'
385
386
        # Build the stat_name = plugin_name + header
387
        if header == "":
388
            stat_name = self.plugin_name
389
        else:
390
            stat_name = self.plugin_name + '_' + header
391
392
        # Manage limits
393
        ret = 'OK'
394
        try:
395
            if value > self.__get_limit('critical', stat_name=stat_name):
396
                ret = 'CRITICAL'
397
            elif value > self.__get_limit('warning', stat_name=stat_name):
398
                ret = 'WARNING'
399
            elif value > self.__get_limit('careful', stat_name=stat_name):
400
                ret = 'CAREFUL'
401
            elif current < minimum:
402
                ret = 'CAREFUL'
403
        except KeyError:
404
            return 'DEFAULT'
405
406
        # Manage log
407
        log_str = ""
408
        if self.__get_limit_log(stat_name=stat_name, default_action=log):
409
            # Add _LOG to the return string
410
            # So stats will be highlited with a specific color
411
            log_str = "_LOG"
412
            # Add the log to the list
413
            glances_logs.add(ret, stat_name.upper(), value, [])
414
415
        # Manage action
416
        # Here is a command line for the current trigger ?
417
        try:
418
            command = self.__get_limit_action(ret.lower(), stat_name=stat_name)
419
        except KeyError:
420
            # Reset the trigger
421
            self.actions.set(stat_name, ret.lower())
422
        else:
423
            # A command line is available for the current alert, run it
424
            # Build the {{mustache}} dictionnary
425
            if isinstance(self.stats, list):
426
                # If the stats are stored in a list of dict (fs plugin for exemple)
427
                # Return the dict for the current header
428
                mustache_dict = {}
429
                for item in self.stats:
430
                    if item[self.get_key()] == header:
431
                        mustache_dict = item
432
                        break
433
            else:
434
                # Use the stats dict
435
                mustache_dict = self.stats
436
            # Run the action
437
            self.actions.run(
438
                stat_name, ret.lower(), command, mustache_dict=mustache_dict)
439
440
        # Default is ok
441
        return ret + log_str
442
443
    def get_alert_log(self, current=0, minimum=0, maximum=100, header=""):
444
        """Get the alert log."""
445
        return self.get_alert(current, minimum, maximum, header, log=True)
446
447
    def __get_limit(self, criticity, stat_name=""):
448
        """Return the limit value for the alert."""
449
        # Get the limit for stat + header
450
        # Exemple: network_wlan0_rx_careful
451
        try:
452
            limit = self._limits[stat_name + '_' + criticity]
453
        except KeyError:
454
            # Try fallback to plugin default limit
455
            # Exemple: network_careful
456
            limit = self._limits[self.plugin_name + '_' + criticity]
457
458
        # Return the limit
459
        return limit
460
461
    def __get_limit_action(self, criticity, stat_name=""):
462
        """Return the action for the alert."""
463
        # Get the action for stat + header
464
        # Exemple: network_wlan0_rx_careful_action
465
        try:
466
            ret = self._limits[stat_name + '_' + criticity + '_action']
467
        except KeyError:
468
            # Try fallback to plugin default limit
469
            # Exemple: network_careful_action
470
            ret = self._limits[self.plugin_name + '_' + criticity + '_action']
471
472
        # Return the action list
473
        return ret
474
475
    def __get_limit_log(self, stat_name, default_action=False):
476
        """Return the log tag for the alert."""
477
        # Get the log tag for stat + header
478
        # Exemple: network_wlan0_rx_log
479
        try:
480
            log_tag = self._limits[stat_name + '_log']
481
        except KeyError:
482
            # Try fallback to plugin default log
483
            # Exemple: network_log
484
            try:
485
                log_tag = self._limits[self.plugin_name + '_log']
486
            except KeyError:
487
                # By defaukt, log are disabled
488
                return default_action
489
490
        # Return the action list
491
        return log_tag[0].lower() == 'true'
492
493
    def get_conf_value(self, value, header="", plugin_name=None):
494
        """Return the configuration (header_) value for the current plugin.
495
496
        ...or the one given by the plugin_name var.
497
        """
498
        if plugin_name is None:
499
            # If not default use the current plugin name
500
            plugin_name = self.plugin_name
501
502
        if header != "":
503
            # Add the header
504
            plugin_name = plugin_name + '_' + header
505
506
        try:
507
            return self._limits[plugin_name + '_' + value]
508
        except KeyError:
509
            return []
510
511
    def is_hide(self, value, header=""):
512
        """
513
        Return True if the value is in the hide configuration list.
514
        The hide configuration list is defined in the glances.conf file.
515
        It is a comma separed list of regexp.
516
        Example for diskio:
517
        hide=sda2,sda5,loop.*
518
        """
519
        # TODO: possible optimisation: create a re.compile list
520
        return not all(j is None for j in [re.match(i, value) for i in self.get_conf_value('hide', header=header)])
521
522
    def has_alias(self, header):
523
        """Return the alias name for the relative header or None if nonexist."""
524
        try:
525
            return self._limits[self.plugin_name + '_' + header + '_' + 'alias'][0]
526
        except (KeyError, IndexError):
527
            return None
528
529
    def msg_curse(self, args=None, max_width=None):
530
        """Return default string to display in the curse interface."""
531
        return [self.curse_add_line(str(self.stats))]
532
533
    def get_stats_display(self, args=None, max_width=None):
534
        """Return a dict with all the information needed to display the stat.
535
536
        key     | description
537
        ----------------------------
538
        display | Display the stat (True or False)
539
        msgdict | Message to display (list of dict [{ 'msg': msg, 'decoration': decoration } ... ])
540
        align   | Message position
541
        """
542
        display_curse = False
543
544
        if hasattr(self, 'display_curse'):
545
            display_curse = self.display_curse
546
        if hasattr(self, 'align'):
547
            align_curse = self._align
548
549
        if max_width is not None:
550
            ret = {'display': display_curse,
551
                   'msgdict': self.msg_curse(args, max_width=max_width),
552
                   'align': align_curse}
553
        else:
554
            ret = {'display': display_curse,
555
                   'msgdict': self.msg_curse(args),
556
                   'align': align_curse}
557
558
        return ret
559
560
    def curse_add_line(self, msg, decoration="DEFAULT",
561
                       optional=False, additional=False,
562
                       splittable=False):
563
        """Return a dict with.
564
565
        Where:
566
            msg: string
567
            decoration:
568
                DEFAULT: no decoration
569
                UNDERLINE: underline
570
                BOLD: bold
571
                TITLE: for stat title
572
                PROCESS: for process name
573
                STATUS: for process status
574
                NICE: for process niceness
575
                CPU_TIME: for process cpu time
576
                OK: Value is OK and non logged
577
                OK_LOG: Value is OK and logged
578
                CAREFUL: Value is CAREFUL and non logged
579
                CAREFUL_LOG: Value is CAREFUL and logged
580
                WARNING: Value is WARINING and non logged
581
                WARNING_LOG: Value is WARINING and logged
582
                CRITICAL: Value is CRITICAL and non logged
583
                CRITICAL_LOG: Value is CRITICAL and logged
584
            optional: True if the stat is optional (display only if space is available)
585
            additional: True if the stat is additional (display only if space is available after optional)
586
            spittable: Line can be splitted to fit on the screen (default is not)
587
        """
588
        return {'msg': msg, 'decoration': decoration, 'optional': optional, 'additional': additional, 'splittable': splittable}
589
590
    def curse_new_line(self):
591
        """Go to a new line."""
592
        return self.curse_add_line('\n')
593
594
    @property
595
    def align(self):
596
        """Get the curse align."""
597
        return self._align
598
599
    @align.setter
600
    def align(self, value):
601
        """Set the curse align.
602
603
        value: left, right, bottom.
604
        """
605
        self._align = value
606
607
    def auto_unit(self, number, low_precision=False):
608
        """Make a nice human-readable string out of number.
609
610
        Number of decimal places increases as quantity approaches 1.
611
612
        examples:
613
        CASE: 613421788        RESULT:       585M low_precision:       585M
614
        CASE: 5307033647       RESULT:      4.94G low_precision:       4.9G
615
        CASE: 44968414685      RESULT:      41.9G low_precision:      41.9G
616
        CASE: 838471403472     RESULT:       781G low_precision:       781G
617
        CASE: 9683209690677    RESULT:      8.81T low_precision:       8.8T
618
        CASE: 1073741824       RESULT:      1024M low_precision:      1024M
619
        CASE: 1181116006       RESULT:      1.10G low_precision:       1.1G
620
621
        'low_precision=True' returns less decimal places potentially
622
        sacrificing precision for more readability.
623
        """
624
        symbols = ('K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y')
625
        prefix = {
626
            'Y': 1208925819614629174706176,
627
            'Z': 1180591620717411303424,
628
            'E': 1152921504606846976,
629
            'P': 1125899906842624,
630
            'T': 1099511627776,
631
            'G': 1073741824,
632
            'M': 1048576,
633
            'K': 1024
634
        }
635
636
        for symbol in reversed(symbols):
637
            value = float(number) / prefix[symbol]
638
            if value > 1:
639
                decimal_precision = 0
640
                if value < 10:
641
                    decimal_precision = 2
642
                elif value < 100:
643
                    decimal_precision = 1
644
                if low_precision:
645
                    if symbol in 'MK':
646
                        decimal_precision = 0
647
                    else:
648
                        decimal_precision = min(1, decimal_precision)
649
                elif symbol in 'K':
650
                    decimal_precision = 0
651
                return '{0:.{decimal}f}{symbol}'.format(
652
                    value, decimal=decimal_precision, symbol=symbol)
653
        return '{0!s}'.format(number)
654
655
    def _log_result_decorator(fct):
656
        """Log (DEBUG) the result of the function fct."""
657
        def wrapper(*args, **kw):
658
            ret = fct(*args, **kw)
659
            logger.debug("%s %s %s return %s" % (
660
                args[0].__class__.__name__,
661
                args[0].__class__.__module__[len('glances_'):],
662
                fct.__name__, ret))
663
            return ret
664
        return wrapper
665
666
    # Mandatory to call the decorator in childs' classes
667
    _log_result_decorator = staticmethod(_log_result_decorator)
668