diff --git a/fluent.runtime/fluent/runtime/__init__.py b/fluent.runtime/fluent/runtime/__init__.py index befc1bb0..284b0eb4 100644 --- a/fluent.runtime/fluent/runtime/__init__.py +++ b/fluent.runtime/fluent/runtime/__init__.py @@ -8,8 +8,9 @@ from fluent.syntax.ast import Message, Term from .builtins import BUILTINS -from .resolver import resolve -from .utils import ATTRIBUTE_SEPARATOR, TERM_SIGIL, add_message_and_attrs_to_store, ast_to_id +from .prepare import Compiler +from .resolver import ResolverEnvironment, CurrentEnvironment +from .utils import ATTRIBUTE_SEPARATOR, TERM_SIGIL, ast_to_id, native_to_fluent class FluentBundle(object): @@ -33,6 +34,8 @@ def __init__(self, locales, functions=None, use_isolating=True): self._functions = _functions self._use_isolating = use_isolating self._messages_and_terms = {} + self._compiled = {} + self._compiler = Compiler(use_isolating=use_isolating) self._babel_locale = self._get_babel_locale() self._plural_form = babel.plural.to_python(self._babel_locale.plural_form) @@ -44,22 +47,41 @@ def add_messages(self, source): if isinstance(item, (Message, Term)): full_id = ast_to_id(item) if full_id not in self._messages_and_terms: - # We add attributes to the store to enable faster looker - # later, and more direct code in some instances. - add_message_and_attrs_to_store(self._messages_and_terms, full_id, item) + self._messages_and_terms[full_id] = item def has_message(self, message_id): if message_id.startswith(TERM_SIGIL) or ATTRIBUTE_SEPARATOR in message_id: return False return message_id in self._messages_and_terms + def lookup(self, full_id): + if full_id not in self._compiled: + entry_id = full_id.split(ATTRIBUTE_SEPARATOR, 1)[0] + entry = self._messages_and_terms[entry_id] + compiled = self._compiler(entry) + if compiled.value is not None: + self._compiled[entry_id] = compiled.value + for attr in compiled.attributes: + self._compiled[ATTRIBUTE_SEPARATOR.join([entry_id, attr.id.name])] = attr.value + return self._compiled[full_id] + def format(self, message_id, args=None): if message_id.startswith(TERM_SIGIL): raise LookupError(message_id) - message = self._messages_and_terms[message_id] - if args is None: - args = {} - return resolve(self, message, args) + if args is not None: + fluent_args = { + argname: native_to_fluent(argvalue) + for argname, argvalue in args.items() + } + else: + fluent_args = {} + + errors = [] + resolve = self.lookup(message_id) + env = ResolverEnvironment(context=self, + current=CurrentEnvironment(args=fluent_args), + errors=errors) + return [resolve(env), errors] def _get_babel_locale(self): for l in self.locales: diff --git a/fluent.runtime/fluent/runtime/prepare.py b/fluent.runtime/fluent/runtime/prepare.py new file mode 100644 index 00000000..698a5dd9 --- /dev/null +++ b/fluent.runtime/fluent/runtime/prepare.py @@ -0,0 +1,53 @@ +from __future__ import absolute_import, unicode_literals +from fluent.syntax import ast as FTL +from . import resolver + + +class Compiler(object): + def __init__(self, use_isolating=False): + self.use_isolating = use_isolating + + def __call__(self, item): + if isinstance(item, FTL.BaseNode): + return self.compile(item) + if isinstance(item, (tuple, list)): + return [self(elem) for elem in item] + return item + + def compile(self, node): + nodename = type(node).__name__ + if not hasattr(resolver, nodename): + return node + kwargs = vars(node).copy() + for propname, propvalue in kwargs.items(): + kwargs[propname] = self(propvalue) + handler = getattr(self, 'compile_' + nodename, self.compile_generic) + return handler(nodename, **kwargs) + + def compile_generic(self, nodename, **kwargs): + return getattr(resolver, nodename)(**kwargs) + + def compile_Placeable(self, _, expression, **kwargs): + if self.use_isolating: + return resolver.IsolatingPlaceable(expression=expression, **kwargs) + if isinstance(expression, resolver.Literal): + return expression + return resolver.Placeable(expression=expression, **kwargs) + + def compile_Pattern(self, _, elements, **kwargs): + if ( + len(elements) == 1 and + isinstance(elements[0], resolver.IsolatingPlaceable) + ): + # Don't isolate isolated placeables + return elements[0].expression + if any( + not isinstance(child, resolver.Literal) + for child in elements + ): + return resolver.Pattern(elements=elements, **kwargs) + if len(elements) == 1: + return elements[0] + return resolver.TextElement( + ''.join(child(None) for child in elements) + ) diff --git a/fluent.runtime/fluent/runtime/resolver.py b/fluent.runtime/fluent/runtime/resolver.py index 07e03e40..1b41e450 100644 --- a/fluent.runtime/fluent/runtime/resolver.py +++ b/fluent.runtime/fluent/runtime/resolver.py @@ -7,34 +7,30 @@ import attr import six -from fluent.syntax.ast import (Attribute, AttributeExpression, CallExpression, Identifier, Message, MessageReference, - NumberLiteral, Pattern, Placeable, SelectExpression, StringLiteral, Term, TermReference, - TextElement, VariableReference, VariantExpression, VariantList) - +from fluent.syntax import ast as FTL from .errors import FluentCyclicReferenceError, FluentFormatError, FluentReferenceError -from .types import FluentDateType, FluentNone, FluentNumber, fluent_date, fluent_number -from .utils import numeric_to_native, reference_to_id, unknown_reference_error_obj +from .types import FluentType, FluentNone, FluentInt, FluentFloat +from .utils import reference_to_id, unknown_reference_error_obj + -try: - from functools import singledispatch -except ImportError: - # Python < 3.4 - from singledispatch import singledispatch +""" +The classes in this module are used to transform the source +AST to a partially evaluated resolver tree. They're subclasses +to the syntax AST node, and `BaseResolver`. Syntax nodes that +don't require special handling, but have children that need to be +transformed, need to just inherit from their syntax base class and +`BaseResolver`. When adding to the module namespace here, watch +out for naming conflicts with `fluent.syntax.ast`. +`ResolverEnvironment` is the `env` passed to the `__call__` method +in the resolver tree. The `CurrentEnvironment` keeps track of the +modifyable state in the resolver environment. +""" -text_type = six.text_type # Prevent expansion of too long placeables, for memory DOS protection MAX_PART_LENGTH = 2500 -# Prevent messages with too many sub parts, for CPI DOS protection -MAX_PARTS = 1000 - - -# Unicode bidi isolation characters. -FSI = "\u2068" -PDI = "\u2069" - @attr.s class CurrentEnvironment(object): @@ -57,7 +53,6 @@ class CurrentEnvironment(object): class ResolverEnvironment(object): context = attr.ib() errors = attr.ib() - dirty = attr.ib(factory=set) part_count = attr.ib(default=0) current = attr.ib(factory=CurrentEnvironment) @@ -79,123 +74,116 @@ def modified_for_term_reference(self, args=None): error_for_missing_arg=False) -def resolve(context, message, args): +class BaseResolver(object): """ - Given a FluentBundle, a Message instance and some arguments, - resolve the message to a string. + Abstract base class of all partially evaluated resolvers. - This is the normal entry point for this module. + Subclasses should implement __call__, with a + ResolverEnvironment as parameter. An exception are wrapper + classes that don't show up in the evaluation, but need to + be part of the compiled tree structure. """ - errors = [] - env = ResolverEnvironment(context=context, - current=CurrentEnvironment(args=args), - errors=errors) - return fully_resolve(message, env), errors + def __call__(self, env): + raise NotImplementedError -def fully_resolve(expr, env): - """ - Fully resolve an expression to a string - """ - # This differs from 'handle' in that 'handle' will often return non-string - # objects, even if a string could have been returned, to allow for further - # handling of that object e.g. attributes of messages. fully_resolve is - # only used when we must have a string. - retval = handle(expr, env) - if isinstance(retval, text_type): - return retval +class Literal(BaseResolver): + pass - return fully_resolve(retval, env) +class Message(FTL.Message, BaseResolver): + pass -@singledispatch -def handle(expr, env): - raise NotImplementedError("Cannot handle object of type {0}" - .format(type(expr).__name__)) +class Term(FTL.Term, BaseResolver): + pass -@handle.register(Message) -def handle_message(message, env): - return handle(message.value, env) +class Pattern(FTL.Pattern, BaseResolver): + # Prevent messages with too many sub parts, for CPI DOS protection + MAX_PARTS = 1000 -@handle.register(Term) -def handle_term(term, env): - return handle(term.value, env) + def __init__(self, *args, **kwargs): + super(Pattern, self).__init__(*args, **kwargs) + self.dirty = False + def __call__(self, env): + if self.dirty: + env.errors.append(FluentCyclicReferenceError("Cyclic reference")) + return FluentNone() + if env.part_count > self.MAX_PARTS: + return "" + self.dirty = True + elements = self.elements + remaining_parts = self.MAX_PARTS - env.part_count + if len(self.elements) > remaining_parts: + elements = elements[:remaining_parts + 1] + env.errors.append(ValueError("Too many parts in message (> {0}), " + "aborting.".format(self.MAX_PARTS))) + retval = ''.join( + resolve(element(env), env) for element in elements + ) + env.part_count += len(elements) + self.dirty = False + return retval -@handle.register(Pattern) -def handle_pattern(pattern, env): - if pattern in env.dirty: - env.errors.append(FluentCyclicReferenceError("Cyclic reference")) - return FluentNone() - env.dirty.add(pattern) +def resolve(fluentish, env): + if isinstance(fluentish, FluentType): + return fluentish.format(env.context._babel_locale) + if isinstance(fluentish, six.string_types): + if len(fluentish) > MAX_PART_LENGTH: + return fluentish[:MAX_PART_LENGTH] + return fluentish - parts = [] - use_isolating = env.context._use_isolating and len(pattern.elements) > 1 - for element in pattern.elements: - env.part_count += 1 - if env.part_count > MAX_PARTS: - if env.part_count == MAX_PARTS + 1: - # Only append an error once. - env.errors.append(ValueError("Too many parts in message (> {0}), " - "aborting.".format(MAX_PARTS))) - parts.append(fully_resolve(FluentNone(), env)) - break +class TextElement(FTL.TextElement, Literal): + def __call__(self, env): + return self.value - if isinstance(element, TextElement): - # shortcut deliberately omits the FSI/PDI chars here. - parts.append(element.value) - continue - part = fully_resolve(element, env) - if use_isolating: - parts.append(FSI) - if len(part) > MAX_PART_LENGTH: - env.errors.append(ValueError( - "Too many characters in part, " - "({0}, max allowed is {1})".format(len(part), - MAX_PART_LENGTH))) - part = part[:MAX_PART_LENGTH] - parts.append(part) - if use_isolating: - parts.append(PDI) - retval = "".join(parts) - env.dirty.remove(pattern) - return retval +class Placeable(FTL.Placeable, BaseResolver): + def __call__(self, env): + return self.expression(env) -@handle.register(TextElement) -def handle_text_element(text_element, env): - return text_element.value +class IsolatingPlaceable(FTL.Placeable, BaseResolver): + def __call__(self, env): + inner = self.expression(env) + return "\u2068" + resolve(inner, env) + "\u2069" -@handle.register(Placeable) -def handle_placeable(placeable, env): - return handle(placeable.expression, env) +class StringLiteral(FTL.StringLiteral, Literal): + def __call__(self, env): + return self.value -@handle.register(StringLiteral) -def handle_string_expression(string_expression, env): - return string_expression.value +class NumberLiteral(FTL.NumberLiteral, BaseResolver): + def __init__(self, value, **kwargs): + super(NumberLiteral, self).__init__(value, **kwargs) + if '.' in self.value: + self.value = FluentFloat(self.value) + else: + self.value = FluentInt(self.value) + def __call__(self, env): + return self.value -@handle.register(NumberLiteral) -def handle_number_expression(number_expression, env): - return numeric_to_native(number_expression.value) +class MessageReference(FTL.MessageReference, BaseResolver): + def __call__(self, env): + return lookup_reference(self, env)(env) -@handle.register(MessageReference) -def handle_message_reference(message_reference, env): - return handle(lookup_reference(message_reference, env), env) +class TermReference(FTL.TermReference, BaseResolver): + def __call__(self, env): + with env.modified_for_term_reference(): + return lookup_reference(self, env)(env) -@handle.register(TermReference) -def handle_term_reference(term_reference, env): - with env.modified_for_term_reference(): - return handle(lookup_reference(term_reference, env), env) + +class FluentNoneResolver(FluentNone, BaseResolver): + def __call__(self, env): + return self.format(env.context._babel_locale) def lookup_reference(ref, env): @@ -204,9 +192,8 @@ def lookup_reference(ref, env): AST node, or FluentNone if not found, including fallback logic """ ref_id = reference_to_id(ref) - try: - return env.context._messages_and_terms[ref_id] + return env.context.lookup(ref_id) except LookupError: env.errors.append(unknown_reference_error_obj(ref_id)) @@ -214,111 +201,86 @@ def lookup_reference(ref, env): # Fallback parent_id = reference_to_id(ref.ref) try: - return env.context._messages_and_terms[parent_id] + return env.context.lookup(parent_id) except LookupError: # Don't add error here, because we already added error for the # actual thing we were looking for. pass - return FluentNone(ref_id) - - -@handle.register(FluentNone) -def handle_fluent_none(none, env): - return none.format(env.context._babel_locale) + return FluentNoneResolver(ref_id) -@handle.register(type(None)) -def handle_none(none, env): - # We raise the same error type here as when a message is completely missing. - raise LookupError("Message body not defined") +class VariableReference(FTL.VariableReference, BaseResolver): + def __call__(self, env): + name = self.id.name + try: + arg_val = env.current.args[name] + except LookupError: + if env.current.error_for_missing_arg: + env.errors.append( + FluentReferenceError("Unknown external: {0}".format(name))) + return FluentNoneResolver(name) - -@handle.register(VariableReference) -def handle_variable_reference(argument, env): - name = argument.id.name - try: - arg_val = env.current.args[name] - except LookupError: - if env.current.error_for_missing_arg: - env.errors.append( - FluentReferenceError("Unknown external: {0}".format(name))) + if isinstance(arg_val, (FluentType, six.text_type)): + return arg_val + env.errors.append(TypeError("Unsupported external type: {0}, {1}" + .format(name, type(arg_val)))) return FluentNone(name) - if isinstance(arg_val, - (int, float, Decimal, - date, datetime, - text_type)): - return arg_val - env.errors.append(TypeError("Unsupported external type: {0}, {1}" - .format(name, type(arg_val)))) - return FluentNone(name) - -@handle.register(AttributeExpression) -def handle_attribute_expression(attribute_ref, env): - return handle(lookup_reference(attribute_ref, env), env) +class AttributeExpression(FTL.AttributeExpression, BaseResolver): + def __call__(self, env): + return lookup_reference(self, env)(env) -@handle.register(Attribute) -def handle_attribute(attribute, env): - return handle(attribute.value, env) +class Attribute(FTL.Attribute, BaseResolver): + pass -@handle.register(VariantList) -def handle_variant_list(variant_list, env): - return select_from_variant_list(variant_list, env, None) +class VariantList(FTL.VariantList, BaseResolver): + def __call__(self, env, key=None): + found = None + for variant in self.variants: + if variant.default: + default = variant + if key is None: + # We only want the default + break - -def select_from_variant_list(variant_list, env, key): - found = None - for variant in variant_list.variants: - if variant.default: - default = variant - if key is None: - # We only want the default + compare_value = variant.key(env) + if match(key, compare_value, env): + found = variant break - compare_value = handle(variant.key, env) - if match(key, compare_value, env): - found = variant - break - - if found is None: - if (key is not None and not isinstance(key, FluentNone)): - env.errors.append(FluentReferenceError("Unknown variant: {0}" - .format(key))) - found = default - if found is None: - return FluentNone() - - return handle(found.value, env) + if found is None: + if (key is not None and not isinstance(key, FluentNone)): + env.errors.append(FluentReferenceError("Unknown variant: {0}" + .format(key))) + found = default + assert found, "Not having a default variant is a parse error" + return found.value(env) -@handle.register(SelectExpression) -def handle_select_expression(expression, env): - key = handle(expression.selector, env) - return select_from_select_expression(expression, env, - key=key) +class SelectExpression(FTL.SelectExpression, BaseResolver): + def __call__(self, env): + key = self.selector(env) + return self.select_from_select_expression(env, key=key) -def select_from_select_expression(expression, env, key): - default = None - found = None - for variant in expression.variants: - if variant.default: - default = variant + def select_from_select_expression(self, env, key): + default = None + found = None + for variant in self.variants: + if variant.default: + default = variant - compare_value = handle(variant.key, env) - if match(key, compare_value, env): - found = variant - break + if match(key, variant.key(env), env): + found = variant + break - if found is None: - found = default - if found is None: - return FluentNone() - return handle(found.value, env) + if found is None: + found = default + return found.value(env) def is_number(val): @@ -340,86 +302,55 @@ def match(val1, val2, env): return val1 == val2 -@handle.register(Identifier) -def handle_indentifier(identifier, env): - return identifier.name - - -@handle.register(VariantExpression) -def handle_variant_expression(expression, env): - message = lookup_reference(expression.ref, env) - if isinstance(message, FluentNone): - return message - - # TODO What to do if message is not a VariantList? - # Need test at least. - assert isinstance(message.value, VariantList) - - variant_name = expression.key.name - return select_from_variant_list(message.value, - env, - variant_name) - - -@handle.register(CallExpression) -def handle_call_expression(expression, env): - args = [handle(arg, env) for arg in expression.positional] - kwargs = {kwarg.name.name: handle(kwarg.value, env) for kwarg in expression.named} - - if isinstance(expression.callee, (TermReference, AttributeExpression)): - term = lookup_reference(expression.callee, env) - if args: - env.errors.append(FluentFormatError("Ignored positional arguments passed to term '{0}'" - .format(reference_to_id(expression.callee)))) - with env.modified_for_term_reference(args=kwargs): - return handle(term, env) - - # builtin or custom function call - function_name = expression.callee.id.name - try: - function = env.context._functions[function_name] - except LookupError: - env.errors.append(FluentReferenceError("Unknown function: {0}" - .format(function_name))) - return FluentNone(function_name + "()") - - try: - return function(*args, **kwargs) - except Exception as e: - env.errors.append(e) - return FluentNone(function_name + "()") - +class Variant(FTL.Variant, BaseResolver): + pass -@handle.register(FluentNumber) -def handle_fluent_number(number, env): - return number.format(env.context._babel_locale) +class Identifier(FTL.Identifier, BaseResolver): + def __call__(self, env): + return self.name -@handle.register(int) -def handle_int(integer, env): - return fluent_number(integer).format(env.context._babel_locale) +class VariantExpression(FTL.VariantExpression, BaseResolver): + def __call__(self, env): + message = lookup_reference(self.ref, env) -@handle.register(float) -def handle_float(f, env): - return fluent_number(f).format(env.context._babel_locale) + # TODO What to do if message is not a VariantList? + # Need test at least. + assert isinstance(message, VariantList) + variant_name = self.key.name + return message(env, variant_name) -@handle.register(Decimal) -def handle_decimal(d, env): - return fluent_number(d).format(env.context._babel_locale) +class CallExpression(FTL.CallExpression, BaseResolver): + def __call__(self, env): + args = [arg(env) for arg in self.positional] + kwargs = {kwarg.name.name: kwarg.value(env) for kwarg in self.named} -@handle.register(FluentDateType) -def handle_fluent_date_type(d, env): - return d.format(env.context._babel_locale) + if isinstance(self.callee, (TermReference, AttributeExpression)): + term = lookup_reference(self.callee, env) + if args: + env.errors.append(FluentFormatError("Ignored positional arguments passed to term '{0}'" + .format(reference_to_id(self.callee)))) + with env.modified_for_term_reference(args=kwargs): + return term(env) + # builtin or custom function call + function_name = self.callee.id.name + try: + function = env.context._functions[function_name] + except LookupError: + env.errors.append(FluentReferenceError("Unknown function: {0}" + .format(function_name))) + return FluentNone(function_name + "()") -@handle.register(date) -def handle_date(d, env): - return fluent_date(d).format(env.context._babel_locale) + try: + return function(*args, **kwargs) + except Exception as e: + env.errors.append(e) + return FluentNoneResolver(function_name + "()") -@handle.register(datetime) -def handle_datetime(d, env): - return fluent_date(d).format(env.context._babel_locale) +class NamedArgument(FTL.NamedArgument, BaseResolver): + pass diff --git a/fluent.runtime/fluent/runtime/types.py b/fluent.runtime/fluent/runtime/types.py index abf631bd..c8d26e38 100644 --- a/fluent.runtime/fluent/runtime/types.py +++ b/fluent.runtime/fluent/runtime/types.py @@ -81,7 +81,7 @@ class NumberFormatOptions(object): maximumSignificantDigits = attr.ib(default=None) -class FluentNumber(object): +class FluentNumber(FluentType): default_number_format_options = NumberFormatOptions() @@ -276,7 +276,7 @@ class DateFormatOptions(object): _SUPPORTED_DATETIME_OPTIONS = ['dateStyle', 'timeStyle', 'timeZone'] -class FluentDateType(object): +class FluentDateType(FluentType): # We need to match signature of `__init__` and `__new__` due to the way # some Python implementation (e.g. PyPy) implement some methods. # So we leave those alone, and implement another `_init_options` diff --git a/fluent.runtime/fluent/runtime/utils.py b/fluent.runtime/fluent/runtime/utils.py index e6f793bd..47a67fdd 100644 --- a/fluent.runtime/fluent/runtime/utils.py +++ b/fluent.runtime/fluent/runtime/utils.py @@ -1,5 +1,11 @@ +from __future__ import absolute_import, unicode_literals + +from datetime import date, datetime +from decimal import Decimal + from fluent.syntax.ast import AttributeExpression, Term, TermReference +from .types import FluentInt, FluentFloat, FluentDecimal, FluentDate, FluentDateTime from .errors import FluentReferenceError TERM_SIGIL = '-' @@ -15,26 +21,22 @@ def ast_to_id(ast): return ast.id.name -def add_message_and_attrs_to_store(store, ref_id, item, is_parent=True): - store[ref_id] = item - if is_parent: - for attr in item.attributes: - add_message_and_attrs_to_store(store, - _make_attr_id(ref_id, attr.id.name), - attr, - is_parent=False) - - -def numeric_to_native(val): +def native_to_fluent(val): """ - Given a numeric string (as defined by fluent spec), - return an int or float + Convert a python type to a Fluent Type. """ - # val matches this EBNF: - # '-'? [0-9]+ ('.' [0-9]+)? - if '.' in val: - return float(val) - return int(val) + if isinstance(val, int): + return FluentInt(val) + if isinstance(val, float): + return FluentFloat(val) + if isinstance(val, Decimal): + return FluentDecimal(val) + + if isinstance(val, datetime): + return FluentDateTime.from_date_time(val) + if isinstance(val, date): + return FluentDate.from_date(val) + return val def reference_to_id(ref): diff --git a/fluent.runtime/setup.py b/fluent.runtime/setup.py index 7acc0802..d22bb27c 100755 --- a/fluent.runtime/setup.py +++ b/fluent.runtime/setup.py @@ -2,12 +2,6 @@ from setuptools import setup import sys -if sys.version_info < (3, 4): - extra_requires = ['singledispatch>=3.4'] -else: - # functools.singledispatch is in stdlib from Python 3.4 onwards. - extra_requires = [] - setup(name='fluent.runtime', version='0.1', description='Localization library for expressive translations.', @@ -26,11 +20,11 @@ ], packages=['fluent', 'fluent.runtime'], install_requires=[ - 'fluent.syntax>=0.10,<=0.11', + 'fluent.syntax>=0.12,<=0.13', 'attrs', 'babel', 'pytz', - ] + extra_requires, + ], tests_require=['six'], test_suite='tests' ) diff --git a/fluent.runtime/tests/test_bomb.py b/fluent.runtime/tests/test_bomb.py index 889cb5a0..082de96b 100644 --- a/fluent.runtime/tests/test_bomb.py +++ b/fluent.runtime/tests/test_bomb.py @@ -39,5 +39,5 @@ def test_max_expansions_protection(self): # Without protection, emptylolz will take a really long time to # evaluate, although it generates an empty message. val, errs = self.ctx.format('emptylolz') - self.assertEqual(val, '???') + self.assertEqual(val, '') self.assertEqual(len(errs), 1) diff --git a/fluent.runtime/tox.ini b/fluent.runtime/tox.ini index 8fd96433..68361a9e 100644 --- a/fluent.runtime/tox.ini +++ b/fluent.runtime/tox.ini @@ -6,10 +6,8 @@ skipsdist=True setenv = PYTHONPATH = {toxinidir} deps = - fluent.syntax>=0.10,<=0.11 + fluent.syntax>=0.12,<=0.13 six attrs Babel - py27: singledispatch - pypy: singledispatch commands = ./runtests.py