"""YAML parsing.""" import re import ast import functools from collections import namedtuple import os import yaml from lxml.etree import XPath from astpath.search import find_in_ast, file_contents_to_xml_ast from bellybutton.exceptions import InvalidNode def constructor(tag=None, pattern=None): """Register custom constructor with pyyaml.""" def decorator(f): if tag is None or f is tag: tag_ = '!{}'.format(f.__name__) else: tag_ = tag yaml.add_constructor(tag_, f) if pattern is not None: yaml.add_implicit_resolver(tag_, re.compile(pattern)) return f if callable(tag): # little convenience hack to avoid empty arg list return decorator(tag) return decorator def _reraise_with_line_no(fn): @functools.wraps(fn) def wrapper(loader, node): try: return fn(loader, node) except Exception as e: msg = getattr(e, 'message', str(e)) raise InvalidNode( "line {}: {}.".format(node.start_mark.line + 1, msg) ) return wrapper @constructor(pattern=r'\~\+[/\\].+') @_reraise_with_line_no def glob(loader, node): """Construct glob expressions.""" value = loader.construct_scalar(node)[len('~+/'):] return os.path.join( os.path.dirname(loader.name), value ) # todo - all exprs return (parsed_expr, contents -> {lines})? @constructor(pattern=r'/.+') @_reraise_with_line_no def xpath(loader, node): """Construct XPath expressions.""" value = loader.construct_scalar(node) return XPath(value) @constructor @_reraise_with_line_no def regex(loader, node): """Construct regular expressions.""" value = loader.construct_scalar(node) return re.compile(value, re.MULTILINE) @constructor @_reraise_with_line_no def verbal(loader, node): """Construct verbal expressions.""" values = loader.construct_sequence(node) pass # todo: verbal expressions @constructor @_reraise_with_line_no def chain(loader, node): """Construct pipelines of other constructors.""" values = loader.construct_sequence(node) pass # todo: chain constructors (viz. xpath then regex) Settings = namedtuple('Settings', 'included excluded allow_ignore') @constructor def settings(loader, node): values = loader.construct_mapping(node) try: return Settings(**values) except TypeError: for field in Settings._fields: if field not in values: raise InvalidNode( "!settings node missing required field `{}`.".format(field) ) raise Rule = namedtuple('Rule', 'name description expr example instead settings') def validate_syntax(rule_clause, clause_type): try: ast.parse(rule_clause) except SyntaxError as e: raise InvalidNode("Invalid syntax in `{}` clause.".format(clause_type)) def _reraise_with_rule_name(fn): @functools.wraps(fn) def wrapper(rule_name, *args, **kwargs): try: return fn(rule_name, *args, **kwargs) except Exception as e: msg = getattr(e, 'message', str(e)) raise InvalidNode("rule `{}`: {}".format(rule_name, msg)) return wrapper @_reraise_with_rule_name def parse_rule(rule_name, rule_values, default_settings=None): rule_description = rule_values.get('description') if rule_description is None: raise InvalidNode("No description provided.") rule_expr = rule_values.get('expr') if rule_expr is None: raise InvalidNode("No expression provided.".format(rule_name)) matches = ( lambda x: find_in_ast( file_contents_to_xml_ast(x), rule_expr.path, return_lines=False ) if isinstance(rule_expr, XPath) else x.match ) rule_example = rule_values.get('example') if rule_example is not None: validate_syntax(rule_example, clause_type='example') if not matches(rule_example): raise InvalidNode("`example` clause is not matched by expression.") rule_instead = rule_values.get('instead') if rule_instead is not None: validate_syntax(rule_instead, clause_type='instead') if matches(rule_instead): raise InvalidNode("`instead` clause is matched by expression.") rule_settings = rule_values.get('settings', default_settings) if rule_settings is None: raise InvalidNode("No settings or default settings specified.") if not isinstance(rule_settings, Settings): raise InvalidNode("Settings must be a !settings node.") return Rule( name=rule_name, description=rule_description, expr=rule_expr, example=rule_example, instead=rule_instead, settings=rule_settings, ) def load_config(fileobj): """Load bellybutton config file, returning a list of rules.""" loaded = yaml.load(fileobj, Loader = yaml.FullLoader) default_settings = loaded.get('default_settings') rules = [ parse_rule(rule_name, rule_values, default_settings) for rule_name, rule_values in loaded.get('rules', {}).items() ] return rules