2016-11-18 17:51:58 +03:00
|
|
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
|
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
|
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
|
|
|
|
|
|
import re
|
|
|
|
import yaml
|
|
|
|
import itertools
|
|
|
|
import datetime
|
|
|
|
import string
|
2017-03-06 18:12:49 +03:00
|
|
|
import shared_telemetry_utils as utils
|
2016-11-18 17:51:58 +03:00
|
|
|
|
2017-04-18 19:21:36 +03:00
|
|
|
from shared_telemetry_utils import ParserError
|
|
|
|
|
2016-12-02 14:17:12 +03:00
|
|
|
MAX_CATEGORY_NAME_LENGTH = 30
|
|
|
|
MAX_METHOD_NAME_LENGTH = 20
|
|
|
|
MAX_OBJECT_NAME_LENGTH = 20
|
|
|
|
MAX_EXTRA_KEYS_COUNT = 10
|
|
|
|
MAX_EXTRA_KEY_NAME_LENGTH = 15
|
2016-11-18 17:51:58 +03:00
|
|
|
|
2017-03-17 13:59:17 +03:00
|
|
|
IDENTIFIER_PATTERN = r'^[a-zA-Z][a-zA-Z0-9_.]*[a-zA-Z0-9]$'
|
2016-11-18 17:51:58 +03:00
|
|
|
DATE_PATTERN = r'^[0-9]{4}-[0-9]{2}-[0-9]{2}$'
|
|
|
|
|
2017-03-08 20:11:00 +03:00
|
|
|
|
2016-11-18 17:51:58 +03:00
|
|
|
def nice_type_name(t):
|
2017-04-11 06:02:08 +03:00
|
|
|
if issubclass(t, basestring):
|
2016-11-18 17:51:58 +03:00
|
|
|
return "string"
|
|
|
|
return t.__name__
|
|
|
|
|
2017-03-08 20:11:00 +03:00
|
|
|
|
2016-11-18 17:51:58 +03:00
|
|
|
def convert_to_cpp_identifier(s, sep):
|
|
|
|
return string.capwords(s, sep).replace(sep, "")
|
|
|
|
|
2017-03-08 20:11:00 +03:00
|
|
|
|
2016-11-18 17:51:58 +03:00
|
|
|
class OneOf:
|
|
|
|
"""This is a placeholder type for the TypeChecker below.
|
|
|
|
It signals that the checked value should match one of the following arguments
|
|
|
|
passed to the TypeChecker constructor.
|
|
|
|
"""
|
|
|
|
pass
|
|
|
|
|
2017-03-08 20:11:00 +03:00
|
|
|
|
2017-04-11 15:50:15 +03:00
|
|
|
class AtomicTypeChecker:
|
|
|
|
"""Validate a simple value against a given type"""
|
|
|
|
def __init__(self, instance_type):
|
|
|
|
self.instance_type = instance_type
|
2016-11-18 17:51:58 +03:00
|
|
|
|
2017-01-09 19:14:00 +03:00
|
|
|
def check(self, identifier, key, value):
|
2017-04-11 15:50:15 +03:00
|
|
|
if not isinstance(value, self.instance_type):
|
2017-04-18 19:25:04 +03:00
|
|
|
raise ParserError("%s: Failed type check for %s - expected %s, got %s." %
|
2017-04-11 15:50:15 +03:00
|
|
|
(identifier, key, nice_type_name(self.instance_type),
|
2017-03-15 14:48:52 +03:00
|
|
|
nice_type_name(type(value))))
|
2016-11-18 17:51:58 +03:00
|
|
|
|
2017-04-11 15:50:15 +03:00
|
|
|
|
|
|
|
class MultiTypeChecker:
|
|
|
|
"""Validate a simple value against a list of possible types"""
|
|
|
|
def __init__(self, *instance_types):
|
|
|
|
if not instance_types:
|
2017-04-18 19:25:04 +03:00
|
|
|
raise Exception("At least one instance type is required.")
|
2017-04-11 15:50:15 +03:00
|
|
|
self.instance_types = instance_types
|
|
|
|
|
|
|
|
def check(self, identifier, key, value):
|
|
|
|
if not any(isinstance(value, i) for i in self.instance_types):
|
2017-04-18 19:25:04 +03:00
|
|
|
raise ParserError("%s: Failed type check for %s - got %s, expected one of:\n%s" %
|
2017-04-11 15:50:15 +03:00
|
|
|
(identifier, key,
|
|
|
|
nice_type_name(type(value)),
|
|
|
|
" or ".join(map(nice_type_name, self.instance_types))))
|
|
|
|
|
|
|
|
|
|
|
|
class ListTypeChecker:
|
|
|
|
"""Validate a list of values against a given type"""
|
|
|
|
def __init__(self, instance_type):
|
|
|
|
self.instance_type = instance_type
|
|
|
|
|
|
|
|
def check(self, identifier, key, value):
|
|
|
|
if len(value) < 1:
|
2017-04-18 19:25:04 +03:00
|
|
|
raise ParserError("%s: Failed check for %s - list should not be empty." %
|
2017-04-11 15:50:15 +03:00
|
|
|
(identifier, key))
|
|
|
|
|
|
|
|
for x in value:
|
|
|
|
if not isinstance(x, self.instance_type):
|
2017-04-18 19:25:04 +03:00
|
|
|
raise ParserError("%s: Failed type check for %s - expected list value type %s, got"
|
|
|
|
" %s." % (identifier, key, nice_type_name(self.instance_type),
|
2017-04-11 15:50:15 +03:00
|
|
|
nice_type_name(type(x))))
|
|
|
|
|
|
|
|
|
|
|
|
class DictTypeChecker:
|
|
|
|
"""Validate keys and values of a dict against a given type"""
|
|
|
|
def __init__(self, keys_instance_type, values_instance_type):
|
|
|
|
self.keys_instance_type = keys_instance_type
|
|
|
|
self.values_instance_type = values_instance_type
|
|
|
|
|
|
|
|
def check(self, identifier, key, value):
|
|
|
|
if len(value.keys()) < 1:
|
2017-04-18 19:25:04 +03:00
|
|
|
raise ParserError("%s: Failed check for %s - dict should not be empty." %
|
2017-04-11 15:50:15 +03:00
|
|
|
(identifier, key))
|
|
|
|
for x in value.iterkeys():
|
|
|
|
if not isinstance(x, self.keys_instance_type):
|
2017-04-18 19:25:04 +03:00
|
|
|
raise ParserError("%s: Failed dict type check for %s - expected key type %s, got "
|
|
|
|
"%s." %
|
2017-04-11 15:50:15 +03:00
|
|
|
(identifier, key,
|
|
|
|
nice_type_name(self.keys_instance_type),
|
|
|
|
nice_type_name(type(x))))
|
|
|
|
for k, v in value.iteritems():
|
|
|
|
if not isinstance(v, self.values_instance_type):
|
2017-04-18 19:25:04 +03:00
|
|
|
raise ParserError("%s: Failed dict type check for %s - "
|
|
|
|
"expected value type %s for key %s, got %s." %
|
2017-04-11 15:50:15 +03:00
|
|
|
(identifier, key,
|
|
|
|
nice_type_name(self.values_instance_type),
|
|
|
|
k, nice_type_name(type(v))))
|
2016-11-18 17:51:58 +03:00
|
|
|
|
2017-03-08 20:11:00 +03:00
|
|
|
|
2017-01-09 19:14:00 +03:00
|
|
|
def type_check_event_fields(identifier, name, definition):
|
2016-11-18 17:51:58 +03:00
|
|
|
"""Perform a type/schema check on the event definition."""
|
|
|
|
REQUIRED_FIELDS = {
|
2017-04-11 15:50:15 +03:00
|
|
|
'objects': ListTypeChecker(basestring),
|
|
|
|
'bug_numbers': ListTypeChecker(int),
|
|
|
|
'notification_emails': ListTypeChecker(basestring),
|
|
|
|
'record_in_processes': ListTypeChecker(basestring),
|
|
|
|
'description': AtomicTypeChecker(basestring),
|
2016-11-18 17:51:58 +03:00
|
|
|
}
|
|
|
|
OPTIONAL_FIELDS = {
|
2017-04-11 15:50:15 +03:00
|
|
|
'methods': ListTypeChecker(basestring),
|
|
|
|
'release_channel_collection': AtomicTypeChecker(basestring),
|
|
|
|
'expiry_date': MultiTypeChecker(basestring, datetime.date),
|
|
|
|
'expiry_version': AtomicTypeChecker(basestring),
|
|
|
|
'extra_keys': DictTypeChecker(basestring, basestring),
|
2016-11-18 17:51:58 +03:00
|
|
|
}
|
|
|
|
ALL_FIELDS = REQUIRED_FIELDS.copy()
|
|
|
|
ALL_FIELDS.update(OPTIONAL_FIELDS)
|
|
|
|
|
|
|
|
# Check that all the required fields are available.
|
|
|
|
missing_fields = [f for f in REQUIRED_FIELDS.keys() if f not in definition]
|
|
|
|
if len(missing_fields) > 0:
|
2017-04-18 19:25:04 +03:00
|
|
|
raise ParserError(identifier + ': Missing required fields: ' + ', '.join(missing_fields))
|
2016-11-18 17:51:58 +03:00
|
|
|
|
|
|
|
# Is there any unknown field?
|
|
|
|
unknown_fields = [f for f in definition.keys() if f not in ALL_FIELDS]
|
|
|
|
if len(unknown_fields) > 0:
|
2017-04-18 19:25:04 +03:00
|
|
|
raise ParserError(identifier + ': Unknown fields: ' + ', '.join(unknown_fields))
|
2016-11-18 17:51:58 +03:00
|
|
|
|
|
|
|
# Type-check fields.
|
2017-03-13 22:07:32 +03:00
|
|
|
for k, v in definition.iteritems():
|
2017-01-09 19:14:00 +03:00
|
|
|
ALL_FIELDS[k].check(identifier, k, v)
|
2016-11-18 17:51:58 +03:00
|
|
|
|
2017-03-08 20:11:00 +03:00
|
|
|
|
2017-01-09 19:14:00 +03:00
|
|
|
def string_check(identifier, field, value, min_length=1, max_length=None, regex=None):
|
2016-11-18 17:51:58 +03:00
|
|
|
# Length check.
|
2017-01-09 19:14:00 +03:00
|
|
|
if len(value) < min_length:
|
2017-04-18 19:25:04 +03:00
|
|
|
raise ParserError("%s: Value '%s' for field %s is less than minimum length of %d." %
|
2017-01-09 19:14:00 +03:00
|
|
|
(identifier, value, field, min_length))
|
|
|
|
if max_length and len(value) > max_length:
|
2017-04-18 19:25:04 +03:00
|
|
|
raise ParserError("%s: Value '%s' for field %s is greater than maximum length of %d." %
|
2017-01-09 19:14:00 +03:00
|
|
|
(identifier, value, field, max_length))
|
2016-11-18 17:51:58 +03:00
|
|
|
# Regex check.
|
|
|
|
if regex and not re.match(regex, value):
|
2017-04-18 19:25:04 +03:00
|
|
|
raise ParserError('%s: String value "%s" for %s is not matching pattern "%s".' %
|
2017-03-15 14:48:52 +03:00
|
|
|
(identifier, value, field, regex))
|
2016-11-18 17:51:58 +03:00
|
|
|
|
2017-03-08 20:11:00 +03:00
|
|
|
|
2016-11-18 17:51:58 +03:00
|
|
|
class EventData:
|
|
|
|
"""A class representing one event."""
|
|
|
|
|
2017-01-09 14:03:21 +03:00
|
|
|
def __init__(self, category, name, definition):
|
|
|
|
self._category = category
|
|
|
|
self._name = name
|
|
|
|
self._definition = definition
|
|
|
|
|
2017-01-09 19:14:00 +03:00
|
|
|
type_check_event_fields(self.identifier, name, definition)
|
|
|
|
|
2017-01-09 14:03:21 +03:00
|
|
|
# Check method & object string patterns.
|
|
|
|
for method in self.methods:
|
2017-01-09 19:14:00 +03:00
|
|
|
string_check(self.identifier, field='methods', value=method,
|
|
|
|
min_length=1, max_length=MAX_METHOD_NAME_LENGTH,
|
|
|
|
regex=IDENTIFIER_PATTERN)
|
2017-01-09 14:03:21 +03:00
|
|
|
for obj in self.objects:
|
2017-01-09 19:14:00 +03:00
|
|
|
string_check(self.identifier, field='objects', value=obj,
|
|
|
|
min_length=1, max_length=MAX_OBJECT_NAME_LENGTH,
|
|
|
|
regex=IDENTIFIER_PATTERN)
|
2016-11-18 17:51:58 +03:00
|
|
|
|
|
|
|
# Check release_channel_collection
|
|
|
|
rcc_key = 'release_channel_collection'
|
|
|
|
rcc = definition.get(rcc_key, 'opt-in')
|
|
|
|
allowed_rcc = ["opt-in", "opt-out"]
|
2017-03-14 04:03:13 +03:00
|
|
|
if rcc not in allowed_rcc:
|
2017-04-18 19:25:04 +03:00
|
|
|
raise ParserError("%s: Value for %s should be one of: %s" %
|
2017-03-15 14:48:52 +03:00
|
|
|
(self.identifier, rcc_key, ", ".join(allowed_rcc)))
|
2016-11-18 17:51:58 +03:00
|
|
|
|
2017-03-06 18:12:49 +03:00
|
|
|
# Check record_in_processes.
|
|
|
|
record_in_processes = definition.get('record_in_processes')
|
|
|
|
for proc in record_in_processes:
|
|
|
|
if not utils.is_valid_process_name(proc):
|
2017-04-18 19:25:04 +03:00
|
|
|
raise ParserError(self.identifier + ': Unknown value in record_in_processes: ' + proc)
|
2017-03-06 18:12:49 +03:00
|
|
|
|
2016-11-18 17:51:58 +03:00
|
|
|
# Check extra_keys.
|
|
|
|
extra_keys = definition.get('extra_keys', {})
|
|
|
|
if len(extra_keys.keys()) > MAX_EXTRA_KEYS_COUNT:
|
2017-04-18 19:25:04 +03:00
|
|
|
raise ParserError("%s: Number of extra_keys exceeds limit %d." %
|
2017-03-15 14:48:52 +03:00
|
|
|
(self.identifier, MAX_EXTRA_KEYS_COUNT))
|
2016-11-18 17:51:58 +03:00
|
|
|
for key in extra_keys.iterkeys():
|
2017-01-09 19:14:00 +03:00
|
|
|
string_check(self.identifier, field='extra_keys', value=key,
|
|
|
|
min_length=1, max_length=MAX_EXTRA_KEY_NAME_LENGTH,
|
|
|
|
regex=IDENTIFIER_PATTERN)
|
2016-11-18 17:51:58 +03:00
|
|
|
|
|
|
|
# Check expiry.
|
2017-03-14 04:03:13 +03:00
|
|
|
if 'expiry_version' not in definition and 'expiry_date' not in definition:
|
2017-04-18 19:21:36 +03:00
|
|
|
raise ParserError("%s: event is missing an expiration - either expiry_version or expiry_date is required" %
|
2017-03-15 14:48:52 +03:00
|
|
|
(self.identifier))
|
2016-11-18 17:51:58 +03:00
|
|
|
expiry_date = definition.get('expiry_date')
|
|
|
|
if expiry_date and isinstance(expiry_date, basestring) and expiry_date != 'never':
|
|
|
|
if not re.match(DATE_PATTERN, expiry_date):
|
2017-04-18 19:25:04 +03:00
|
|
|
raise ParserError("%s: Event has invalid expiry_date, it should be either 'never' or match this format: %s" %
|
2017-03-15 14:48:52 +03:00
|
|
|
(self.identifier, DATE_PATTERN))
|
2016-11-18 17:51:58 +03:00
|
|
|
# Parse into date.
|
|
|
|
definition['expiry_date'] = datetime.datetime.strptime(expiry_date, '%Y-%m-%d')
|
|
|
|
|
|
|
|
# Finish setup.
|
2017-03-06 18:12:49 +03:00
|
|
|
definition['expiry_version'] = utils.add_expiration_postfix(definition.get('expiry_version', 'never'))
|
2016-11-18 17:51:58 +03:00
|
|
|
|
|
|
|
@property
|
|
|
|
def category(self):
|
|
|
|
return self._category
|
|
|
|
|
|
|
|
@property
|
|
|
|
def category_cpp(self):
|
|
|
|
# Transform e.g. category.example into CategoryExample.
|
|
|
|
return convert_to_cpp_identifier(self._category, ".")
|
|
|
|
|
2017-01-09 14:03:21 +03:00
|
|
|
@property
|
|
|
|
def name(self):
|
|
|
|
return self._name
|
|
|
|
|
2017-01-09 19:14:00 +03:00
|
|
|
@property
|
|
|
|
def identifier(self):
|
|
|
|
return self.category + "#" + self.name
|
|
|
|
|
2016-11-18 17:51:58 +03:00
|
|
|
@property
|
|
|
|
def methods(self):
|
2017-01-09 14:03:21 +03:00
|
|
|
return self._definition.get('methods', [self.name])
|
2016-11-18 17:51:58 +03:00
|
|
|
|
|
|
|
@property
|
|
|
|
def objects(self):
|
|
|
|
return self._definition.get('objects')
|
|
|
|
|
2017-03-06 18:12:49 +03:00
|
|
|
@property
|
|
|
|
def record_in_processes(self):
|
|
|
|
return self._definition.get('record_in_processes')
|
|
|
|
|
|
|
|
@property
|
|
|
|
def record_in_processes_enum(self):
|
|
|
|
"""Get the non-empty list of flags representing the processes to record data in"""
|
|
|
|
return [utils.process_name_to_enum(p) for p in self.record_in_processes]
|
|
|
|
|
2016-11-18 17:51:58 +03:00
|
|
|
@property
|
|
|
|
def expiry_version(self):
|
|
|
|
return self._definition.get('expiry_version')
|
|
|
|
|
|
|
|
@property
|
|
|
|
def expiry_day(self):
|
|
|
|
date = self._definition.get('expiry_date')
|
|
|
|
if not date:
|
|
|
|
return 0
|
|
|
|
if isinstance(date, basestring) and date == 'never':
|
|
|
|
return 0
|
|
|
|
|
|
|
|
# Convert date to days since UNIX epoch.
|
|
|
|
epoch = datetime.date(1970, 1, 1)
|
|
|
|
days = (date - epoch).total_seconds() / (24 * 60 * 60)
|
|
|
|
return round(days)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def cpp_guard(self):
|
|
|
|
return self._definition.get('cpp_guard')
|
|
|
|
|
|
|
|
@property
|
|
|
|
def enum_labels(self):
|
|
|
|
def enum(method_name, object_name):
|
|
|
|
m = convert_to_cpp_identifier(method_name, "_")
|
|
|
|
o = convert_to_cpp_identifier(object_name, "_")
|
|
|
|
return m + '_' + o
|
|
|
|
combinations = itertools.product(self.methods, self.objects)
|
|
|
|
return [enum(t[0], t[1]) for t in combinations]
|
|
|
|
|
|
|
|
@property
|
|
|
|
def dataset(self):
|
|
|
|
"""Get the nsITelemetry constant equivalent for release_channel_collection.
|
|
|
|
"""
|
|
|
|
rcc = self._definition.get('release_channel_collection', 'opt-in')
|
|
|
|
if rcc == 'opt-out':
|
|
|
|
return 'nsITelemetry::DATASET_RELEASE_CHANNEL_OPTOUT'
|
|
|
|
else:
|
|
|
|
return 'nsITelemetry::DATASET_RELEASE_CHANNEL_OPTIN'
|
|
|
|
|
|
|
|
@property
|
|
|
|
def extra_keys(self):
|
|
|
|
return self._definition.get('extra_keys', {}).keys()
|
|
|
|
|
2017-03-08 20:11:00 +03:00
|
|
|
|
2016-11-18 17:51:58 +03:00
|
|
|
def load_events(filename):
|
|
|
|
"""Parses a YAML file containing the event definitions.
|
|
|
|
|
|
|
|
:param filename: the YAML file containing the event definitions.
|
2017-04-18 19:21:36 +03:00
|
|
|
:raises ParserError: if the event file cannot be opened or parsed.
|
2016-11-18 17:51:58 +03:00
|
|
|
"""
|
|
|
|
|
|
|
|
# Parse the event definitions from the YAML file.
|
|
|
|
events = None
|
|
|
|
try:
|
|
|
|
with open(filename, 'r') as f:
|
|
|
|
events = yaml.safe_load(f)
|
|
|
|
except IOError, e:
|
2017-04-18 19:25:04 +03:00
|
|
|
raise ParserError('Error opening ' + filename + ': ' + e.message + ".")
|
2017-04-18 19:21:36 +03:00
|
|
|
except ParserError, e:
|
2017-04-18 19:25:04 +03:00
|
|
|
raise ParserError('Error parsing events in ' + filename + ': ' + e.message + ".")
|
2016-11-18 17:51:58 +03:00
|
|
|
|
|
|
|
event_list = []
|
|
|
|
|
|
|
|
# Events are defined in a fixed two-level hierarchy within the definition file.
|
2017-01-09 14:03:21 +03:00
|
|
|
# The first level contains the category (group name), while the second level contains
|
|
|
|
# the event names and definitions, e.g.:
|
|
|
|
# category.name:
|
|
|
|
# event_name:
|
|
|
|
# <event definition>
|
|
|
|
# ...
|
|
|
|
# ...
|
2017-03-13 22:07:32 +03:00
|
|
|
for category_name, category in events.iteritems():
|
2017-01-09 19:14:00 +03:00
|
|
|
string_check("top level structure", field='category', value=category_name,
|
|
|
|
min_length=1, max_length=MAX_CATEGORY_NAME_LENGTH,
|
|
|
|
regex=IDENTIFIER_PATTERN)
|
2016-11-18 17:51:58 +03:00
|
|
|
|
|
|
|
# Make sure that the category has at least one entry in it.
|
|
|
|
if not category or len(category) == 0:
|
2017-04-18 19:25:04 +03:00
|
|
|
raise ParserError('Category ' + category_name + ' must contain at least one entry.')
|
2016-11-18 17:51:58 +03:00
|
|
|
|
2017-03-13 22:07:32 +03:00
|
|
|
for name, entry in category.iteritems():
|
2017-01-09 19:14:00 +03:00
|
|
|
string_check(category_name, field='event name', value=name,
|
|
|
|
min_length=1, max_length=MAX_METHOD_NAME_LENGTH,
|
|
|
|
regex=IDENTIFIER_PATTERN)
|
2017-01-09 14:03:21 +03:00
|
|
|
event_list.append(EventData(category_name, name, entry))
|
2016-11-18 17:51:58 +03:00
|
|
|
|
|
|
|
return event_list
|