From 4c94ee62db28446450335f697130b113d08cd1df Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Thu, 27 Apr 2017 07:51:36 +0100 Subject: [PATCH 1/3] Python 3 support --- .gitignore | 1 + source/lucidity/__init__.py | 150 +++++++--------------------------- source/lucidity/template.py | 157 ++++++++++++++++++++++++++++++++---- test/unit/test_lucidity.py | 6 +- test/unit/test_template.py | 15 ++-- 5 files changed, 181 insertions(+), 148 deletions(-) diff --git a/.gitignore b/.gitignore index 75f5aa9..abf6a80 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ pip-log.txt # Unit test / coverage reports .coverage +.cache # Translations *.mo diff --git a/source/lucidity/__init__.py b/source/lucidity/__init__.py index 74d1cfe..6e8d358 100644 --- a/source/lucidity/__init__.py +++ b/source/lucidity/__init__.py @@ -2,127 +2,31 @@ # :copyright: Copyright (c) 2013 Martin Pengelly-Phillips # :license: See LICENSE.txt. -import os -import uuid -import imp - from ._version import __version__ -from .template import Template, Resolver -from .error import ParseError, FormatError, NotFound - - -def discover_templates(paths=None, recursive=True): - '''Search *paths* for mount points and load templates from them. - - *paths* should be a list of filesystem paths to search for mount points. - If not specified will try to use value from environment variable - :envvar:`LUCIDITY_TEMPLATE_PATH`. - - A mount point is a Python file that defines a 'register' function. The - function should return a list of instantiated - :py:class:`~lucidity.template.Template` objects. - - If *recursive* is True (the default) then all directories under a path - will also be searched. - - ''' - templates = [] - - if paths is None: - paths = os.environ.get('LUCIDITY_TEMPLATE_PATH', '').split(os.pathsep) - - for path in paths: - for base, directories, filenames in os.walk(path): - for filename in filenames: - _, extension = os.path.splitext(filename) - if extension != '.py': - continue - - module_path = os.path.join(base, filename) - module_name = uuid.uuid4().hex - module = imp.load_source(module_name, module_path) - try: - registered = module.register() - except AttributeError: - pass - else: - if registered: - templates.extend(registered) - - if not recursive: - del directories[:] - - return templates - - -def parse(path, templates): - '''Parse *path* against *templates*. - - *path* should be a string to parse. - - *templates* should be a list of :py:class:`~lucidity.template.Template` - instances in the order that they should be tried. - - Return ``(data, template)`` from first successful parse. - - Raise :py:class:`~lucidity.error.ParseError` if *path* is not - parseable by any of the supplied *templates*. - - ''' - for template in templates: - try: - data = template.parse(path) - except ParseError: - continue - else: - return (data, template) - - raise ParseError( - 'Path {0!r} did not match any of the supplied template patterns.' - .format(path) - ) - - -def format(data, templates): # @ReservedAssignment - '''Format *data* using *templates*. - - *data* should be a dictionary of data to format into a path. - - *templates* should be a list of :py:class:`~lucidity.template.Template` - instances in the order that they should be tried. - - Return ``(path, template)`` from first successful format. - - Raise :py:class:`~lucidity.error.FormatError` if *data* is not - formattable by any of the supplied *templates*. - - - ''' - for template in templates: - try: - path = template.format(data) - except FormatError: - continue - else: - return (path, template) - - raise FormatError( - 'Data {0!r} was not formattable by any of the supplied templates.' - .format(data) - ) - - -def get_template(name, templates): - '''Retrieve a template from *templates* by *name*. - - Raise :py:exc:`~lucidity.error.NotFound` if no matching template with - *name* found in *templates*. - - ''' - for template in templates: - if template.name == name: - return template - - raise NotFound( - '{0} template not found in specified templates.'.format(name) - ) +from .error import ( + ParseError, + FormatError, + NotFound +) + +from .template import ( + Template, + Resolver, + discover_templates, + parse, + format, + get_template +) + +__all__ = [ + "__version__", + "ParseError", + "FormatError", + "NotFound", + "Template", + "Resolver", + "discover_templates", + "parse", + "format", + "get_template", +] diff --git a/source/lucidity/template.py b/source/lucidity/template.py index 1393803..ccc0d78 100644 --- a/source/lucidity/template.py +++ b/source/lucidity/template.py @@ -2,13 +2,16 @@ # :copyright: Copyright (c) 2013 Martin Pengelly-Phillips # :license: See LICENSE.txt. +import os +import re +import imp import abc import sys -import re +import uuid import functools from collections import defaultdict -import lucidity.error +from . import error # Type of a RegexObject for isinstance check. _RegexType = type(re.compile('')) @@ -42,7 +45,7 @@ def __init__(self, name, pattern, anchor=ANCHOR_START, be handled during parsing. :attr:`~Template.RELAXED` mode extracts the last matching value without checking the other values. :attr:`~Template.STRICT` mode ensures that all duplicate placeholders - extract the same value and raises :exc:`~lucidity.error.ParseError` if + extract the same value and raises :exc:`~error.ParseError` if they do not. If *template_resolver* is supplied, use it to resolve any template @@ -84,8 +87,8 @@ def pattern(self): def expanded_pattern(self): '''Return pattern with all referenced templates expanded recursively. - Raise :exc:`lucidity.error.ResolveError` if pattern contains a reference - that cannot be resolved by currently set template_resolver. + Raise :exc:`error.ResolveError` if pattern contains a + reference that cannot be resolved by currently set template_resolver. ''' return self._TEMPLATE_REFERENCE_REGEX.sub( @@ -97,14 +100,15 @@ def _expand_reference(self, match): reference = match.group('reference') if self.template_resolver is None: - raise lucidity.error.ResolveError( - 'Failed to resolve reference {0!r} as no template resolver set.' + raise error.ResolveError( + 'Failed to resolve reference {0!r} as no template ' + 'resolver set.' .format(reference) ) template = self.template_resolver.get(reference) if template is None: - raise lucidity.error.ResolveError( + raise error.ResolveError( 'Failed to resolve reference {0!r} using template resolver.' .format(reference) ) @@ -114,7 +118,7 @@ def _expand_reference(self, match): def parse(self, path): '''Return dictionary of data extracted from *path* using this template. - Raise :py:class:`~lucidity.error.ParseError` if *path* is not + Raise :py:class:`~error.ParseError` if *path* is not parsable by this template. ''' @@ -131,12 +135,13 @@ def parse(self, path): # Strip number that was added to make group name unique. key = key[:-3] - # If strict mode enabled for duplicate placeholders, ensure that - # all duplicate placeholders extract the same value. + # If strict mode enabled for duplicate placeholders, + # ensure that all duplicate placeholders extract the + # same value. if self.duplicate_placeholder_mode == self.STRICT: if key in parsed: if parsed[key] != value: - raise lucidity.error.ParseError( + raise error.ParseError( 'Different extracted values for placeholder ' '{0!r} detected. Values were {1!r} and {2!r}.' .format(key, parsed[key], value) @@ -156,14 +161,14 @@ def parse(self, path): return data else: - raise lucidity.error.ParseError( + raise error.ParseError( 'Path {0!r} did not match template pattern.'.format(path) ) def format(self, data): '''Return a path formatted by applying *data* to this template. - Raise :py:class:`~lucidity.error.FormatError` if *data* does not + Raise :py:class:`~error.FormatError` if *data* does not supply enough information to fill the template fields. ''' @@ -188,7 +193,7 @@ def _format(self, match, data): value = value[part] except (TypeError, KeyError): - raise lucidity.error.FormatError( + raise error.FormatError( 'Could not format data {0!r} due to missing key {1!r}.' .format(data, placeholder) ) @@ -208,7 +213,8 @@ def references(self): format_specification = self._construct_format_specification( self.pattern ) - return set(self._TEMPLATE_REFERENCE_REGEX.findall(format_specification)) + return set(self._TEMPLATE_REFERENCE_REGEX.findall( + format_specification)) def _construct_format_specification(self, pattern): '''Return format specification from *pattern*.''' @@ -252,7 +258,7 @@ def _construct_regular_expression(self, pattern): else: _, value, traceback = sys.exc_info() message = 'Invalid pattern: {0}'.format(value) - raise ValueError, message, traceback #@IgnorePep8 + raise ValueError(message, traceback) return compiled @@ -324,3 +330,120 @@ def __subclasshook__(cls, subclass): return callable(getattr(subclass, 'get', None)) return NotImplemented + + +def discover_templates(paths=None, recursive=True): + '''Search *paths* for mount points and load templates from them. + + *paths* should be a list of filesystem paths to search for mount points. + If not specified will try to use value from environment variable + :envvar:`LUCIDITY_TEMPLATE_PATH`. + + A mount point is a Python file that defines a 'register' function. The + function should return a list of instantiated + :py:class:`~lucidity.template.Template` objects. + + If *recursive* is True (the default) then all directories under a path + will also be searched. + + ''' + templates = [] + + if paths is None: + paths = os.environ.get('LUCIDITY_TEMPLATE_PATH', '').split(os.pathsep) + + for path in paths: + for base, directories, filenames in os.walk(path): + for filename in filenames: + _, extension = os.path.splitext(filename) + if extension != '.py': + continue + + module_path = os.path.join(base, filename) + module_name = uuid.uuid4().hex + module = imp.load_source(module_name, module_path) + try: + registered = module.register() + except AttributeError: + pass + else: + if registered: + templates.extend(registered) + + if not recursive: + del directories[:] + + return templates + + +def parse(path, templates): + '''Parse *path* against *templates*. + + *path* should be a string to parse. + + *templates* should be a list of :py:class:`~lucidity.template.Template` + instances in the order that they should be tried. + + Return ``(data, template)`` from first successful parse. + + Raise :py:class:`~error.ParseError` if *path* is not + parseable by any of the supplied *templates*. + + ''' + for template in templates: + try: + data = template.parse(path) + except error.ParseError: + continue + else: + return (data, template) + + raise error.ParseError( + 'Path {0!r} did not match any of the supplied template patterns.' + .format(path) + ) + + +def format(data, templates): # @ReservedAssignment + '''Format *data* using *templates*. + + *data* should be a dictionary of data to format into a path. + + *templates* should be a list of :py:class:`~lucidity.template.Template` + instances in the order that they should be tried. + + Return ``(path, template)`` from first successful format. + + Raise :py:class:`~error.FormatError` if *data* is not + formattable by any of the supplied *templates*. + + + ''' + for template in templates: + try: + path = template.format(data) + except error.FormatError: + continue + else: + return (path, template) + + raise error.FormatError( + 'Data {0!r} was not formattable by any of the supplied templates.' + .format(data) + ) + + +def get_template(name, templates): + '''Retrieve a template from *templates* by *name*. + + Raise :py:exc:`~error.NotFound` if no matching template with + *name* found in *templates*. + + ''' + for template in templates: + if template.name == name: + return template + + raise error.NotFound( + '{0} template not found in specified templates.'.format(name) + ) diff --git a/test/unit/test_lucidity.py b/test/unit/test_lucidity.py index 2bb9ea6..843cad1 100644 --- a/test/unit/test_lucidity.py +++ b/test/unit/test_lucidity.py @@ -12,7 +12,7 @@ TEST_TEMPLATE_PATH = os.path.join( os.path.dirname(__file__), '..', 'fixture', 'template' -) +) @pytest.fixture @@ -36,7 +36,7 @@ def test_discover(recursive, expected): templates = lucidity.discover_templates( [TEST_TEMPLATE_PATH], recursive=recursive ) - assert map(operator.attrgetter('name'), templates) == expected + assert [template.name for template in templates] == expected @pytest.mark.parametrize(('path', 'expected'), [ @@ -50,7 +50,7 @@ def test_discover_with_env(path, expected, monkeypatch): '''Discover templates using environment variable.''' monkeypatch.setenv('LUCIDITY_TEMPLATE_PATH', path) templates = lucidity.discover_templates() - assert map(operator.attrgetter('name'), templates) == expected + assert [template.name for template in templates] == expected @pytest.mark.parametrize(('path', 'expected'), [ diff --git a/test/unit/test_template.py b/test/unit/test_template.py index d7139e4..c9c6451 100644 --- a/test/unit/test_template.py +++ b/test/unit/test_template.py @@ -119,7 +119,7 @@ def test_non_matching_parse(pattern, path, template_resolver): '''Extract data from non-matching path.''' template = Template('test', pattern, template_resolver=template_resolver) with pytest.raises(ParseError): - data = template.parse(path) + template.parse(path) @pytest.mark.parametrize(('pattern', 'path', 'expected'), [ @@ -137,7 +137,10 @@ def test_non_matching_parse(pattern, path, template_resolver): 'multiple duplicates', 'duplicate from reference' ]) -def test_valid_parse_in_strict_mode(pattern, path, expected, template_resolver): +def test_valid_parse_in_strict_mode(pattern, + path, + expected, + template_resolver): '''Extract data in strict mode when no invalid duplicates detected.''' template = Template( 'test', pattern, duplicate_placeholder_mode=Template.STRICT, @@ -258,8 +261,10 @@ def test_format_failure(pattern, data, template_resolver): def test_repr(): '''Represent template.''' - assert (repr(Template('test', '/foo/{bar}/{baz:\d+}')) - == 'Template(name=\'test\', pattern=\'/foo/{bar}/{baz:\\\d+}\')') + assert ( + repr(Template('test', '/foo/{bar}/{baz:\d+}')) == + 'Template(name=\'test\', pattern=\'/foo/{bar}/{baz:\\\d+}\')' + ) def test_escaping_pattern(): @@ -337,7 +342,7 @@ def test_references(pattern, expected): ([], False), ], ids=[ 'subclass', - 'compliant non-subclass', + # 'compliant non-subclass', 'non-compliant non-subclass', ]) def test_resolver_interface_check(instance, expected): From c2a1450676cb63ed6f4099159702523a31c4a53f Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Thu, 27 Apr 2017 08:05:11 +0100 Subject: [PATCH 2/3] Repair Python 2 compatibility --- test/unit/test_template.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/test_template.py b/test/unit/test_template.py index c9c6451..fccac94 100644 --- a/test/unit/test_template.py +++ b/test/unit/test_template.py @@ -342,7 +342,7 @@ def test_references(pattern, expected): ([], False), ], ids=[ 'subclass', - # 'compliant non-subclass', + 'compliant non-subclass', 'non-compliant non-subclass', ]) def test_resolver_interface_check(instance, expected): From 5caa169d6de4c323601804dda7c20ee26c040e7a Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Thu, 27 Apr 2017 08:14:07 +0100 Subject: [PATCH 3/3] Add Travis support --- .travis.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..4865e92 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,17 @@ +language: python + +sudo: required +dist: trusty + +python: + - 2.7 + - 3.6 + +install: + - pip install pytest coveralls + +script: + - PYTHONPATH=$(pwd)/source pytest test + +after_success: + - coveralls \ No newline at end of file