Skip to content

Commit

Permalink
Add YAML constructors (#3)
Browse files Browse the repository at this point in the history
* Add constructors for settings, rules
  • Loading branch information
hchasestevens committed Feb 18, 2018
1 parent 3cbbd21 commit 0163473
Show file tree
Hide file tree
Showing 8 changed files with 224 additions and 23 deletions.
6 changes: 6 additions & 0 deletions bellybutton/exceptions.py
@@ -0,0 +1,6 @@
"""Custom exceptions."""
from yaml import YAMLError


class InvalidNode(YAMLError):
"""Raised when a custom node fails validation."""
87 changes: 87 additions & 0 deletions bellybutton/parsing.py
@@ -1,8 +1,13 @@
"""YAML parsing."""
import ast
import re
from collections import namedtuple

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):
Expand Down Expand Up @@ -47,3 +52,85 @@ 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')


@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_example):
try:
ast.parse(rule_example)
except SyntaxError as e:
raise InvalidNode("Invalid syntax in rule example.")


def parse_rule(rule_name, rule_values, default_settings=None):
rule_description = rule_values.get('description')
if rule_description is None:
raise InvalidNode("No rule description provided.")

rule_expr = rule_values.get('expr')
if rule_expr is None:
raise InvalidNode("No rule expression provided.")
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)
if not matches(rule_example):
raise InvalidNode("Rule `example` clause is not matched by rule.")

rule_instead = rule_values.get('instead')
if rule_instead is not None:
validate_syntax(rule_instead)
if matches(rule_instead):
raise InvalidNode("Rule `instead` clause is matched by rule.")

rule_settings = rule_values.get('settings', default_settings)
if rule_settings is None:
raise InvalidNode("No rule settings or default settings specified.")

return Rule(
name=rule_name,
description=rule_description,
expr=rule_expr,
example=rule_example,
instead=rule_instead,
settings=rule_settings,
)


def load_config(fname):
"""Load bellybutton config file, returning a list of rules."""
loaded = yaml.load(fname)
default_settings = loaded.get('default_settings')
return [
parse_rule(rule_name, rule_values, default_settings)
for rule_name, rule_values in
loaded.get('rules', {}).items()
]
2 changes: 2 additions & 0 deletions pytest.ini
@@ -0,0 +1,2 @@
[pytest]
xfail_strict=true
28 changes: 28 additions & 0 deletions tests/integration/examples/.test.bellybutton.yml
@@ -0,0 +1,28 @@
settings:
all_files: &all_files !settings
included:
- "*"
excluded: []

tests_only: &tests_only !settings
included:
- tests/*
- test/*
excluded: []

excluding_tests: &excluding_tests !settings
included:
- "*"
excluded:
- tests/*
- test/*

default_settings: *excluding_tests

rules:
EmptyModule:
description: "Empty module."
expr: /Module/body[not(./*)]
example: ""
instead: |
"""This module has a docstring."""
6 changes: 0 additions & 6 deletions tests/integration/test_package.py

This file was deleted.

20 changes: 20 additions & 0 deletions tests/integration/test_parsing_integration.py
@@ -0,0 +1,20 @@
"""Integration tests for bellybutton/parsing.py."""

import os

import pytest

from bellybutton.parsing import load_config


@pytest.mark.parametrize('file', [
os.path.join(os.path.dirname(__file__), 'examples', fname)
for fname in os.listdir(
os.path.join(os.path.dirname(__file__), 'examples')
)
if fname.endswith('.yml')
])
def test_loadable(file):
"""Ensure that bellybutton is able to parse configuration."""
with open(file, 'r') as f:
assert isinstance(load_config(f), list)
81 changes: 81 additions & 0 deletions tests/unit/test_parsing.py
@@ -0,0 +1,81 @@
"""Unit tests for bellybutton/parsing.py."""

import re

import pytest

import yaml
from lxml.etree import XPath, XPathSyntaxError

from bellybutton.exceptions import InvalidNode
from bellybutton.parsing import Settings, parse_rule, Rule


@pytest.mark.parametrize('expression,expected_type', (
('!xpath //*', XPath),
('//*', XPath),
pytest.mark.xfail(('//[]', XPath), raises=XPathSyntaxError),
('!regex .*', re._pattern_type),
pytest.mark.xfail(('!regex "*"', re._pattern_type), raises=re.error),
('!settings {included: [], excluded: []}', Settings),
pytest.mark.xfail(('!settings {}', Settings), raises=InvalidNode)
))
def test_constructors(expression, expected_type):
"""Ensure custom constructors successfully parse given expressions."""
assert isinstance(yaml.load(expression), expected_type)


def test_parse_rule():
"""Ensure parse_rule returns expected output."""
expr = XPath("//Num")
assert parse_rule(
rule_name='',
rule_values=dict(
description='',
expr=expr,
example="a = 1",
instead="a = int('1')",
settings=Settings(included=[], excluded=[]),
)
) == Rule(
name='',
description='',
expr=expr,
example="a = 1",
instead="a = int('1')",
settings=Settings(included=[], excluded=[])
)


def test_parse_rule_requires_settings():
"""Ensure parse_rule raises an exception if settings are not provided."""
with pytest.raises(InvalidNode):
parse_rule(
rule_name='',
rule_values=dict(
description='',
expr=XPath("//Num"),
example="a = 1",
instead="a = int('1')",
)
)


@pytest.mark.parametrize('kwargs', (
dict(example="a = "),
dict(instead="a = int('1'"),
))
def test_parse_rule_validates_code_examples(kwargs):
"""
Ensure parse_rule raises an exception if code examples are syntactically
invalid.
"""
with pytest.raises(InvalidNode):
parse_rule(
rule_name='',
rule_values=dict(
description='',
expr=XPath("//Num"),
**kwargs
)
)
17 changes: 0 additions & 17 deletions tests/unit/test_yaml.py

This file was deleted.

0 comments on commit 0163473

Please sign in to comment.