flexget.validator
Covered: 547 lines
Missed: 73 lines
Skipped 217 lines
Percent: 88 %
  1
import re
  6
class Errors(object):
  7
    """Create and hold validator error messages."""
  9
    def __init__(self):
 10
        self.messages = []
 11
        self.path = []
 12
        self.path_level = None
 14
    def count(self):
 15
        """Return number of errors."""
 16
        return len(self.messages)
 18
    def add(self, msg):
 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"""
 26
        if num > 0:
 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])
 39
        self.path_level -= 1
 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
 48
registry = {}
 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):
 59
    name = 'validator'
 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):
 71
        self.valid = []
 72
        self.message = message
 73
        self.parent = parent
 74
        self._errors = None
 76
    @property
 77
    def errors(self):
 78
        """Recursively return the Errors class from the root of the validator tree."""
 79
        if self.parent:
 80
            return self.parent.errors
 81
        else:
 82
            if not self._errors:
 83
                self._errors = Errors()
 84
            return self._errors
 86
    def add_root_parent(self):
 87
        if self.name == 'root':
 88
            return self
 89
        root = factory('root')
 90
        root.accept(self)
 91
        return root
 93
    def add_parent(self, parent):
 94
        self.parent = parent
 95
        return parent
 97
    def get_validator(self, value, **kwargs):
 98
        """Returns a child validator of this one.
100
        :param value:
101
          Can be a validator type string, an already created Validator instance,
102
          or a function that returns a validator instance.
103
        :param kwargs:
104
          Keyword arguments are passed on to validator init if a new validator is created.
105
        """
106
        if isinstance(value, Validator):
108
            value.add_parent(self)
109
            return value
110
        elif callable(value):
112
            return LazyValidator(value, parent=self)
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__)
128
    def schema(self):
129
        """Return schema for validator"""
130
        raise NotImplementedError(self.__name__)
132
    def validate_item(self, item, rules):
133
        """
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.
136
        """
137
        count = self.errors.count()
138
        for rule in rules:
140
            if rule.validateable(item):
141
                if rule.validate(item):
143
                    self.errors.back_out_errors(self.errors.count() - count)
144
                    return True
147
        if count == self.errors.count():
148
            for rule in rules:
149
                if rule.message:
150
                    self.errors.add(rule.message)
152
            if count == self.errors.count():
153
                acceptable = [v.name for v in rules]
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)
161
                else:
162
                    self.errors.add('value \'%s\' is not valid %s' % (item, acceptable))
163
        return False
165
    def __str__(self):
166
        return '<validator:name=%s>' % self.name
168
    __repr__ = __str__
171
class RootValidator(Validator):
172
    name = 'root'
174
    def accept(self, value, **kwargs):
175
        v = self.get_validator(value, **kwargs)
176
        self.valid.append(v)
177
        return v
179
    def validateable(self, data):
180
        return True
182
    def validate(self, data):
183
        return self.validate_item(data, self.valid)
185
    def schema(self):
186
        return {'type': 'root', 'valid': [v.schema() for v in self.valid]}
189
class ChoiceValidator(Validator):
190
    name = 'choice'
192
    def __init__(self, parent=None, **kwargs):
193
        self.valid_ic = []
194
        Validator.__init__(self, parent, **kwargs)
196
    def accept(self, value, ignore_case=False):
197
        """
198
        :param value: accepted text, int or boolean
199
        :param bool ignore_case: Whether case matters for text values
200
        """
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())
205
        else:
206
            self.valid.append(value)
208
    def accept_choices(self, values, **kwargs):
209
        """Same as accept but with multiple values (list)"""
210
        for value in values:
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:
218
            return True
219
        elif isinstance(data, basestring) and data.lower() in self.valid_ic:
220
            return True
221
        else:
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)))
224
            return False
226
    def schema(self):
227
        return {'type': 'choice', 'valid': self.valid + self.valid_ic}
230
class AnyValidator(Validator):
231
    name = 'any'
233
    def accept(self, value, **kwargs):
234
        self.valid = value
236
    def validateable(self, data):
237
        return True
239
    def validate(self, data):
240
        return True
242
    def schema(self):
243
        return {'type': 'any'}
246
class EqualsValidator(Validator):
247
    name = 'equals'
249
    def accept(self, value, **kwargs):
250
        self.valid = value
252
    def validateable(self, data):
253
        return isinstance(data, (basestring, int, float))
255
    def validate(self, data):
256
        return self.valid == data
258
    def schema(self):
259
        return {'type': 'equals', 'valid': self.valid}
262
class NumberValidator(Validator):
263
    name = 'number'
265
    def accept(self, name, **kwargs):
266
        pass
268
    def validateable(self, data):
269
        return isinstance(data, (int, float))
271
    def validate(self, data):
272
        valid = isinstance(data, (int, float))
273
        if not valid:
274
            self.errors.add('value %s is not valid number' % data)
275
        return valid
277
    def schema(self):
278
        return {'type': 'number'}
281
class IntegerValidator(Validator):
282
    name = 'integer'
284
    def accept(self, name, **kwargs):
285
        pass
287
    def validateable(self, data):
288
        return isinstance(data, int)
290
    def validate(self, data):
291
        valid = isinstance(data, int)
292
        if not valid:
293
            self.errors.add('value %s is not valid integer' % data)
294
        return valid
296
    def schema(self):
297
        return {'type': 'integer'}
300
class DecimalValidator(Validator):
301
    name = 'decimal'
303
    def accept(self, name, **kwargs):
304
        pass
306
    def validateable(self, data):
307
        return isinstance(data, float)
309
    def validate(self, data):
310
        valid = isinstance(data, float)
311
        if not valid:
312
            self.errors.add('value %s is not valid decimal number' % data)
313
        return valid
315
    def schema(self):
316
        return {'type': 'decimal'}
319
class BooleanValidator(Validator):
320
    name = 'boolean'
322
    def accept(self, name, **kwargs):
323
        pass
325
    def validateable(self, data):
326
        return isinstance(data, bool)
328
    def validate(self, data):
329
        valid = isinstance(data, bool)
330
        if not valid:
331
            self.errors.add('value %s is not valid boolean' % data)
332
        return valid
334
    def schema(self):
335
        return {'type': 'boolean'}
338
class TextValidator(Validator):
339
    name = 'text'
341
    def accept(self, name, **kwargs):
342
        pass
344
    def validateable(self, data):
345
        return isinstance(data, basestring)
347
    def validate(self, data):
348
        valid = isinstance(data, basestring)
349
        if not valid:
350
            self.errors.add('value %s is not valid text' % data)
351
        return valid
353
    def schema(self):
354
        return {'type': 'text'}
357
class RegexpValidator(Validator):
358
    name = 'regexp'
360
    def accept(self, name, **kwargs):
361
        pass
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')
369
            return False
370
        try:
371
            re.compile(data)
372
        except:
373
            self.errors.add('%s is not a valid regular expression' % data)
374
            return False
375
        return True
377
    def schema(self):
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)
386
        self.regexps = []
387
        self.reject_regexps = []
389
    def add_regexp(self, regexp_list, regexp):
390
        try:
391
            regexp_list.append(re.compile(regexp))
392
        except:
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')
409
            return False
410
        for regexp in self.reject_regexps:
411
            if regexp.match(data):
412
                break
413
        else:
414
            for regexp in self.regexps:
415
                if regexp.match(data):
416
                    return True
417
        if self.message:
418
            self.errors.add(self.message)
419
        else:
420
            self.errors.add('%s does not match regexp' % data)
421
        return False
423
    def schema(self):
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):
430
    name = 'interval'
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):
439
    name = 'file'
441
    def validate(self, data):
442
        import os
444
        if not os.path.isfile(os.path.expanduser(data)):
445
            self.errors.add('File %s does not exist' % data)
446
            return False
447
        return True
449
    def schema(self):
450
        return {'type': 'file'}
453
class PathValidator(TextValidator):
454
    name = 'path'
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):
461
        import os
463
        path = data
464
        if self.allow_replacement:
467
            pat = re.compile(r'{[{%].*[}%]}')
468
            result = pat.search(data)
469
            if not result:
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)
478
                    ''', re.VERBOSE)
480
                result = pat.search(data)
481
            if result:
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)
486
            return False
487
        return True
489
    def schema(self):
490
        return {'type': 'path'}
493
class UrlValidator(TextValidator):
494
    name = 'url'
496
    def __init__(self, parent=None, protocols=None, **kwargs):
497
        if protocols:
498
            self.protocols = protocols
499
        else:
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')
507
            return False
508
        valid = re.match(regexp, data) is not None
509
        if not valid:
510
            self.errors.add('value %s is not a valid url' % data)
511
        return valid
513
    def schema(self):
514
        return {'type': 'url'}
517
class ListValidator(Validator):
518
    name = 'list'
520
    def accept(self, value, **kwargs):
521
        v = self.get_validator(value, **kwargs)
522
        self.valid.append(v)
523
        return v
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')
531
            return False
532
        self.errors.path_add_level()
533
        count = self.errors.count()
534
        for item in data:
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()
540
    def schema(self):
541
        return {'type': 'list', 'valid': [v.schema() for v in self.valid]}
544
class DictValidator(Validator):
545
    name = 'dict'
547
    def __init__(self, parent=None, **kwargs):
548
        self.reject = {}
549
        self.any_key = []
550
        self.required_keys = []
551
        self.key_validators = []
552
        Validator.__init__(self, parent, **kwargs)
554
        self.valid = {}
556
    def accept(self, value, key=None, required=False, **kwargs):
557
        """
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
562
        """
563
        if not key:
564
            raise ValueError('%s.accept() must specify key' % self.name)
566
        if required:
567
            self.require_key(key)
569
        v = self.get_validator(value, **kwargs)
570
        self.valid.setdefault(key, []).append(v)
571
        return v
573
    def reject_key(self, key, message=None):
574
        """Rejects a key"""
575
        self.reject[key] = message
577
    def reject_keys(self, keys, message=None):
578
        """Reject list of keys"""
579
        for key in 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)
591
        return v
593
    def accept_valid_keys(self, value, key_type=None, key_validator=None, **kwargs):
594
        """
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
601
            `value` will govern
602
        :raises ValueError: If both `key_type` and `key_validator` are specified.
603
        """
604
        if key_type and key_validator:
605
            raise ValueError('key_type and key_validator are mutually exclusive')
606
        if key_validator:
608
            key_validator.add_parent(self)
609
        elif key_type:
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)
615
        else:
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))
619
        return 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')
627
            return False
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]
636
                if msg:
637
                    from string import Template
639
                    template = Template(msg)
640
                    self.errors.add(template.safe_substitute(key=key))
641
                else:
642
                    self.errors.add('key \'%s\' is forbidden here' % key)
643
                continue
645
            rules = []
646
            if key in self.valid:
648
                rules = self.valid.get(key, [])
649
            else:
650
                errors_before_key_val = self.errors.count()
651
                for key_validator, value_validator in self.key_validators:
653
                    if key_validator.validateable(key) and key_validator.validate(key):
655
                        rules = [value_validator]
656
                        break
657
                else:
658
                    if self.any_key:
660
                        rules = self.any_key
661
                if rules:
662
                    self.errors.back_out_errors(self.errors.count() - errors_before_key_val)
663
            if not rules:
664
                error = 'key \'%s\' is not recognized' % key
665
                if self.valid:
666
                    error += ', valid keys: %s' % ', '.join(sorted(self.valid))
668
                self.errors.add(error)
669
                continue
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()
677
    def schema(self):
678
        schema = {'type': 'dict'}
679
        valid = {}
680
        for name, validators in self.valid.iteritems():
681
            if not validators:
682
                continue
683
            if len(validators) == 1:
684
                valid[name] = validators[0].schema()
685
            else:
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
691
        if self.any_key:
692
            schema['any_key'] = [v.schema() for v in self.any_key]
693
        if self.reject_keys:
694
            schema['reject_keys'] = self.reject
696
        return schema
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):
704
        """
705
        :param func: A function that returns a Validator instance when called.
706
        :param parent: The parent validator.
707
        """
708
        self.func = func
709
        self.validator = None
710
        self.parent = parent
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)
720
    def schema(self):
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'}
724
        else:
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')
736
    options.accept('regexp', key='name_regexp')
737
    options.accept('regexp', key='ep_regexp')
738
    options.accept('regexp', key='id_regexp')
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')
761
    watched = options.accept('regexp_match', key='watched')
762
    watched.accept('(?i)s\d\de\d\d$', message='Must be in SXXEXX format')
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')
774
def complex_test():
776
    def build_list(series):
777
        """Build series list to series."""
778
        series.accept('text')
779
        series.accept('number')
780
        bundle = series.accept('dict')
782
        """
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.')
787
        """
788
        bundle.accept_any_key('path')
789
        options = bundle.accept_any_key('dict')
790
        build_options_validator(options)
792
    root = factory()
798
    simple = root.accept('list')
799
    build_list(simple)
807
    """
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')
814
    build_list(group)
815
    """
817
    return root
820
if __name__ == '__main__':
821
    v = complex_test()
822
    schema = v.schema()
824
    import yaml
826
    print yaml.dump(schema)
828
    """
829
    root = factory()
830
    list = root.accept('list')
831
    list.accept('text')
832
    list.accept('regexp')
833
    list.accept('choice').accept_choices(['foo', 'bar'])
835
    print root.schema()
836
    """