3
# TODO: rename all validator.valid -> validator.accepts / accepted / accept ?
7
"""Create and hold validator error messages."""
12
self.path_level = None
15
"""Return number of errors."""
16
return len(self.messages)
19
"""Add new error message to current path."""
20
path = [str(p) for p in self.path]
21
msg = '[/%s] %s' % ('/'.join(path), msg)
22
self.messages.append(msg)
24
def back_out_errors(self, num=1):
25
"""Remove last num errors from list"""
27
del self.messages[0 - num:]
29
def path_add_level(self, value='?'):
30
"""Adds level into error message path"""
31
self.path_level = len(self.path)
32
self.path.append(value)
34
def path_remove_level(self):
35
"""Removes level from path by depth number"""
36
if self.path_level is None:
37
raise Exception('no path level')
38
del(self.path[self.path_level])
41
def path_update_value(self, value):
42
"""Updates path level value"""
43
if self.path_level is None:
44
raise Exception('no path level')
45
self.path[self.path_level] = value
47
# A registry mapping validator names to their class
51
def factory(name='root', **kwargs):
52
"""Factory method, returns validator instance."""
53
if name not in registry:
54
raise Exception('Asked unknown validator \'%s\'' % name)
55
return registry[name](**kwargs)
58
class Validator(object):
61
class __metaclass__(type):
62
"""Automatically adds subclasses to the registry."""
64
def __init__(cls, name, bases, dict):
65
type.__init__(cls, name, bases, dict)
66
if not 'name' in dict:
67
raise Exception('Validator %s is missing class-attribute name' % name)
68
registry[dict['name']] = cls
70
def __init__(self, parent=None, message=None, **kwargs):
72
self.message = message
78
"""Recursively return the Errors class from the root of the validator tree."""
80
return self.parent.errors
83
self._errors = Errors()
86
def add_root_parent(self):
87
if self.name == 'root':
89
root = factory('root')
93
def add_parent(self, parent):
97
def get_validator(self, value, **kwargs):
98
"""Returns a child validator of this one.
101
Can be a validator type string, an already created Validator instance,
102
or a function that returns a validator instance.
104
Keyword arguments are passed on to validator init if a new validator is created.
106
if isinstance(value, Validator):
107
# If we are passed a Validator instance, make it a child of this validator and return it.
108
value.add_parent(self)
110
elif callable(value):
111
# Create a LazyValidator that will serve as a Validator when attributes are accessed.
112
return LazyValidator(value, parent=self)
113
# Otherwise create a new child Validator
114
kwargs['parent'] = self
115
return factory(value, **kwargs)
117
def accept(self, value, **kwargs):
118
raise NotImplementedError('Validator %s should override accept method' % self.__class__.__name__)
120
def validateable(self, data):
121
"""Return True if validator can be used to validate given data, False otherwise."""
122
raise NotImplementedError('Validator %s should override validateable method' % self.__class__.__name__)
124
def validate(self, data):
125
"""Validate given data and log errors, return True if passed and False if not."""
126
raise NotImplementedError('Validator %s should override validate method' % self.__class__.__name__)
129
"""Return schema for validator"""
130
raise NotImplementedError(self.__name__)
132
def validate_item(self, item, rules):
134
Helper method. Validate item against list of rules (validators).
135
Return True if item passed any of the rules, False if none of the rules pass item.
137
count = self.errors.count()
139
# print 'validating %s' % rule.name
140
if rule.validateable(item):
141
if rule.validate(item):
142
# item is valid, remove added errors before returning
143
self.errors.back_out_errors(self.errors.count() - count)
146
# If no validators matched or reported errors, and one of them has a custom error message, display it.
147
if count == self.errors.count():
150
self.errors.add(rule.message)
151
# If there are still no errors, list the valid types, as well as what was actually received
152
if count == self.errors.count():
153
acceptable = [v.name for v in rules]
154
# Make acceptable into an english list, with commas and 'or'
155
acceptable = ', '.join(acceptable[:-2] + ['']) + ' or '.join(acceptable[-2:])
156
self.errors.add('must be a `%s` value' % acceptable)
157
if isinstance(item, dict):
158
self.errors.add('got a dict instead of %s' % acceptable)
159
elif isinstance(item, list):
160
self.errors.add('got a list instead of %s' % acceptable)
162
self.errors.add('value \'%s\' is not valid %s' % (item, acceptable))
166
return '<validator:name=%s>' % self.name
171
class RootValidator(Validator):
174
def accept(self, value, **kwargs):
175
v = self.get_validator(value, **kwargs)
179
def validateable(self, data):
182
def validate(self, data):
183
return self.validate_item(data, self.valid)
186
return {'type': 'root', 'valid': [v.schema() for v in self.valid]}
189
class ChoiceValidator(Validator):
192
def __init__(self, parent=None, **kwargs):
194
Validator.__init__(self, parent, **kwargs)
196
def accept(self, value, ignore_case=False):
198
:param value: accepted text, int or boolean
199
:param bool ignore_case: Whether case matters for text values
201
if not isinstance(value, (basestring, int, float)):
202
raise Exception('Choice validator only accepts strings and numbers')
203
if isinstance(value, basestring) and ignore_case:
204
self.valid_ic.append(value.lower())
206
self.valid.append(value)
208
def accept_choices(self, values, **kwargs):
209
"""Same as accept but with multiple values (list)"""
211
self.accept(value, **kwargs)
213
def validateable(self, data):
214
return isinstance(data, (basestring, int, float))
216
def validate(self, data):
217
if data in self.valid:
219
elif isinstance(data, basestring) and data.lower() in self.valid_ic:
222
acceptable = (str(value) for value in self.valid + self.valid_ic)
223
self.errors.add('\'%s\' is not one of acceptable values: %s' % (data, ', '.join(acceptable)))
227
return {'type': 'choice', 'valid': self.valid + self.valid_ic}
230
class AnyValidator(Validator):
233
def accept(self, value, **kwargs):
236
def validateable(self, data):
239
def validate(self, data):
243
return {'type': 'any'}
246
class EqualsValidator(Validator):
249
def accept(self, value, **kwargs):
252
def validateable(self, data):
253
return isinstance(data, (basestring, int, float))
255
def validate(self, data):
256
return self.valid == data
259
return {'type': 'equals', 'valid': self.valid}
262
class NumberValidator(Validator):
265
def accept(self, name, **kwargs):
268
def validateable(self, data):
269
return isinstance(data, (int, float))
271
def validate(self, data):
272
valid = isinstance(data, (int, float))
274
self.errors.add('value %s is not valid number' % data)
278
return {'type': 'number'}
281
class IntegerValidator(Validator):
284
def accept(self, name, **kwargs):
287
def validateable(self, data):
288
return isinstance(data, int)
290
def validate(self, data):
291
valid = isinstance(data, int)
293
self.errors.add('value %s is not valid integer' % data)
297
return {'type': 'integer'}
300
class DecimalValidator(Validator):
303
def accept(self, name, **kwargs):
306
def validateable(self, data):
307
return isinstance(data, float)
309
def validate(self, data):
310
valid = isinstance(data, float)
312
self.errors.add('value %s is not valid decimal number' % data)
316
return {'type': 'decimal'}
319
class BooleanValidator(Validator):
322
def accept(self, name, **kwargs):
325
def validateable(self, data):
326
return isinstance(data, bool)
328
def validate(self, data):
329
valid = isinstance(data, bool)
331
self.errors.add('value %s is not valid boolean' % data)
335
return {'type': 'boolean'}
338
class TextValidator(Validator):
341
def accept(self, name, **kwargs):
344
def validateable(self, data):
345
return isinstance(data, basestring)
347
def validate(self, data):
348
valid = isinstance(data, basestring)
350
self.errors.add('value %s is not valid text' % data)
354
return {'type': 'text'}
357
class RegexpValidator(Validator):
360
def accept(self, name, **kwargs):
363
def validateable(self, data):
364
return isinstance(data, basestring)
366
def validate(self, data):
367
if not isinstance(data, basestring):
368
self.errors.add('Value should be text')
373
self.errors.add('%s is not a valid regular expression' % data)
378
return {'type': 'regexp'}
381
class RegexpMatchValidator(Validator):
382
name = 'regexp_match'
384
def __init__(self, parent=None, **kwargs):
385
Validator.__init__(self, parent, **kwargs)
387
self.reject_regexps = []
389
def add_regexp(self, regexp_list, regexp):
391
regexp_list.append(re.compile(regexp))
393
raise ValueError('Invalid regexp given to match_regexp')
395
def accept(self, regexp, **kwargs):
396
self.add_regexp(self.regexps, regexp)
397
if kwargs.get('message'):
398
self.message = kwargs['message']
400
def reject(self, regexp):
401
self.add_regexp(self.reject_regexps, regexp)
403
def validateable(self, data):
404
return isinstance(data, basestring)
406
def validate(self, data):
407
if not isinstance(data, basestring):
408
self.errors.add('Value should be text')
410
for regexp in self.reject_regexps:
411
if regexp.match(data):
414
for regexp in self.regexps:
415
if regexp.match(data):
418
self.errors.add(self.message)
420
self.errors.add('%s does not match regexp' % data)
424
return {'type': 'regexp_match', 'valid': {
425
'accept': [r.pattern for r in self.regexps],
426
'reject': [r.pattern for r in self.reject_regexps]}}
429
class IntervalValidator(RegexpMatchValidator):
432
def __init__(self, parent=None, **kwargs):
433
RegexpMatchValidator.__init__(self, parent, **kwargs)
434
self.accept(r'^\d+ (second|minute|hour|day|week)s?$')
435
self.message = "should be in format 'x (seconds|minutes|hours|days|weeks)'"
438
class FileValidator(TextValidator):
441
def validate(self, data):
444
if not os.path.isfile(os.path.expanduser(data)):
445
self.errors.add('File %s does not exist' % data)
450
return {'type': 'file'}
453
class PathValidator(TextValidator):
456
def __init__(self, parent=None, allow_replacement=False, **kwargs):
457
self.allow_replacement = allow_replacement
458
Validator.__init__(self, parent, **kwargs)
460
def validate(self, data):
464
if self.allow_replacement:
465
# If string replacement is allowed, only validate the part of the
466
# path before the first identifier to be replaced
467
pat = re.compile(r'{[{%].*[}%]}')
468
result = pat.search(data)
470
# Check for old style string replacement if no jinja identifiers are found
471
pat = re.compile(r'''
472
% # Start with percent,
473
(?:\( ([^()]*) \)) # name in parens (do not capture parens),
474
[-+ #0]* # zero or more flags
475
(?:\*|[0-9]*) # optional minimum field width
476
(?:\.(?:\*|[0-9]*))? # optional dot and length modifier
477
[EGXcdefgiorsux%] # type code (or [formatted] percent character)
480
result = pat.search(data)
482
path = os.path.dirname(data[0:result.start()])
484
if not os.path.isdir(os.path.expanduser(path)):
485
self.errors.add('Path %s does not exist' % path)
490
return {'type': 'path'}
493
class UrlValidator(TextValidator):
496
def __init__(self, parent=None, protocols=None, **kwargs):
498
self.protocols = protocols
500
self.protocols = ['ftp', 'http', 'https', 'file']
501
Validator.__init__(self, parent, **kwargs)
503
def validate(self, data):
504
regexp = '(' + '|'.join(self.protocols) + '):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?'
505
if not isinstance(data, basestring):
506
self.errors.add('expecting text')
508
valid = re.match(regexp, data) is not None
510
self.errors.add('value %s is not a valid url' % data)
514
return {'type': 'url'}
517
class ListValidator(Validator):
520
def accept(self, value, **kwargs):
521
v = self.get_validator(value, **kwargs)
525
def validateable(self, data):
526
return isinstance(data, list)
528
def validate(self, data):
529
if not isinstance(data, list):
530
self.errors.add('value must be a list')
532
self.errors.path_add_level()
533
count = self.errors.count()
535
self.errors.path_update_value('list:%i' % data.index(item))
536
self.validate_item(item, self.valid)
537
self.errors.path_remove_level()
538
return count == self.errors.count()
541
return {'type': 'list', 'valid': [v.schema() for v in self.valid]}
544
class DictValidator(Validator):
547
def __init__(self, parent=None, **kwargs):
550
self.required_keys = []
551
self.key_validators = []
552
Validator.__init__(self, parent, **kwargs)
553
# TODO: not dictionary?
556
def accept(self, value, key=None, required=False, **kwargs):
558
:param value: validator name, instance or function that returns an instance, which validates the given `key`
559
:param string key: The dictionary key to accept
560
:param bool required: = Mark this `key` as required
561
:raises ValueError: `key` was not specified
564
raise ValueError('%s.accept() must specify key' % self.name)
567
self.require_key(key)
569
v = self.get_validator(value, **kwargs)
570
self.valid.setdefault(key, []).append(v)
573
def reject_key(self, key, message=None):
575
self.reject[key] = message
577
def reject_keys(self, keys, message=None):
578
"""Reject list of keys"""
580
self.reject[key] = message
582
def require_key(self, key):
583
"""Flag key as mandatory"""
584
if not key in self.required_keys:
585
self.required_keys.append(key)
587
def accept_any_key(self, value, **kwargs):
588
"""Accepts any leftover keys in dictionary, which will be validated with `value`"""
589
v = self.get_validator(value, **kwargs)
590
self.any_key.append(v)
593
def accept_valid_keys(self, value, key_type=None, key_validator=None, **kwargs):
595
Accepts keys that pass a given validator, and validates them using validator specified in `value`
597
:param value: Validator name, instance or function returning an instance
598
that will be used to validate dict values.
599
:param key_type: Name of validator or list of names that determine which keys in this dict `value` will govern
600
:param Validator key_validator: A validator instance that will be used to determine which keys in the dict
602
:raises ValueError: If both `key_type` and `key_validator` are specified.
604
if key_type and key_validator:
605
raise ValueError('key_type and key_validator are mutually exclusive')
607
# Make sure errors show up in our list
608
key_validator.add_parent(self)
610
if isinstance(key_type, basestring):
611
key_type = [key_type]
612
key_validator = self.get_validator('root')
613
for key_type in key_type:
614
key_validator.accept(key_type)
616
raise ValueError('%s.accept_valid_keys() must specify key_type or key_validator' % self.name)
617
v = self.get_validator(value, **kwargs)
618
self.key_validators.append((key_validator, v))
621
def validateable(self, data):
622
return isinstance(data, dict)
624
def validate(self, data):
625
if not isinstance(data, dict):
626
self.errors.add('value must be a dictionary')
629
count = self.errors.count()
630
self.errors.path_add_level()
631
for key, value in data.iteritems():
632
self.errors.path_update_value('dict:%s' % key)
634
if key in self.reject:
635
msg = self.reject[key]
637
from string import Template
639
template = Template(msg)
640
self.errors.add(template.safe_substitute(key=key))
642
self.errors.add('key \'%s\' is forbidden here' % key)
644
# Get rules for key, most specific rules will be used
646
if key in self.valid:
647
# Rules for explicitly allowed keys
648
rules = self.valid.get(key, [])
650
errors_before_key_val = self.errors.count()
651
for key_validator, value_validator in self.key_validators:
652
# Use validate_item to make sure error message is added
653
if key_validator.validateable(key) and key_validator.validate(key):
654
# Rules for a validated_key
655
rules = [value_validator]
662
self.errors.back_out_errors(self.errors.count() - errors_before_key_val)
664
error = 'key \'%s\' is not recognized' % key
666
error += ', valid keys: %s' % ', '.join(sorted(self.valid))
667
# TODO: print options if accept_valid_keys is used
668
self.errors.add(error)
670
self.validate_item(value, rules)
671
self.errors.path_remove_level()
672
for required in self.required_keys:
673
if not required in data:
674
self.errors.add('key \'%s\' required' % required)
675
return count == self.errors.count()
678
schema = {'type': 'dict'}
680
for name, validators in self.valid.iteritems():
683
if len(validators) == 1:
684
valid[name] = validators[0].schema()
686
valid[name] = [v.schema() for v in validators]
688
schema['valid'] = valid
689
if self.required_keys:
690
schema['required_keys'] = self.required_keys
692
schema['any_key'] = [v.schema() for v in self.any_key]
694
schema['reject_keys'] = self.reject
699
class LazyValidator(object):
700
"""Acts as a wrapper for a Validator instance, but does not generate the instance until one of its attributes
701
needs to be accessed. Used to create validators that may otherwise cause endless loops."""
703
def __init__(self, func, parent=None):
705
:param func: A function that returns a Validator instance when called.
706
:param parent: The parent validator.
709
self.validator = None
712
def __getattr__(self, item):
713
"""Creates the actual validator instance if needed. Return attributes of that instance as our own."""
714
if self.validator is None:
715
self.validator = self.func()
716
assert isinstance(self.validator, Validator)
717
self.validator.add_parent(self.parent)
718
return getattr(self.validator, item)
721
"""Return the schema of our instance if it has already been created, otherwise return 'ondemand' type."""
722
if self.validator is None:
723
return {'type': 'ondemand'}
725
return self.validator.schema()
730
def build_options_validator(options):
731
quals = ['720p', '1080p', '720p bluray', 'hdtv']
732
options.accept('text', key='path')
734
options.accept('dict', key='set').accept_any_key('any')
735
# regexes can be given in as a single string ..
736
options.accept('regexp', key='name_regexp')
737
options.accept('regexp', key='ep_regexp')
738
options.accept('regexp', key='id_regexp')
739
# .. or as list containing strings
740
options.accept('list', key='name_regexp').accept('regexp')
741
options.accept('list', key='ep_regexp').accept('regexp')
742
options.accept('list', key='id_regexp').accept('regexp')
744
options.accept('choice', key='quality').accept_choices(quals, ignore_case=True)
745
options.accept('list', key='qualities').accept('choice').accept_choices(quals, ignore_case=True)
746
options.accept('boolean', key='upgrade')
747
options.accept('choice', key='min_quality').accept_choices(quals, ignore_case=True)
748
options.accept('choice', key='max_quality').accept_choices(quals, ignore_case=True)
750
options.accept('boolean', key='propers')
751
message = "should be in format 'x (minutes|hours|days|weeks)' e.g. '5 days'"
752
time_regexp = r'\d+ (minutes|hours|days|weeks)'
753
options.accept('regexp_match', key='propers', message=message + ' or yes/no').accept(time_regexp)
755
options.accept('choice', key='identified_by').accept_choices(['ep', 'id', 'auto'])
757
options.accept('regexp_match', key='timeframe', message=message).accept(time_regexp)
759
options.accept('boolean', key='exact')
760
# watched in SXXEXX form
761
watched = options.accept('regexp_match', key='watched')
762
watched.accept('(?i)s\d\de\d\d$', message='Must be in SXXEXX format')
763
# watched in dict form
764
watched = options.accept('dict', key='watched')
765
watched.accept('integer', key='season')
766
watched.accept('integer', key='episode')
768
options.accept('text', key='from_group')
769
options.accept('list', key='from_group').accept('text')
771
options.accept('boolean', key='parse_only')
776
def build_list(series):
777
"""Build series list to series."""
778
series.accept('text')
779
series.accept('number')
780
bundle = series.accept('dict')
781
# prevent invalid indentation level
783
bundle.reject_keys(['set', 'path', 'timeframe', 'name_regexp',
784
'ep_regexp', 'id_regexp', 'watched', 'quality', 'min_quality',
785
'max_quality', 'qualities', 'exact', 'from_group'],
786
'Option \'$key\' has invalid indentation level. It needs 2 more spaces.')
788
bundle.accept_any_key('path')
789
options = bundle.accept_any_key('dict')
790
build_options_validator(options)
798
simple = root.accept('list')
808
advanced = root.accept('dict')
809
settings = advanced.accept('dict', key='settings')
810
settings_group = settings.accept_any_key('dict')
811
build_options_validator(settings_group)
813
group = advanced.accept_any_key('list')
820
if __name__ == '__main__':
826
print yaml.dump(schema)
830
list = root.accept('list')
832
list.accept('regexp')
833
list.accept('choice').accept_choices(['foo', 'bar'])