Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add YAML constructors #3

Merged
merged 2 commits into from
Feb 18, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bellybutton/exceptions.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[pytest]
xfail_strict=true
28 changes: 28 additions & 0 deletions tests/integration/examples/.test.bellybutton.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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.