diff --git a/bellybutton/exceptions.py b/bellybutton/exceptions.py new file mode 100644 index 0000000..efcb190 --- /dev/null +++ b/bellybutton/exceptions.py @@ -0,0 +1,6 @@ +"""Custom exceptions.""" +from yaml import YAMLError + + +class InvalidNode(YAMLError): + """Raised when a custom node fails validation.""" diff --git a/bellybutton/parsing.py b/bellybutton/parsing.py index a0d2c1f..7025045 100644 --- a/bellybutton/parsing.py +++ b/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): @@ -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() + ] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..d61d029 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +xfail_strict=true diff --git a/tests/integration/examples/.test.bellybutton.yml b/tests/integration/examples/.test.bellybutton.yml new file mode 100644 index 0000000..a3a9d0f --- /dev/null +++ b/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.""" diff --git a/tests/integration/test_package.py b/tests/integration/test_package.py deleted file mode 100644 index 808db8d..0000000 --- a/tests/integration/test_package.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Tests for the exported bellybutton package.""" - - -def test_importable(): - """Ensure that bellybutton is importable.""" - import bellybutton diff --git a/tests/integration/test_parsing_integration.py b/tests/integration/test_parsing_integration.py new file mode 100644 index 0000000..ccb120b --- /dev/null +++ b/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) diff --git a/tests/unit/test_parsing.py b/tests/unit/test_parsing.py new file mode 100644 index 0000000..5b24734 --- /dev/null +++ b/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 + ) + ) diff --git a/tests/unit/test_yaml.py b/tests/unit/test_yaml.py deleted file mode 100644 index 9f5afe7..0000000 --- a/tests/unit/test_yaml.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Tests for bellybutton/yaml.py.""" - -import pytest - -import yaml - -import bellybutton.parsing - - -@pytest.mark.parametrize('expression', ( - '- !xpath //*', - '- //*', - '- !regex .*', -)) -def test_constructors(expression): - """Ensure custom constructors successfully parse given expressions.""" - yaml.load(expression)