diff --git a/dev_requirements.txt b/dev_requirements.txt index fe2f0e1..91a0968 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,3 +1,3 @@ -coverage==3.7.1 +coverage==4.0.3 nose==1.3.7 mock==1.3.0 diff --git a/pygen/parsers/__init__.py b/pygen/parsers/__init__.py index daedba4..0e130b6 100644 --- a/pygen/parsers/__init__.py +++ b/pygen/parsers/__init__.py @@ -6,7 +6,7 @@ import pkg_resources import warnings -from ..exceptions import NoParserError +from .exceptions import NoParserError def get_parser_class_map(): diff --git a/pygen/exceptions.py b/pygen/parsers/exceptions.py similarity index 100% rename from pygen/exceptions.py rename to pygen/parsers/exceptions.py diff --git a/pygen/parsers/json.py b/pygen/parsers/json.py index a4f47dd..8ecb883 100644 --- a/pygen/parsers/json.py +++ b/pygen/parsers/json.py @@ -2,6 +2,8 @@ A JSON file parser. """ +from __future__ import absolute_import + import json from .base import BaseParser diff --git a/pygen/parsers/yaml.py b/pygen/parsers/yaml.py index b82ce68..5b1d5ec 100644 --- a/pygen/parsers/yaml.py +++ b/pygen/parsers/yaml.py @@ -2,8 +2,12 @@ A YAML file parser. """ +from __future__ import absolute_import + import yaml +from six import StringIO + from .base import BaseParser @@ -14,4 +18,4 @@ def load(self, file): return yaml.load(file) def loads(self, s): - return yaml.loads(s) + return yaml.load(StringIO(s)) diff --git a/pygen/scope/__init__.py b/pygen/scope/__init__.py index 1093c0f..cb0e365 100644 --- a/pygen/scope/__init__.py +++ b/pygen/scope/__init__.py @@ -14,10 +14,18 @@ def parse_scope(value): :param value: The dotted path string. :returns: A `Scope` instance if `value` is a valid path. """ + if not value: + return Scope() + if not re.match('^[\w\d_-]+(\.[\w\d_-]+)*$', value): raise InvalidScope(value) - return Scope(scope=value.split('.')) + scope = [ + int(x) if re.match('^\d+$', x) else x + for x in value.split('.') + ] + + return Scope(scope=scope) class Scope(object): @@ -30,7 +38,16 @@ def __init__(self, scope=None): :param scope: A list of path components. """ - self.scope = scope or [] + self.scope = list(scope or []) + + def __eq__(self, other): + if not isinstance(other, Scope): + return NotImplemented + + return other.scope == self.scope + + def __ne__(self, other): + return not self == other def __repr__(self): return 'Scope(%r)' % self.scope diff --git a/pygen/scripts.py b/pygen/scripts.py index 3582fd2..819bcf8 100644 --- a/pygen/scripts.py +++ b/pygen/scripts.py @@ -4,6 +4,7 @@ import click import os +import yaml from .parsers import parse_file from .templates import TemplatesManager @@ -13,10 +14,6 @@ def hl(obj): return click.style(str(obj), fg='yellow', bold=True) -def identifier(obj): - return click.style(str(obj), fg='cyan', bold=True) - - def pinfo(msg, *args, **kwargs): click.secho(str(msg).format(*args, **kwargs), fg='white') @@ -25,10 +22,6 @@ def pdebug(msg, *args, **kwargs): click.secho(str(msg).format(*args, **kwargs), fg='black', bold=True) -def perror(msg, *args, **kwargs): - click.secho(str(msg).format(*args, **kwargs), fg='red', bold=True) - - @click.command(help="Generate code from a definition file.") @click.option( '-d', @@ -76,7 +69,7 @@ def pygen(debug, output_root, targets, templates_root, definition_file): if debug: pdebug( "Definition file is as follow:\n{definition}", - definition=definition, + definition=yaml.dump(definition), ) pinfo( @@ -89,13 +82,13 @@ def pygen(debug, output_root, targets, templates_root, definition_file): try: os.makedirs(output_root) - except IOError: + except OSError: pass if not targets: targets = templates_manager.default_targets - for index, target_name in enumerate(targets): + for index, target_name in enumerate(sorted(targets)): progress = float(index) / len(targets) pinfo( "[{progress:3d}%] Generating target `{target_name}`.", @@ -113,11 +106,11 @@ def pygen(debug, output_root, targets, templates_root, definition_file): ) pinfo( "Writing {output_file_name}.", - output_file_name=hl(output_file_name), + output_file_name=hl(output_file_name.replace('\\', '/')), ) with open(output_file_name, 'w') as destination_file: - destination_file.write(content) + destination_file.write(content) # pragma: no branch pinfo("[100%] Done.") diff --git a/pygen/templates.py b/pygen/templates.py index d4d47c7..bf83de7 100644 --- a/pygen/templates.py +++ b/pygen/templates.py @@ -50,7 +50,11 @@ def default_targets(self): targets = self.index['default_targets'] if targets: - return targets + return { + target: value + for target, value in self.targets.items() + if target in targets + } else: return self.targets diff --git a/setup.py b/setup.py index e5f0fb5..2400961 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,8 @@ 'tests', ]), install_requires=[ - 'six==1.9.0', + 'six==1.10.0', + 'future==0.15.2', 'Jinja2==2.8', 'click==6.2', 'PyYAML==3.11', diff --git a/tests/test_parsers/__init__.py b/tests/test_parsers/__init__.py new file mode 100644 index 0000000..823a1a4 --- /dev/null +++ b/tests/test_parsers/__init__.py @@ -0,0 +1,128 @@ +""" +Unit tests for the parsers utility functions. +""" + +import warnings + +from mock import ( + MagicMock, + patch, +) +from unittest import TestCase + +from pygen.parsers import ( + get_parser_class_map, + get_parser_from_file, + get_parser_from_file_name, + parse_file, +) +from pygen.parsers.exceptions import NoParserError + + +class ParsersTests(TestCase): + @patch('pygen.parsers.pkg_resources.iter_entry_points') + def test_get_parser_class_map_import_error(self, iter_entry_points): + entry_point = MagicMock() + entry_point.load.side_effect = ImportError + iter_entry_points.return_value = [entry_point] + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + result = get_parser_class_map() + + self.assertEqual({}, result) + + @patch('pygen.parsers.pkg_resources.iter_entry_points') + def test_get_parser_class_map_registered_twice(self, iter_entry_points): + class_ = MagicMock() + class_.extensions = ['.foo'] + entry_point = MagicMock() + entry_point.load.return_value = class_ + + iter_entry_points.return_value = [ + entry_point, + entry_point, + ] + + result = get_parser_class_map() + + self.assertEqual( + { + '.foo': class_, + }, + result, + ) + + @patch('pygen.parsers.pkg_resources.iter_entry_points') + def test_get_parser_class_map_already_registered(self, iter_entry_points): + def get_entry_point_for_extensions(extensions): + class_ = MagicMock() + class_.extensions = extensions + entry_point = MagicMock() + entry_point.load.return_value = class_ + return entry_point + + iter_entry_points.return_value = [ + get_entry_point_for_extensions(['.foo']), + get_entry_point_for_extensions(['.bar', '.foo']), + ] + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + result = get_parser_class_map() + + self.assertEqual( + { + '.foo': iter_entry_points.return_value[0].load.return_value, + '.bar': iter_entry_points.return_value[1].load.return_value, + }, + result, + ) + + @patch('pygen.parsers.parser_class_map') + def test_get_parser_from_file_name_no_parser_error(self, parser_class_map): + parser_class_map.get.return_value = None + + with self.assertRaises(NoParserError) as error: + get_parser_from_file_name('a.foo') + + parser_class_map.get.assert_called_once_with('.foo') + self.assertEqual('a.foo', error.exception.filename) + self.assertEqual(set(), error.exception.extensions) + + @patch('pygen.parsers.parser_class_map') + def test_get_parser_from_file_name(self, parser_class_map): + class_ = MagicMock() + parser_class_map.get.return_value = class_ + + result = get_parser_from_file_name('a.foo') + + parser_class_map.get.assert_called_once_with('.foo') + self.assertEqual(class_(), result) + + @patch('pygen.parsers.parser_class_map') + def test_get_parser_from_file(self, parser_class_map): + class_ = MagicMock() + parser_class_map.get.return_value = class_ + file = MagicMock() + file.name = 'a.foo' + + result = get_parser_from_file(file) + + parser_class_map.get.assert_called_once_with('.foo') + self.assertEqual(class_(), result) + + @patch('pygen.parsers.parser_class_map') + def test_parse_file(self, parser_class_map): + content = 'my_content' + class_ = MagicMock() + class_().load.return_value = content + parser_class_map.get.return_value = class_ + file = MagicMock() + file.name = 'a.foo' + + result = parse_file(file) + + parser_class_map.get.assert_called_once_with('.foo') + class_().load.assert_called_once_with(file) + self.assertEqual('my_content', result) diff --git a/tests/test_parsers/test_json.py b/tests/test_parsers/test_json.py new file mode 100644 index 0000000..b908917 --- /dev/null +++ b/tests/test_parsers/test_json.py @@ -0,0 +1,22 @@ +""" +Tests for the JSON parser. +""" + +from unittest import TestCase + +from pygen.parsers.json import JsonParser +from six import StringIO + + +class JsonParserTests(TestCase): + def setUp(self): + self.parser = JsonParser() + + def test_load(self): + file = StringIO('{"a": 1}') + result = self.parser.load(file=file) + self.assertEqual({'a': 1}, result) + + def test_loads(self): + result = self.parser.loads('{"a": 1}') + self.assertEqual({'a': 1}, result) diff --git a/tests/test_parsers/test_yaml.py b/tests/test_parsers/test_yaml.py new file mode 100644 index 0000000..9f07927 --- /dev/null +++ b/tests/test_parsers/test_yaml.py @@ -0,0 +1,22 @@ +""" +Tests for the YAML parser. +""" + +from unittest import TestCase + +from pygen.parsers.yaml import YamlParser +from six import StringIO + + +class YamlParserTests(TestCase): + def setUp(self): + self.parser = YamlParser() + + def test_load(self): + file = StringIO('a: 1') + result = self.parser.load(file=file) + self.assertEqual({'a': 1}, result) + + def test_loads(self): + result = self.parser.loads('a: 1') + self.assertEqual({'a': 1}, result) diff --git a/tests/test_scope/__init__.py b/tests/test_scope/__init__.py new file mode 100644 index 0000000..6de2414 --- /dev/null +++ b/tests/test_scope/__init__.py @@ -0,0 +1,85 @@ +""" +Tests for the scope utility functions and classes. +""" + +from unittest import TestCase + +from pygen.scope import ( + parse_scope, + Scope, +) +from pygen.scope.exceptions import InvalidScope + + +class ScopeTests(TestCase): + def test_parse_scope_empty(self): + scope = parse_scope('') + self.assertEqual(Scope(scope=[]), scope) + + def test_parse_scope_single_identifier(self): + scope = parse_scope('foo') + self.assertEqual(Scope(scope=['foo']), scope) + + def test_parse_scope_multiple_identifiers(self): + scope = parse_scope('foo.bar') + self.assertEqual(Scope(scope=['foo', 'bar']), scope) + + def test_parse_scope_single_number(self): + scope = parse_scope('2') + self.assertEqual(Scope(scope=[2]), scope) + + def test_parse_scope_multiple_identifiers_and_numbers(self): + scope = parse_scope('foo.3') + self.assertEqual(Scope(scope=['foo', 3]), scope) + + def test_parse_scope_invalid(self): + with self.assertRaises(InvalidScope) as error: + parse_scope('foo.') + + self.assertEqual('foo.', error.exception.scope) + + def test_scope_default_initialization(self): + scope = Scope() + self.assertEqual([], scope.scope) + + def test_scope_list_initialization(self): + scope = Scope(('a', 'b')) + self.assertEqual(['a', 'b'], scope.scope) + + def test_scope_comparison(self): + scope_a = Scope(['a', 'b']) + scope_b = Scope(['a', 'b']) + scope_c = Scope(['a', 'c']) + self.assertTrue(scope_a == scope_b) + self.assertFalse(scope_a != scope_b) + self.assertFalse(scope_a == scope_c) + self.assertTrue(scope_a != scope_c) + self.assertFalse(scope_a == 1) + + def test_scope_representation(self): + scope = Scope(['a', 'b']) + self.assertEqual("Scope(['a', 'b'])", repr(scope)) + + def test_scope_resolve_empty(self): + context = {'a': 1, 'b': 2} + scope = parse_scope('') + result = scope.resolve(context) + self.assertEqual(context, result) + + def test_scope_resolve_single_identifier(self): + context = {'a': 1, 'b': 2} + scope = parse_scope('a') + result = scope.resolve(context) + self.assertEqual(context['a'], result) + + def test_scope_resolve_multiple_identifiers(self): + context = {'a': {'c': 3}, 'b': 2} + scope = parse_scope('a.c') + result = scope.resolve(context) + self.assertEqual(context['a']['c'], result) + + def test_scope_resolve_multiple_identifiers_and_numbers(self): + context = {'a': [{'c': 3}], 'b': 2} + scope = parse_scope('a.0.c') + result = scope.resolve(context) + self.assertEqual(context['a'][0]['c'], result) diff --git a/tests/test_scripts.py b/tests/test_scripts.py new file mode 100644 index 0000000..07cc8f3 --- /dev/null +++ b/tests/test_scripts.py @@ -0,0 +1,216 @@ +""" +Unit tests for the scripts. +""" + +import os +import yaml + +from click.testing import CliRunner +from unittest import TestCase + +from pygen.scripts import pygen + + +class ScriptsTests(TestCase): + index = { + 'targets': { + 'fruits': { + 'source': 'fruit.html', + 'destination': '{{ fruit.name }}.html', + 'scope': 'fruits', + 'alias': 'fruit', + }, + 'error': { + 'source': 'fruit.html', + 'destination': '{{ fruit.name }}.html', + 'scope': 'fruits', + } + }, + } + definition = { + 'fruits': [ + { + 'name': 'apple', + }, + { + 'name': 'pear', + }, + ] + } + templates = { + 'fruit.html': '

{{ fruit.name }}

', + } + + def setUp(self): + self.runner = CliRunner() + + def test_pygen_no_args(self): + result = self.runner.invoke(pygen) + self.assertEqual(2, result.exit_code) + self.assertEqual(''' +Usage: pygen [OPTIONS] TEMPLATES_ROOT DEFINITION_FILE + +Error: Missing argument "templates-root". +'''.lstrip(), + result.output, + ) + + def test_pygen_error(self): + with self.runner.isolated_filesystem(): + os.makedirs('templates') + + with open( + os.path.join('templates', 'index.yml'), + 'w', + ) as index_file: + yaml.dump(self.index, index_file) + + for template, content in self.templates.items(): + with open( + os.path.join('templates', template), + 'w', + ) as template_file: + template_file.write(content) + + with open('definition.yml', 'w') as definition_file: + yaml.dump(self.definition, definition_file) + + result = self.runner.invoke( + pygen, + ['templates', 'definition.yml'], + ) + + self.assertEqual(1, result.exit_code) + self.assertEqual(r''' +Successfully loaded definition file at definition.yml. +Loading templates from: templates. +Output root is at: output +[ 0%] Generating target `error`. +Error: 'fruit' is undefined +'''.lstrip(), + result.output, + ) + + def test_pygen_error_debug(self): + with self.runner.isolated_filesystem(): + os.makedirs('templates') + + with open( + os.path.join('templates', 'index.yml'), + 'w', + ) as index_file: + yaml.dump(self.index, index_file) + + for template, content in self.templates.items(): + with open( + os.path.join('templates', template), + 'w', + ) as template_file: + template_file.write(content) + + with open('definition.yml', 'w') as definition_file: + yaml.dump(self.definition, definition_file) + + result = self.runner.invoke( + pygen, + ['-d', 'templates', 'definition.yml'], + ) + + self.assertEqual(-1, result.exit_code) + self.assertEqual(r''' +Parsing definition file: definition.yml. +Successfully loaded definition file at definition.yml. +Definition file is as follow: +fruits: +- {name: apple} +- {name: pear} + +Loading templates from: templates. +Output root is at: output +[ 0%] Generating target `error`. +'''.lstrip(), + result.output, + ) + + def test_pygen_simple(self): + with self.runner.isolated_filesystem(): + os.makedirs('templates') + + with open( + os.path.join('templates', 'index.yml'), + 'w', + ) as index_file: + yaml.dump(self.index, index_file) + + for template, content in self.templates.items(): + with open( + os.path.join('templates', template), + 'w', + ) as template_file: + template_file.write(content) + + with open('definition.yml', 'w') as definition_file: + yaml.dump(self.definition, definition_file) + + result = self.runner.invoke( + pygen, + ['-t', 'fruits', 'templates', 'definition.yml'], + ) + + self.assertEqual(0, result.exit_code) + self.assertEqual(r''' +Successfully loaded definition file at definition.yml. +Loading templates from: templates. +Output root is at: output +[ 0%] Generating target `fruits`. +Writing output/apple.html. +Writing output/pear.html. +[100%] Done. +'''.lstrip(), + result.output, + ) + + def test_pygen_simple_debug(self): + with self.runner.isolated_filesystem(): + os.makedirs('templates') + os.makedirs('output') + + with open( + os.path.join('templates', 'index.yml'), + 'w', + ) as index_file: + yaml.dump(self.index, index_file) + + for template, content in self.templates.items(): + with open( + os.path.join('templates', template), + 'w', + ) as template_file: + template_file.write(content) + + with open('definition.yml', 'w') as definition_file: + yaml.dump(self.definition, definition_file) + + result = self.runner.invoke( + pygen, + ['-d', '-t', 'fruits', 'templates', 'definition.yml'], + ) + + self.assertEqual(0, result.exit_code) + self.assertEqual(r''' +Parsing definition file: definition.yml. +Successfully loaded definition file at definition.yml. +Definition file is as follow: +fruits: +- {name: apple} +- {name: pear} + +Loading templates from: templates. +Output root is at: output +[ 0%] Generating target `fruits`. +Writing output/apple.html. +Writing output/pear.html. +[100%] Done. +'''.lstrip(), + result.output, + ) diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..8bb5fd7 --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,123 @@ +""" +Unit tests for templates. +""" + +import yaml + +from jinja2 import DictLoader +from mock import ( + patch, + mock_open, +) +from unittest import TestCase + +from pygen.templates import TemplatesManager + + +class TemplatesTests(TestCase): + definition = { + 'fruits': [ + { + 'name': 'apple', + }, + { + 'name': 'pear', + }, + ], + } + index = yaml.dump({ + 'targets': { + 'apple': { + 'source': 'apple.html', + 'destination': '{{ catalog.fruits[0].name }}.html', + 'alias': 'catalog', + }, + }, + }) + index_default_targets = yaml.dump({ + 'targets': { + 'apple': { + 'source': 'apple.html', + 'destination': '{{ catalog.fruits[0].name }}.html', + 'alias': 'catalog', + }, + 'pear': { + 'source': 'pear.html', + 'destination': '{{ name }}.html', + 'scope': 'fruits.1', + }, + 'fruits': { + 'source': 'fruit.html', + 'destination': '{{ fruit.name }}.html', + 'scope': 'fruits', + 'alias': 'fruit', + } + }, + 'default_targets': ['apple', 'mango'], + }) + templates = { + 'apple.html': '

{{ catalog.fruits[0].name }}

', + 'pear.html': '

{{ name }}

', + 'fruit.html': '

{{ fruit.name }}

', + } + + def setUp(self): + patcher = patch( + 'pygen.templates.FileSystemLoader', + return_value=DictLoader(self.templates), + ) + patcher.start() + self.addCleanup(patcher.stop) + + @patch('pygen.templates.open', mock_open(read_data='{}')) + def test_templates_manager_default_initialization(self): + manager = TemplatesManager(root='/foo') + self.assertEqual({}, manager.targets) + self.assertEqual({}, manager.default_targets) + + @patch('pygen.templates.open', mock_open(read_data=index)) + def test_templates_manager_initialization(self): + manager = TemplatesManager(root='/foo') + self.assertEqual({'apple'}, set(manager.targets)) + self.assertEqual({'apple'}, set(manager.default_targets)) + + @patch('pygen.templates.open', mock_open(read_data=index_default_targets)) + def test_templates_manager_initialization_with_default_targets(self): + manager = TemplatesManager(root='/foo') + self.assertEqual({'apple', 'pear', 'fruits'}, set(manager.targets)) + self.assertEqual({'apple'}, set(manager.default_targets)) + + @patch('pygen.templates.open', mock_open(read_data=index_default_targets)) + def test_templates_manager_render(self): + manager = TemplatesManager(root='/foo') + + results = manager.render('apple', self.definition) + + file_name, content = next(results) + self.assertEqual('apple.html', file_name) + self.assertEqual('

apple

', content) + + with self.assertRaises(StopIteration): + next(results) + + results = manager.render('pear', self.definition) + + file_name, content = next(results) + self.assertEqual('pear.html', file_name) + self.assertEqual('

pear

', content) + + with self.assertRaises(StopIteration): + next(results) + + results = manager.render('fruits', self.definition) + + file_name, content = next(results) + self.assertEqual('apple.html', file_name) + self.assertEqual('

apple

', content) + + file_name, content = next(results) + self.assertEqual('pear.html', file_name) + self.assertEqual('

pear

', content) + + with self.assertRaises(StopIteration): + next(results) diff --git a/tox.ini b/tox.ini index 2c45445..e03b11f 100644 --- a/tox.ini +++ b/tox.ini @@ -9,13 +9,13 @@ envlist = [testenv:coverage-erase] deps = - coverage==3.7.1 + coverage==4.0.3 commands = coverage erase [testenv:coverage-report] deps = - coverage==3.7.1 + coverage==4.0.3 commands = coverage combine coverage report --fail-under=100 -m