Merge "Factorize field definition of declarative code"
This commit is contained in:
commit
2be016ec68
118
ceilometer/declarative.py
Normal file
118
ceilometer/declarative.py
Normal file
@ -0,0 +1,118 @@
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
|
||||
from jsonpath_rw_ext import parser
|
||||
import six
|
||||
|
||||
from ceilometer.i18n import _
|
||||
|
||||
|
||||
class DefinitionException(Exception):
|
||||
def __init__(self, message, definition_cfg):
|
||||
super(DefinitionException, self).__init__(message)
|
||||
self.definition_cfg = definition_cfg
|
||||
|
||||
|
||||
class Definition(object):
|
||||
JSONPATH_RW_PARSER = parser.ExtentedJsonPathParser()
|
||||
|
||||
def __init__(self, name, cfg, plugin_manager):
|
||||
self.cfg = cfg
|
||||
self.name = name
|
||||
self.plugin = None
|
||||
if isinstance(cfg, dict):
|
||||
if 'fields' not in cfg:
|
||||
raise DefinitionException(
|
||||
_("The field 'fields' is required for %s") % name,
|
||||
self.cfg)
|
||||
|
||||
if 'plugin' in cfg:
|
||||
plugin_cfg = cfg['plugin']
|
||||
if isinstance(plugin_cfg, six.string_types):
|
||||
plugin_name = plugin_cfg
|
||||
plugin_params = {}
|
||||
else:
|
||||
try:
|
||||
plugin_name = plugin_cfg['name']
|
||||
except KeyError:
|
||||
raise DefinitionException(
|
||||
_('Plugin specified, but no plugin name supplied '
|
||||
'for %s') % name, self.cfg)
|
||||
plugin_params = plugin_cfg.get('parameters')
|
||||
if plugin_params is None:
|
||||
plugin_params = {}
|
||||
try:
|
||||
plugin_ext = plugin_manager[plugin_name]
|
||||
except KeyError:
|
||||
raise DefinitionException(
|
||||
_('No plugin named %(plugin)s available for '
|
||||
'%(name)s') % dict(
|
||||
plugin=plugin_name,
|
||||
name=name), self.cfg)
|
||||
plugin_class = plugin_ext.plugin
|
||||
self.plugin = plugin_class(**plugin_params)
|
||||
|
||||
fields = cfg['fields']
|
||||
else:
|
||||
# Simple definition "foobar: jsonpath"
|
||||
fields = cfg
|
||||
|
||||
if isinstance(fields, list):
|
||||
# NOTE(mdragon): if not a string, we assume a list.
|
||||
if len(fields) == 1:
|
||||
fields = fields[0]
|
||||
else:
|
||||
fields = '|'.join('(%s)' % path for path in fields)
|
||||
|
||||
if isinstance(fields, six.integer_types):
|
||||
self.getter = fields
|
||||
else:
|
||||
try:
|
||||
self.getter = self.JSONPATH_RW_PARSER.parse(fields).find
|
||||
except Exception as e:
|
||||
raise DefinitionException(
|
||||
_("Parse error in JSONPath specification "
|
||||
"'%(jsonpath)s' for %(name)s: %(err)s")
|
||||
% dict(jsonpath=fields, name=name, err=e), self.cfg)
|
||||
|
||||
def _get_path(self, match):
|
||||
if match.context is not None:
|
||||
for path_element in self._get_path(match.context):
|
||||
yield path_element
|
||||
yield str(match.path)
|
||||
|
||||
def parse(self, obj, return_all_values=False):
|
||||
if callable(self.getter):
|
||||
values = self.getter(obj)
|
||||
else:
|
||||
return self.getter
|
||||
|
||||
values = [match for match in values
|
||||
if return_all_values or match.value is not None]
|
||||
|
||||
if self.plugin is not None:
|
||||
if return_all_values and not self.plugin.support_return_all_values:
|
||||
raise DefinitionException("Plugin %s don't allows to "
|
||||
"return multiple values" %
|
||||
self.cfg["plugin"]["name"])
|
||||
values_map = [('.'.join(self._get_path(match)), match.value) for
|
||||
match in values]
|
||||
values = [v for v in self.plugin.trait_values(values_map)
|
||||
if v is not None]
|
||||
else:
|
||||
values = [match.value for match in values if match is not None]
|
||||
if return_all_values:
|
||||
return values
|
||||
else:
|
||||
return values[0] if values else None
|
@ -13,18 +13,18 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import fnmatch
|
||||
import functools
|
||||
import itertools
|
||||
import operator
|
||||
import os
|
||||
import threading
|
||||
|
||||
from jsonpath_rw_ext import parser
|
||||
from oslo_config import cfg
|
||||
from oslo_log import log
|
||||
import six
|
||||
from stevedore import extension
|
||||
import yaml
|
||||
|
||||
from ceilometer import declarative
|
||||
from ceilometer import dispatcher
|
||||
from ceilometer.dispatcher import gnocchi_client
|
||||
from ceilometer.i18n import _, _LE
|
||||
@ -81,42 +81,23 @@ class ResourcesDefinition(object):
|
||||
MANDATORY_FIELDS = {'resource_type': six.string_types,
|
||||
'metrics': list}
|
||||
|
||||
JSONPATH_RW_PARSER = parser.ExtentedJsonPathParser()
|
||||
|
||||
def __init__(self, definition_cfg, default_archive_policy):
|
||||
def __init__(self, definition_cfg, default_archive_policy, plugin_manager):
|
||||
self._default_archive_policy = default_archive_policy
|
||||
self.cfg = definition_cfg
|
||||
|
||||
for field, field_type in self.MANDATORY_FIELDS.items():
|
||||
if field not in self.cfg:
|
||||
raise ResourcesDefinitionException(
|
||||
raise declarative.DefinitionException(
|
||||
_LE("Required field %s not specified") % field, self.cfg)
|
||||
if not isinstance(self.cfg[field], field_type):
|
||||
raise ResourcesDefinitionException(
|
||||
raise declarative.DefinitionException(
|
||||
_LE("Required field %(field)s should be a %(type)s") %
|
||||
{'field': field, 'type': field_type}, self.cfg)
|
||||
|
||||
self._field_getter = {}
|
||||
for name, fval in self.cfg.get('attributes', {}).items():
|
||||
if isinstance(fval, six.integer_types):
|
||||
self._field_getter[name] = fval
|
||||
else:
|
||||
try:
|
||||
parts = self.JSONPATH_RW_PARSER.parse(fval)
|
||||
except Exception as e:
|
||||
raise ResourcesDefinitionException(
|
||||
_LE("Parse error in JSONPath specification "
|
||||
"'%(jsonpath)s': %(err)s")
|
||||
% dict(jsonpath=fval, err=e), self.cfg)
|
||||
self._field_getter[name] = functools.partial(
|
||||
self._parse_jsonpath_field, parts)
|
||||
|
||||
@staticmethod
|
||||
def _parse_jsonpath_field(parts, sample):
|
||||
values = [match.value for match in parts.find(sample)
|
||||
if match.value is not None]
|
||||
if values:
|
||||
return values[0]
|
||||
self._attributes = {}
|
||||
for name, attr_cfg in self.cfg.get('attributes', {}).items():
|
||||
self._attributes[name] = declarative.Definition(name, attr_cfg,
|
||||
plugin_manager)
|
||||
|
||||
def match(self, metric_name):
|
||||
for t in self.cfg['metrics']:
|
||||
@ -126,13 +107,10 @@ class ResourcesDefinition(object):
|
||||
|
||||
def attributes(self, sample):
|
||||
attrs = {}
|
||||
for attr, getter in self._field_getter.items():
|
||||
if callable(getter):
|
||||
value = getter(sample)
|
||||
else:
|
||||
value = getter
|
||||
for name, definition in self._attributes.items():
|
||||
value = definition.parse(sample)
|
||||
if value is not None:
|
||||
attrs[attr] = value
|
||||
attrs[name] = value
|
||||
return attrs
|
||||
|
||||
def metrics(self):
|
||||
@ -172,6 +150,8 @@ class GnocchiDispatcher(dispatcher.Base):
|
||||
|
||||
@classmethod
|
||||
def _load_resources_definitions(cls, conf):
|
||||
plugin_manager = extension.ExtensionManager(
|
||||
namespace='ceilometer.event.trait_plugin')
|
||||
res_def_file = cls._get_config_file(
|
||||
conf, conf.dispatcher_gnocchi.resources_definition_file)
|
||||
data = {}
|
||||
@ -182,7 +162,8 @@ class GnocchiDispatcher(dispatcher.Base):
|
||||
except ValueError:
|
||||
data = {}
|
||||
|
||||
return [ResourcesDefinition(r, conf.dispatcher_gnocchi.archive_policy)
|
||||
return [ResourcesDefinition(r, conf.dispatcher_gnocchi.archive_policy,
|
||||
plugin_manager)
|
||||
for r in data.get('resources', [])]
|
||||
|
||||
@property
|
||||
|
@ -16,13 +16,14 @@
|
||||
import fnmatch
|
||||
import os
|
||||
|
||||
from jsonpath_rw_ext import parser
|
||||
from debtcollector import moves
|
||||
from oslo_config import cfg
|
||||
from oslo_log import log
|
||||
from oslo_utils import timeutils
|
||||
import six
|
||||
import yaml
|
||||
|
||||
from ceilometer import declarative
|
||||
from ceilometer.event.storage import models
|
||||
from ceilometer.i18n import _, _LI
|
||||
|
||||
@ -46,97 +47,26 @@ cfg.CONF.register_opts(OPTS, group='event')
|
||||
|
||||
LOG = log.getLogger(__name__)
|
||||
|
||||
|
||||
class EventDefinitionException(Exception):
|
||||
def __init__(self, message, definition_cfg):
|
||||
super(EventDefinitionException, self).__init__(message)
|
||||
self.definition_cfg = definition_cfg
|
||||
|
||||
def __str__(self):
|
||||
return '%s %s: %s' % (self.__class__.__name__,
|
||||
self.definition_cfg, self.message)
|
||||
EventDefinitionException = moves.moved_class(declarative.DefinitionException,
|
||||
'EventDefinitionException',
|
||||
__name__,
|
||||
version=6.0,
|
||||
removal_version="?")
|
||||
|
||||
|
||||
class TraitDefinition(object):
|
||||
|
||||
JSONPATH_RW_PARSER = parser.ExtentedJsonPathParser()
|
||||
|
||||
class TraitDefinition(declarative.Definition):
|
||||
def __init__(self, name, trait_cfg, plugin_manager):
|
||||
self.cfg = trait_cfg
|
||||
self.name = name
|
||||
|
||||
type_name = trait_cfg.get('type', 'text')
|
||||
|
||||
if 'plugin' in trait_cfg:
|
||||
plugin_cfg = trait_cfg['plugin']
|
||||
if isinstance(plugin_cfg, six.string_types):
|
||||
plugin_name = plugin_cfg
|
||||
plugin_params = {}
|
||||
else:
|
||||
try:
|
||||
plugin_name = plugin_cfg['name']
|
||||
except KeyError:
|
||||
raise EventDefinitionException(
|
||||
_('Plugin specified, but no plugin name supplied for '
|
||||
'trait %s') % name, self.cfg)
|
||||
plugin_params = plugin_cfg.get('parameters')
|
||||
if plugin_params is None:
|
||||
plugin_params = {}
|
||||
try:
|
||||
plugin_ext = plugin_manager[plugin_name]
|
||||
except KeyError:
|
||||
raise EventDefinitionException(
|
||||
_('No plugin named %(plugin)s available for '
|
||||
'trait %(trait)s') % dict(plugin=plugin_name,
|
||||
trait=name), self.cfg)
|
||||
plugin_class = plugin_ext.plugin
|
||||
self.plugin = plugin_class(**plugin_params)
|
||||
else:
|
||||
self.plugin = None
|
||||
|
||||
if 'fields' not in trait_cfg:
|
||||
raise EventDefinitionException(
|
||||
_("Required field in trait definition not specified: "
|
||||
"'%s'") % 'fields',
|
||||
self.cfg)
|
||||
|
||||
fields = trait_cfg['fields']
|
||||
if not isinstance(fields, six.string_types):
|
||||
# NOTE(mdragon): if not a string, we assume a list.
|
||||
if len(fields) == 1:
|
||||
fields = fields[0]
|
||||
else:
|
||||
fields = '|'.join('(%s)' % path for path in fields)
|
||||
try:
|
||||
self.fields = self.JSONPATH_RW_PARSER.parse(fields)
|
||||
except Exception as e:
|
||||
raise EventDefinitionException(
|
||||
_("Parse error in JSONPath specification "
|
||||
"'%(jsonpath)s' for %(trait)s: %(err)s")
|
||||
% dict(jsonpath=fields, trait=name, err=e), self.cfg)
|
||||
super(TraitDefinition, self).__init__(name, trait_cfg, plugin_manager)
|
||||
type_name = (trait_cfg.get('type', 'text')
|
||||
if isinstance(trait_cfg, dict) else 'text')
|
||||
self.trait_type = models.Trait.get_type_by_name(type_name)
|
||||
if self.trait_type is None:
|
||||
raise EventDefinitionException(
|
||||
raise declarative.DefinitionException(
|
||||
_("Invalid trait type '%(type)s' for trait %(trait)s")
|
||||
% dict(type=type_name, trait=name), self.cfg)
|
||||
|
||||
def _get_path(self, match):
|
||||
if match.context is not None:
|
||||
for path_element in self._get_path(match.context):
|
||||
yield path_element
|
||||
yield str(match.path)
|
||||
|
||||
def to_trait(self, notification_body):
|
||||
values = [match for match in self.fields.find(notification_body)
|
||||
if match.value is not None]
|
||||
|
||||
if self.plugin is not None:
|
||||
value_map = [('.'.join(self._get_path(match)), match.value) for
|
||||
match in values]
|
||||
value = self.plugin.trait_value(value_map)
|
||||
else:
|
||||
value = values[0].value if values else None
|
||||
|
||||
value = self.parse(notification_body)
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
@ -175,7 +105,7 @@ class EventDefinition(object):
|
||||
event_type = definition_cfg['event_type']
|
||||
traits = definition_cfg['traits']
|
||||
except KeyError as err:
|
||||
raise EventDefinitionException(
|
||||
raise declarative.DefinitionException(
|
||||
_("Required field %s not specified") % err.args[0], self.cfg)
|
||||
|
||||
if isinstance(event_type, six.string_types):
|
||||
|
@ -15,6 +15,7 @@
|
||||
|
||||
import abc
|
||||
|
||||
from debtcollector import moves
|
||||
from oslo_log import log
|
||||
import six
|
||||
|
||||
@ -30,6 +31,12 @@ class TraitPluginBase(object):
|
||||
It converts notification fields to Trait values.
|
||||
"""
|
||||
|
||||
support_return_all_values = False
|
||||
"""If True, an exception will be raised if the user expect
|
||||
the plugin to return one trait per match_list, but
|
||||
the plugin doesn't allow/support that.
|
||||
"""
|
||||
|
||||
def __init__(self, **kw):
|
||||
"""Setup the trait plugin.
|
||||
|
||||
@ -43,9 +50,12 @@ class TraitPluginBase(object):
|
||||
"""
|
||||
super(TraitPluginBase, self).__init__()
|
||||
|
||||
@abc.abstractmethod
|
||||
@moves.moved_method('trait_values', version=6.0, removal_version="?")
|
||||
def trait_value(self, match_list):
|
||||
"""Convert a set of fields to a Trait value.
|
||||
pass
|
||||
|
||||
def trait_values(self, match_list):
|
||||
"""Convert a set of fields to one or multiple Trait values.
|
||||
|
||||
This method is called each time a trait is attempted to be extracted
|
||||
from a notification. It will be called *even if* no matching fields
|
||||
@ -93,13 +103,18 @@ class TraitPluginBase(object):
|
||||
def trait_value(self, match_list):
|
||||
if not match_list:
|
||||
return None
|
||||
return match_list[0][1]
|
||||
return [ match[1] for match in match_list]
|
||||
"""
|
||||
|
||||
# For backwards compatibility for the renamed method.
|
||||
return [self.trait_value(match_list)]
|
||||
|
||||
|
||||
class SplitterTraitPlugin(TraitPluginBase):
|
||||
"""Plugin that splits a piece off of a string value."""
|
||||
|
||||
support_return_all_values = True
|
||||
|
||||
def __init__(self, separator=".", segment=0, max_split=None, **kw):
|
||||
"""Setup how do split the field.
|
||||
|
||||
@ -120,10 +135,12 @@ class SplitterTraitPlugin(TraitPluginBase):
|
||||
self.max_split = max_split
|
||||
super(SplitterTraitPlugin, self).__init__(**kw)
|
||||
|
||||
def trait_value(self, match_list):
|
||||
if not match_list:
|
||||
return None
|
||||
value = six.text_type(match_list[0][1])
|
||||
def trait_values(self, match_list):
|
||||
return [self._trait_value(match)
|
||||
for match in match_list]
|
||||
|
||||
def _trait_value(self, match):
|
||||
value = six.text_type(match[1])
|
||||
if self.max_split is not None:
|
||||
values = value.split(self.separator, self.max_split)
|
||||
else:
|
||||
@ -158,7 +175,7 @@ class BitfieldTraitPlugin(TraitPluginBase):
|
||||
self.flags = flags
|
||||
super(BitfieldTraitPlugin, self).__init__(**kw)
|
||||
|
||||
def trait_value(self, match_list):
|
||||
def trait_values(self, match_list):
|
||||
matches = dict(match_list)
|
||||
bitfield = self.initial_bitfield
|
||||
for flagdef in self.flags:
|
||||
@ -170,4 +187,4 @@ class BitfieldTraitPlugin(TraitPluginBase):
|
||||
bitfield |= bit
|
||||
else:
|
||||
bitfield |= bit
|
||||
return bitfield
|
||||
return [bitfield]
|
||||
|
@ -12,19 +12,20 @@
|
||||
# under the License.
|
||||
|
||||
import fnmatch
|
||||
import functools
|
||||
import itertools
|
||||
import os
|
||||
import pkg_resources
|
||||
import six
|
||||
import yaml
|
||||
|
||||
from jsonpath_rw_ext import parser
|
||||
from debtcollector import moves
|
||||
from oslo_config import cfg
|
||||
from oslo_log import log
|
||||
import oslo_messaging
|
||||
from stevedore import extension
|
||||
|
||||
from ceilometer.agent import plugin_base
|
||||
from ceilometer import declarative
|
||||
from ceilometer.i18n import _LE, _LI
|
||||
from ceilometer import sample
|
||||
|
||||
@ -42,95 +43,129 @@ cfg.CONF.import_opt('disable_non_metric_meters', 'ceilometer.notification',
|
||||
LOG = log.getLogger(__name__)
|
||||
|
||||
|
||||
class MeterDefinitionException(Exception):
|
||||
def __init__(self, message, definition_cfg):
|
||||
super(MeterDefinitionException, self).__init__(message)
|
||||
self.message = message
|
||||
self.definition_cfg = definition_cfg
|
||||
|
||||
def __str__(self):
|
||||
return '%s %s: %s' % (self.__class__.__name__,
|
||||
self.definition_cfg, self.message)
|
||||
MeterDefinitionException = moves.moved_class(declarative.DefinitionException,
|
||||
'MeterDefinitionException',
|
||||
__name__,
|
||||
version=6.0,
|
||||
removal_version="?")
|
||||
|
||||
|
||||
class MeterDefinition(object):
|
||||
|
||||
JSONPATH_RW_PARSER = parser.ExtentedJsonPathParser()
|
||||
SAMPLE_ATTRIBUTES = ["name", "type", "volume", "unit", "timestamp",
|
||||
"user_id", "project_id", "resource_id"]
|
||||
|
||||
REQUIRED_FIELDS = ['name', 'type', 'event_type', 'unit', 'volume',
|
||||
'resource_id']
|
||||
|
||||
def __init__(self, definition_cfg):
|
||||
def __init__(self, definition_cfg, plugin_manager):
|
||||
self.cfg = definition_cfg
|
||||
missing = [field for field in self.REQUIRED_FIELDS
|
||||
if not self.cfg.get(field)]
|
||||
if missing:
|
||||
raise MeterDefinitionException(
|
||||
raise declarative.DefinitionException(
|
||||
_LE("Required fields %s not specified") % missing, self.cfg)
|
||||
|
||||
self._event_type = self.cfg.get('event_type')
|
||||
if isinstance(self._event_type, six.string_types):
|
||||
self._event_type = [self._event_type]
|
||||
|
||||
if ('type' not in self.cfg.get('lookup', []) and
|
||||
self.cfg['type'] not in sample.TYPES):
|
||||
raise MeterDefinitionException(
|
||||
raise declarative.DefinitionException(
|
||||
_LE("Invalid type %s specified") % self.cfg['type'], self.cfg)
|
||||
|
||||
self._field_getter = {}
|
||||
for name, field in self.cfg.items():
|
||||
if name in ["event_type", "lookup"] or not field:
|
||||
continue
|
||||
elif isinstance(field, six.integer_types):
|
||||
self._field_getter[name] = field
|
||||
elif isinstance(field, dict) and name == 'metadata':
|
||||
meta = {}
|
||||
for key, val in field.items():
|
||||
parts = self.parse_jsonpath(val)
|
||||
meta[key] = functools.partial(self._parse_jsonpath_field,
|
||||
parts)
|
||||
self._field_getter['metadata'] = meta
|
||||
else:
|
||||
parts = self.parse_jsonpath(field)
|
||||
self._field_getter[name] = functools.partial(
|
||||
self._parse_jsonpath_field, parts)
|
||||
self._fallback_user_id = declarative.Definition(
|
||||
'user_id', "_context_user_id|_context_user", plugin_manager)
|
||||
self._fallback_project_id = declarative.Definition(
|
||||
'project_id', "_context_tenant_id|_context_tenant", plugin_manager)
|
||||
self._attributes = {}
|
||||
self._metadata_attributes = {}
|
||||
|
||||
def parse_jsonpath(self, field):
|
||||
try:
|
||||
parts = self.JSONPATH_RW_PARSER.parse(field)
|
||||
except Exception as e:
|
||||
raise MeterDefinitionException(_LE(
|
||||
"Parse error in JSONPath specification "
|
||||
"'%(jsonpath)s': %(err)s")
|
||||
% dict(jsonpath=field, err=e), self.cfg)
|
||||
return parts
|
||||
for name in self.SAMPLE_ATTRIBUTES:
|
||||
attr_cfg = self.cfg.get(name)
|
||||
if attr_cfg:
|
||||
self._attributes[name] = declarative.Definition(
|
||||
name, attr_cfg, plugin_manager)
|
||||
metadata = self.cfg.get('metadata', {})
|
||||
for name in metadata:
|
||||
self._metadata_attributes[name] = declarative.Definition(
|
||||
name, metadata[name], plugin_manager)
|
||||
|
||||
# List of fields we expected when multiple meter are in the payload
|
||||
self.lookup = self.cfg.get('lookup')
|
||||
if isinstance(self.lookup, six.string_types):
|
||||
self.lookup = [self.lookup]
|
||||
|
||||
def match_type(self, meter_name):
|
||||
for t in self._event_type:
|
||||
if fnmatch.fnmatch(meter_name, t):
|
||||
return True
|
||||
|
||||
def parse_fields(self, field, message, all_values=False):
|
||||
getter = self._field_getter.get(field)
|
||||
if not getter:
|
||||
return
|
||||
elif isinstance(getter, dict):
|
||||
dict_val = {}
|
||||
for key, val in getter.items():
|
||||
dict_val[key] = val(message, all_values)
|
||||
return dict_val
|
||||
elif callable(getter):
|
||||
return getter(message, all_values)
|
||||
else:
|
||||
return getter
|
||||
def to_samples(self, message, all_values=False):
|
||||
# Sample defaults
|
||||
sample = {
|
||||
'name': self.cfg["name"], 'type': self.cfg["type"],
|
||||
'unit': self.cfg["unit"], 'volume': None, 'timestamp': None,
|
||||
'user_id': self._fallback_user_id.parse(message),
|
||||
'project_id': self._fallback_project_id.parse(message),
|
||||
'resource_id': None, 'message': message, 'metadata': {},
|
||||
}
|
||||
for name, parser in self._metadata_attributes.items():
|
||||
value = parser.parse(message)
|
||||
if value:
|
||||
sample['metadata'][name] = value
|
||||
|
||||
@staticmethod
|
||||
def _parse_jsonpath_field(parts, message, all_values):
|
||||
values = [match.value for match in parts.find(message)
|
||||
if match.value is not None]
|
||||
if values:
|
||||
if not all_values:
|
||||
return values[0]
|
||||
return values
|
||||
# NOTE(sileht): We expect multiple samples in the payload
|
||||
# so put each attribute into a list
|
||||
if self.lookup:
|
||||
for name in sample:
|
||||
sample[name] = [sample[name]]
|
||||
|
||||
for name in self.SAMPLE_ATTRIBUTES:
|
||||
parser = self._attributes.get(name)
|
||||
if parser is not None:
|
||||
value = parser.parse(message, bool(self.lookup))
|
||||
# NOTE(sileht): If we expect multiple samples
|
||||
# some attributes and overriden even we doesn't get any
|
||||
# result. Also note in this case value is always a list
|
||||
if ((not self.lookup and value is not None) or
|
||||
(self.lookup and ((name in self.lookup + ["name"])
|
||||
or value))):
|
||||
sample[name] = value
|
||||
|
||||
if self.lookup:
|
||||
nb_samples = len(sample['name'])
|
||||
# skip if no meters in payload
|
||||
if nb_samples <= 0:
|
||||
raise StopIteration
|
||||
|
||||
attributes = self.SAMPLE_ATTRIBUTES + ["message", "metadata"]
|
||||
|
||||
samples_values = []
|
||||
for name in attributes:
|
||||
values = sample.get(name)
|
||||
nb_values = len(values)
|
||||
if nb_values == nb_samples:
|
||||
samples_values.append(values)
|
||||
elif nb_values == 1 and name not in self.lookup:
|
||||
samples_values.append(itertools.cycle(values))
|
||||
else:
|
||||
nb = (0 if nb_values == 1 and values[0] is None
|
||||
else nb_values)
|
||||
LOG.warning('Only %(nb)d fetched meters contain '
|
||||
'"%(name)s" field instead of %(total)d.' %
|
||||
dict(name=name, nb=nb,
|
||||
total=nb_samples))
|
||||
raise StopIteration
|
||||
|
||||
# NOTE(sileht): Transform the sample with multiple values per
|
||||
# attribute into multiple samples with one value per attribute.
|
||||
for values in zip(*samples_values):
|
||||
yield dict((attributes[idx], value)
|
||||
for idx, value in enumerate(values))
|
||||
else:
|
||||
yield sample
|
||||
|
||||
|
||||
def get_config_file():
|
||||
@ -182,23 +217,23 @@ def setup_meters_config():
|
||||
def load_definitions(config_def):
|
||||
if not config_def:
|
||||
return []
|
||||
|
||||
plugin_manager = extension.ExtensionManager(
|
||||
namespace='ceilometer.event.trait_plugin')
|
||||
|
||||
meter_defs = []
|
||||
for event_def in reversed(config_def['metric']):
|
||||
try:
|
||||
if (event_def['volume'] != 1 or
|
||||
not cfg.CONF.notification.disable_non_metric_meters):
|
||||
meter_defs.append(MeterDefinition(event_def))
|
||||
except MeterDefinitionException as me:
|
||||
meter_defs.append(MeterDefinition(event_def, plugin_manager))
|
||||
except declarative.DefinitionException as me:
|
||||
errmsg = (_LE("Error loading meter definition : %(err)s")
|
||||
% dict(err=me.message))
|
||||
% dict(err=six.text_type(me)))
|
||||
LOG.error(errmsg)
|
||||
return meter_defs
|
||||
|
||||
|
||||
class InvalidPayload(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ProcessMeterNotifications(plugin_base.NotificationBase):
|
||||
|
||||
event_types = []
|
||||
@ -237,82 +272,8 @@ class ProcessMeterNotifications(plugin_base.NotificationBase):
|
||||
for topic in conf.notification_topics)
|
||||
return targets
|
||||
|
||||
@staticmethod
|
||||
def _normalise_as_list(value, d, body, length):
|
||||
values = d.parse_fields(value, body, True)
|
||||
if not values:
|
||||
if value in d.cfg.get('lookup'):
|
||||
LOG.warning('Could not find %s values', value)
|
||||
raise InvalidPayload
|
||||
values = [d.cfg[value]]
|
||||
elif value in d.cfg.get('lookup') and length != len(values):
|
||||
LOG.warning('Not all fetched meters contain "%s" field', value)
|
||||
raise InvalidPayload
|
||||
return values if isinstance(values, list) else [values]
|
||||
|
||||
def process_notification(self, notification_body):
|
||||
for d in self.definitions:
|
||||
if d.match_type(notification_body['event_type']):
|
||||
userid = self.get_user_id(d, notification_body)
|
||||
projectid = self.get_project_id(d, notification_body)
|
||||
resourceid = d.parse_fields('resource_id', notification_body)
|
||||
ts = d.parse_fields('timestamp', notification_body)
|
||||
metadata = d.parse_fields('metadata', notification_body)
|
||||
if d.cfg.get('lookup'):
|
||||
meters = d.parse_fields('name', notification_body, True)
|
||||
if not meters: # skip if no meters in payload
|
||||
break
|
||||
try:
|
||||
resources = self._normalise_as_list(
|
||||
'resource_id', d, notification_body, len(meters))
|
||||
volumes = self._normalise_as_list(
|
||||
'volume', d, notification_body, len(meters))
|
||||
units = self._normalise_as_list(
|
||||
'unit', d, notification_body, len(meters))
|
||||
types = self._normalise_as_list(
|
||||
'type', d, notification_body, len(meters))
|
||||
users = (self._normalise_as_list(
|
||||
'user_id', d, notification_body, len(meters))
|
||||
if 'user_id' in d.cfg['lookup'] else [userid])
|
||||
projs = (self._normalise_as_list(
|
||||
'project_id', d, notification_body, len(meters))
|
||||
if 'project_id' in d.cfg['lookup']
|
||||
else [projectid])
|
||||
times = (self._normalise_as_list(
|
||||
'timestamp', d, notification_body, len(meters))
|
||||
if 'timestamp' in d.cfg['lookup'] else [ts])
|
||||
except InvalidPayload:
|
||||
break
|
||||
for m, v, unit, t, r, p, user, ts in zip(
|
||||
meters, volumes, itertools.cycle(units),
|
||||
itertools.cycle(types), itertools.cycle(resources),
|
||||
itertools.cycle(projs), itertools.cycle(users),
|
||||
itertools.cycle(times)):
|
||||
yield sample.Sample.from_notification(
|
||||
name=m, type=t, unit=unit, volume=v,
|
||||
resource_id=r, user_id=user, project_id=p,
|
||||
message=notification_body, timestamp=ts,
|
||||
metadata=metadata)
|
||||
else:
|
||||
yield sample.Sample.from_notification(
|
||||
name=d.cfg['name'],
|
||||
type=d.cfg['type'],
|
||||
unit=d.cfg['unit'],
|
||||
volume=d.parse_fields('volume', notification_body),
|
||||
resource_id=resourceid,
|
||||
user_id=userid,
|
||||
project_id=projectid,
|
||||
message=notification_body,
|
||||
timestamp=ts, metadata=metadata)
|
||||
|
||||
@staticmethod
|
||||
def get_user_id(d, notification_body):
|
||||
return (d.parse_fields('user_id', notification_body) or
|
||||
notification_body.get('_context_user_id') or
|
||||
notification_body.get('_context_user', None))
|
||||
|
||||
@staticmethod
|
||||
def get_project_id(d, notification_body):
|
||||
return (d.parse_fields('project_id', notification_body) or
|
||||
notification_body.get('_context_tenant_id') or
|
||||
notification_body.get('_context_tenant', None))
|
||||
for s in d.to_samples(notification_body):
|
||||
yield sample.Sample.from_notification(**s)
|
||||
|
@ -28,6 +28,7 @@ import six
|
||||
import six.moves.urllib.parse as urlparse
|
||||
import testscenarios
|
||||
|
||||
from ceilometer import declarative
|
||||
from ceilometer.dispatcher import gnocchi
|
||||
from ceilometer import service as ceilometer_service
|
||||
from ceilometer.tests import base
|
||||
@ -133,7 +134,7 @@ class DispatcherTest(base.BaseTestCase):
|
||||
self.conf.config(filter_service_activity=False,
|
||||
resources_definition_file=temp,
|
||||
group='dispatcher_gnocchi')
|
||||
self.assertRaises(gnocchi.ResourcesDefinitionException,
|
||||
self.assertRaises(declarative.DefinitionException,
|
||||
gnocchi.GnocchiDispatcher, self.conf.conf)
|
||||
|
||||
@mock.patch('ceilometer.dispatcher.gnocchi.GnocchiDispatcher'
|
||||
|
@ -20,6 +20,7 @@ import mock
|
||||
from oslo_config import fixture as fixture_config
|
||||
import six
|
||||
|
||||
from ceilometer import declarative
|
||||
from ceilometer.event import converter
|
||||
from ceilometer.event.storage import models
|
||||
from ceilometer.tests import base
|
||||
@ -112,13 +113,13 @@ class TestTraitDefinition(ConverterBase):
|
||||
self.ext1 = mock.MagicMock(name='mock_test_plugin')
|
||||
self.test_plugin_class = self.ext1.plugin
|
||||
self.test_plugin = self.test_plugin_class()
|
||||
self.test_plugin.trait_value.return_value = 'foobar'
|
||||
self.test_plugin.trait_values.return_value = ['foobar']
|
||||
self.ext1.reset_mock()
|
||||
|
||||
self.ext2 = mock.MagicMock(name='mock_nothing_plugin')
|
||||
self.nothing_plugin_class = self.ext2.plugin
|
||||
self.nothing_plugin = self.nothing_plugin_class()
|
||||
self.nothing_plugin.trait_value.return_value = None
|
||||
self.nothing_plugin.trait_values.return_value = [None]
|
||||
self.ext2.reset_mock()
|
||||
|
||||
self.fake_plugin_mgr = dict(test=self.ext1, nothing=self.ext2)
|
||||
@ -136,7 +137,7 @@ class TestTraitDefinition(ConverterBase):
|
||||
self.assertEqual(models.Trait.TEXT_TYPE, t.dtype)
|
||||
self.assertEqual('foobar', t.value)
|
||||
self.test_plugin_class.assert_called_once_with()
|
||||
self.test_plugin.trait_value.assert_called_once_with([
|
||||
self.test_plugin.trait_values.assert_called_once_with([
|
||||
('payload.instance_id', 'id-for-instance-0001'),
|
||||
('payload.instance_uuid', 'uuid-for-instance-0001')])
|
||||
|
||||
@ -153,7 +154,7 @@ class TestTraitDefinition(ConverterBase):
|
||||
self.assertEqual(models.Trait.TEXT_TYPE, t.dtype)
|
||||
self.assertEqual('foobar', t.value)
|
||||
self.test_plugin_class.assert_called_once_with()
|
||||
self.test_plugin.trait_value.assert_called_once_with([])
|
||||
self.test_plugin.trait_values.assert_called_once_with([])
|
||||
|
||||
def test_to_trait_with_plugin_null(self):
|
||||
cfg = dict(type='text',
|
||||
@ -165,7 +166,7 @@ class TestTraitDefinition(ConverterBase):
|
||||
t = tdef.to_trait(self.n1)
|
||||
self.assertIs(None, t)
|
||||
self.nothing_plugin_class.assert_called_once_with()
|
||||
self.nothing_plugin.trait_value.assert_called_once_with([
|
||||
self.nothing_plugin.trait_values.assert_called_once_with([
|
||||
('payload.instance_id', 'id-for-instance-0001'),
|
||||
('payload.instance_uuid', 'uuid-for-instance-0001')])
|
||||
|
||||
@ -182,7 +183,7 @@ class TestTraitDefinition(ConverterBase):
|
||||
self.assertEqual(models.Trait.TEXT_TYPE, t.dtype)
|
||||
self.assertEqual('foobar', t.value)
|
||||
self.test_plugin_class.assert_called_once_with(a=1, b='foo')
|
||||
self.test_plugin.trait_value.assert_called_once_with([
|
||||
self.test_plugin.trait_values.assert_called_once_with([
|
||||
('payload.instance_id', 'id-for-instance-0001'),
|
||||
('payload.instance_uuid', 'uuid-for-instance-0001')])
|
||||
|
||||
@ -287,7 +288,7 @@ class TestTraitDefinition(ConverterBase):
|
||||
self.assertIs(None, t)
|
||||
|
||||
def test_missing_fields_config(self):
|
||||
self.assertRaises(converter.EventDefinitionException,
|
||||
self.assertRaises(declarative.DefinitionException,
|
||||
converter.TraitDefinition,
|
||||
'bogus_trait',
|
||||
dict(),
|
||||
@ -296,19 +297,20 @@ class TestTraitDefinition(ConverterBase):
|
||||
def test_string_fields_config(self):
|
||||
cfg = dict(fields='payload.test')
|
||||
t = converter.TraitDefinition('test_trait', cfg, self.fake_plugin_mgr)
|
||||
self.assertPathsEqual(t.fields, jsonpath_rw_ext.parse('payload.test'))
|
||||
self.assertPathsEqual(t.getter.__self__,
|
||||
jsonpath_rw_ext.parse('payload.test'))
|
||||
|
||||
def test_list_fields_config(self):
|
||||
cfg = dict(fields=['payload.test', 'payload.other'])
|
||||
t = converter.TraitDefinition('test_trait', cfg, self.fake_plugin_mgr)
|
||||
self.assertPathsEqual(
|
||||
t.fields,
|
||||
t.getter.__self__,
|
||||
jsonpath_rw_ext.parse('(payload.test)|(payload.other)'))
|
||||
|
||||
def test_invalid_path_config(self):
|
||||
# test invalid jsonpath...
|
||||
cfg = dict(fields='payload.bogus(')
|
||||
self.assertRaises(converter.EventDefinitionException,
|
||||
self.assertRaises(declarative.DefinitionException,
|
||||
converter.TraitDefinition,
|
||||
'bogus_trait',
|
||||
cfg,
|
||||
@ -317,7 +319,7 @@ class TestTraitDefinition(ConverterBase):
|
||||
def test_invalid_plugin_config(self):
|
||||
# test invalid jsonpath...
|
||||
cfg = dict(fields='payload.test', plugin=dict(bogus="true"))
|
||||
self.assertRaises(converter.EventDefinitionException,
|
||||
self.assertRaises(declarative.DefinitionException,
|
||||
converter.TraitDefinition,
|
||||
'test_trait',
|
||||
cfg,
|
||||
@ -326,7 +328,7 @@ class TestTraitDefinition(ConverterBase):
|
||||
def test_unknown_plugin(self):
|
||||
# test invalid jsonpath...
|
||||
cfg = dict(fields='payload.test', plugin=dict(name='bogus'))
|
||||
self.assertRaises(converter.EventDefinitionException,
|
||||
self.assertRaises(declarative.DefinitionException,
|
||||
converter.TraitDefinition,
|
||||
'test_trait',
|
||||
cfg,
|
||||
@ -352,7 +354,7 @@ class TestTraitDefinition(ConverterBase):
|
||||
def test_invalid_type_config(self):
|
||||
# test invalid jsonpath...
|
||||
cfg = dict(type='bogus', fields='payload.test')
|
||||
self.assertRaises(converter.EventDefinitionException,
|
||||
self.assertRaises(declarative.DefinitionException,
|
||||
converter.TraitDefinition,
|
||||
'bogus_trait',
|
||||
cfg,
|
||||
@ -438,14 +440,14 @@ class TestEventDefinition(ConverterBase):
|
||||
|
||||
def test_bogus_cfg_no_traits(self):
|
||||
bogus = dict(event_type='test.foo')
|
||||
self.assertRaises(converter.EventDefinitionException,
|
||||
self.assertRaises(declarative.DefinitionException,
|
||||
converter.EventDefinition,
|
||||
bogus,
|
||||
self.fake_plugin_mgr)
|
||||
|
||||
def test_bogus_cfg_no_type(self):
|
||||
bogus = dict(traits=self.traits_cfg)
|
||||
self.assertRaises(converter.EventDefinitionException,
|
||||
self.assertRaises(declarative.DefinitionException,
|
||||
converter.EventDefinition,
|
||||
bogus,
|
||||
self.fake_plugin_mgr)
|
||||
|
@ -27,41 +27,41 @@ class TestSplitterPlugin(base.BaseTestCase):
|
||||
param = dict(separator='-', segment=0)
|
||||
plugin = self.pclass(**param)
|
||||
match_list = [('test.thing', 'test-foobar-baz')]
|
||||
value = plugin.trait_value(match_list)
|
||||
value = plugin.trait_values(match_list)[0]
|
||||
self.assertEqual('test', value)
|
||||
|
||||
param = dict(separator='-', segment=1)
|
||||
plugin = self.pclass(**param)
|
||||
match_list = [('test.thing', 'test-foobar-baz')]
|
||||
value = plugin.trait_value(match_list)
|
||||
value = plugin.trait_values(match_list)[0]
|
||||
self.assertEqual('foobar', value)
|
||||
|
||||
param = dict(separator='-', segment=1, max_split=1)
|
||||
plugin = self.pclass(**param)
|
||||
match_list = [('test.thing', 'test-foobar-baz')]
|
||||
value = plugin.trait_value(match_list)
|
||||
value = plugin.trait_values(match_list)[0]
|
||||
self.assertEqual('foobar-baz', value)
|
||||
|
||||
def test_no_sep(self):
|
||||
param = dict(separator='-', segment=0)
|
||||
plugin = self.pclass(**param)
|
||||
match_list = [('test.thing', 'test.foobar.baz')]
|
||||
value = plugin.trait_value(match_list)
|
||||
value = plugin.trait_values(match_list)[0]
|
||||
self.assertEqual('test.foobar.baz', value)
|
||||
|
||||
def test_no_segment(self):
|
||||
param = dict(separator='-', segment=5)
|
||||
plugin = self.pclass(**param)
|
||||
match_list = [('test.thing', 'test-foobar-baz')]
|
||||
value = plugin.trait_value(match_list)
|
||||
value = plugin.trait_values(match_list)[0]
|
||||
self.assertIs(None, value)
|
||||
|
||||
def test_no_match(self):
|
||||
param = dict(separator='-', segment=0)
|
||||
plugin = self.pclass(**param)
|
||||
match_list = []
|
||||
value = plugin.trait_value(match_list)
|
||||
self.assertIs(None, value)
|
||||
value = plugin.trait_values(match_list)
|
||||
self.assertEqual([], value)
|
||||
|
||||
|
||||
class TestBitfieldPlugin(base.BaseTestCase):
|
||||
@ -86,8 +86,8 @@ class TestBitfieldPlugin(base.BaseTestCase):
|
||||
('thingy.boink', 'testagain')]
|
||||
|
||||
plugin = self.pclass(**self.params)
|
||||
value = plugin.trait_value(match_list)
|
||||
self.assertEqual(0x412, value)
|
||||
value = plugin.trait_values(match_list)
|
||||
self.assertEqual(0x412, value[0])
|
||||
|
||||
def test_initial(self):
|
||||
match_list = [('payload.foo', 12),
|
||||
@ -95,14 +95,14 @@ class TestBitfieldPlugin(base.BaseTestCase):
|
||||
('thingy.boink', 'testagain')]
|
||||
self.params['initial_bitfield'] = 0x2000
|
||||
plugin = self.pclass(**self.params)
|
||||
value = plugin.trait_value(match_list)
|
||||
self.assertEqual(0x2412, value)
|
||||
value = plugin.trait_values(match_list)
|
||||
self.assertEqual(0x2412, value[0])
|
||||
|
||||
def test_no_match(self):
|
||||
match_list = []
|
||||
plugin = self.pclass(**self.params)
|
||||
value = plugin.trait_value(match_list)
|
||||
self.assertEqual(self.init, value)
|
||||
value = plugin.trait_values(match_list)
|
||||
self.assertEqual(self.init, value[0])
|
||||
|
||||
def test_multi(self):
|
||||
match_list = [('payload.foo', 12),
|
||||
@ -111,5 +111,5 @@ class TestBitfieldPlugin(base.BaseTestCase):
|
||||
('thingy.boink', 'testagain')]
|
||||
|
||||
plugin = self.pclass(**self.params)
|
||||
value = plugin.trait_value(match_list)
|
||||
self.assertEqual(0x412, value)
|
||||
value = plugin.trait_values(match_list)
|
||||
self.assertEqual(0x412, value[0])
|
||||
|
@ -18,9 +18,11 @@ import six
|
||||
import yaml
|
||||
|
||||
from oslo_config import fixture as fixture_config
|
||||
from oslo_utils import encodeutils
|
||||
from oslo_utils import fileutils
|
||||
from oslotest import mockpatch
|
||||
|
||||
from ceilometer import declarative
|
||||
from ceilometer.meter import notifications
|
||||
from ceilometer import service as ceilometer_service
|
||||
from ceilometer.tests import base as test
|
||||
@ -223,31 +225,34 @@ class TestMeterDefinition(test.BaseTestCase):
|
||||
volume="$.payload.volume",
|
||||
resource_id="$.payload.resource_id",
|
||||
project_id="$.payload.project_id")
|
||||
handler = notifications.MeterDefinition(cfg)
|
||||
handler = notifications.MeterDefinition(cfg, mock.Mock())
|
||||
self.assertTrue(handler.match_type("test.create"))
|
||||
self.assertEqual(1.0, handler.parse_fields("volume", NOTIFICATION))
|
||||
sample = list(handler.to_samples(NOTIFICATION))[0]
|
||||
self.assertEqual(1.0, sample["volume"])
|
||||
self.assertEqual("bea70e51c7340cb9d555b15cbfcaec23",
|
||||
handler.parse_fields("resource_id", NOTIFICATION))
|
||||
sample["resource_id"])
|
||||
self.assertEqual("30be1fc9a03c4e94ab05c403a8a377f2",
|
||||
handler.parse_fields("project_id", NOTIFICATION))
|
||||
sample["project_id"])
|
||||
|
||||
def test_config_required_missing_fields(self):
|
||||
cfg = dict()
|
||||
try:
|
||||
notifications.MeterDefinition(cfg)
|
||||
except notifications.MeterDefinitionException as e:
|
||||
notifications.MeterDefinition(cfg, mock.Mock())
|
||||
except declarative.DefinitionException as e:
|
||||
self.assertEqual("Required fields ['name', 'type', 'event_type',"
|
||||
" 'unit', 'volume', 'resource_id']"
|
||||
" not specified", e.message)
|
||||
" not specified",
|
||||
encodeutils.exception_to_unicode(e))
|
||||
|
||||
def test_bad_type_cfg_definition(self):
|
||||
cfg = dict(name="test", type="foo", event_type="bar.create",
|
||||
unit="foo", volume="bar",
|
||||
resource_id="bea70e51c7340cb9d555b15cbfcaec23")
|
||||
try:
|
||||
notifications.MeterDefinition(cfg)
|
||||
except notifications.MeterDefinitionException as e:
|
||||
self.assertEqual("Invalid type foo specified", e.message)
|
||||
notifications.MeterDefinition(cfg, mock.Mock())
|
||||
except declarative.DefinitionException as e:
|
||||
self.assertEqual("Invalid type foo specified",
|
||||
encodeutils.exception_to_unicode(e))
|
||||
|
||||
|
||||
class TestMeterProcessing(test.BaseTestCase):
|
||||
@ -601,7 +606,8 @@ class TestMeterProcessing(test.BaseTestCase):
|
||||
self.__setup_meter_def_file(cfg))
|
||||
c = list(self.handler.process_notification(event))
|
||||
self.assertEqual(0, len(c))
|
||||
LOG.warning.assert_called_with('Could not find %s values', 'volume')
|
||||
LOG.warning.assert_called_with('Only 0 fetched meters contain '
|
||||
'"volume" field instead of 2.')
|
||||
|
||||
@mock.patch('ceilometer.meter.notifications.LOG')
|
||||
def test_multi_meter_payload_invalid_short(self, LOG):
|
||||
@ -620,8 +626,8 @@ class TestMeterProcessing(test.BaseTestCase):
|
||||
self.__setup_meter_def_file(cfg))
|
||||
c = list(self.handler.process_notification(event))
|
||||
self.assertEqual(0, len(c))
|
||||
LOG.warning.assert_called_with('Not all fetched meters contain "%s" '
|
||||
'field', 'volume')
|
||||
LOG.warning.assert_called_with('Only 1 fetched meters contain '
|
||||
'"volume" field instead of 2.')
|
||||
|
||||
def test_arithmetic_expr_meter(self):
|
||||
cfg = yaml.dump(
|
||||
|
Loading…
Reference in New Issue
Block a user