Skip to content

Commit c71fe4e

Browse files
committed
Refined and documented error handling for functions
1 parent 9730d3f commit c71fe4e

File tree

11 files changed

+542
-78
lines changed

11 files changed

+542
-78
lines changed

fluent.runtime/CHANGELOG.rst

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ fluent.runtime development version (unreleased)
66

77
* Support for Fluent spec 0.8 (``fluent.syntax`` 0.10), including parameterized
88
terms.
9+
* Refined error handling regarding function calls to be more tolerant of errors
10+
in FTL files, while silencing developer errors less.
911

1012
fluent.runtime 0.1 (January 21, 2019)
1113
-------------------------------------

fluent.runtime/docs/errors.rst

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
Error handling
2+
==============
3+
4+
The Fluent philosophy is to try to recover from errors, and not throw
5+
exceptions, on the basis that a partial translation is usually better than one
6+
that is entirely missing or a 500 page.
7+
8+
python-fluent adopts that philosophy, but also tries to abide by the Zen of
9+
Python - “Errors should never pass silently. Unless explicitly silenced.”
10+
11+
The combination of these two different philosophies works as follows:
12+
13+
* Errors made by **translators** in the contents of FTL files do not raise
14+
exceptions. Instead the errors are collected in the ``errors`` argument returned
15+
by ``FluentBundle.format``, and some kind of substitute string is returned.
16+
For example, if a non-existent term ``-brand-name`` is referenced from a
17+
message, the string ``-brand-name`` is inserted into the returned string.
18+
19+
Also, if the translator uses a function and passes the wrong number of
20+
positional arguments, or unavailable keyword arguments, this error will be
21+
caught and reported, without allowing the exception to propagate.
22+
23+
* Exceptions triggered by **developer** errors (whether the authors of
24+
python-fluent or a user of python-fluent) are not caught, but are allowed to
25+
propagate. For example:
26+
27+
* An incorrect message ID passed to ``FluentBundle.format`` is most likely a
28+
developer error (a typo in the message ID), and so causes an exception to be
29+
raised.
30+
31+
A message ID that is correct but missing in some languages will cause the
32+
same error, but it is expected that to cover this eventuality
33+
``FluentBundle.format`` will be wrapped with functions that automatically
34+
perform fallback to languages that have all messages defined. This fallback
35+
mechanism is outside the scope of ``fluent.runtime`` itself.
36+
37+
* Message arguments of unexpected types will raise exceptions, since it is the
38+
developer's job to ensure the right arguments are being passed to the
39+
``FluentBundle.format`` method.
40+
41+
* Exceptions raised by custom functions are also assumed to be developer
42+
errors (as documented in :doc:`functions`, these functions should not raise
43+
exceptions), and are not caught.

fluent.runtime/docs/functions.rst

+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
Custom functions
2+
----------------
3+
4+
You can add functions to the ones available to FTL authors by passing a
5+
``functions`` dictionary to the ``FluentBundle`` constructor:
6+
7+
.. code-block:: python
8+
9+
>>> import platform
10+
>>> def os_name():
11+
... """Returns linux/mac/windows/other"""
12+
... return {'Linux': 'linux',
13+
... 'Darwin': 'mac',
14+
... 'Windows': 'windows'}.get(platform.system(), 'other')
15+
16+
>>> bundle = FluentBundle(['en-US'], functions={'OS': os_name})
17+
>>> bundle.add_messages("""
18+
... welcome = { OS() ->
19+
... [linux] Welcome to Linux
20+
... [mac] Welcome to Mac
21+
... [windows] Welcome to Windows
22+
... *[other] Welcome
23+
... }
24+
... """)
25+
>>> print(bundle.format('welcome')[0]
26+
Welcome to Linux
27+
28+
These functions can accept positional and keyword arguments, like the ``NUMBER``
29+
and ``DATETIME`` builtins. They must accept the following types of objects
30+
passed as arguments:
31+
32+
- unicode strings (i.e. ``unicode`` on Python 2, ``str`` on Python 3)
33+
- ``fluent.runtime.types.FluentType`` subclasses, namely:
34+
35+
- ``FluentNumber`` - ``int``, ``float`` or ``Decimal`` objects passed in
36+
externally, or expressed as literals, are wrapped in these. Note that these
37+
objects also subclass builtin ``int``, ``float`` or ``Decimal``, so can be
38+
used as numbers in the normal way.
39+
- ``FluentDateType`` - ``date`` or ``datetime`` objects passed in are wrapped in
40+
these. Again, these classes also subclass ``date`` or ``datetime``, and can
41+
be used as such.
42+
- ``FluentNone`` - in error conditions, such as a message referring to an
43+
argument that hasn't been passed in, objects of this type are passed in.
44+
45+
Custom functions should not throw errors, but return ``FluentNone`` instances to
46+
indicate an error or missing data. Otherwise they should return unicode strings,
47+
or instances of a ``FluentType`` subclass as above. Returned numbers and
48+
datetimes should be converted to ``FluentNumber`` or ``FluentDateType``
49+
subclasses using ``fluent.types.fluent_number`` and ``fluent.types.fluent_date``
50+
respectively.
51+
52+
The type signatures of custom functions are checked before they are used, to
53+
ensure the right the number of positional arguments are used, and only available
54+
keyword arguments are used - otherwise a ``TypeError`` will be appended to the
55+
``errors`` list. Using ``*args`` or ``**kwargs`` to allow any number of
56+
positional or keyword arguments is supported, but you should ensure that your
57+
function actually does allow all positional or keyword arguments.
58+
59+
If you want to override the detected type signature (for example, to limit the
60+
arguments that can be used in an FTL file, or to provide a proper signature for
61+
a function that has a signature using ``*args`` and ``**kwargs`` but is more
62+
restricted in reality), you can add an ``ftl_arg_spec`` attribute to the
63+
function. The value should be a two-tuple containing 1) an integer specifying
64+
the number of positional arguments, and 2) a list of allowed keyword arguments.
65+
For example, for a custom function ``my_func`` the following will stop the
66+
``restricted`` keyword argument from being used from FTL files, while allowing
67+
``allowed``, and will require that a single positional argument is passed:
68+
69+
.. code-block:: python
70+
71+
def my_func(arg1, allowed=None, restricted=None):
72+
pass
73+
74+
my_func.ftl_arg_spec = (1, ['allowed'])
75+
76+
The Fluent spec allows keyword arguments with hyphens (``-``) in them.
77+
Since these cannot be used in valid Python keyword arguments, they are
78+
disallowed by ``fluent.runtime`` and will be filtered out and generate
79+
errors if you specify such a keyword in ``ftl_arg_spec`` or use one in a
80+
message.

fluent.runtime/docs/index.rst

+2
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,6 @@ significant changes.
1515

1616
installation
1717
usage
18+
functions
19+
errors
1820
history

fluent.runtime/docs/usage.rst

+7-48
Original file line numberDiff line numberDiff line change
@@ -202,54 +202,6 @@ ways:
202202
>>> val
203203
'Now is Jun 17, 2018, 3:15:05 PM'
204204
205-
Custom functions
206-
~~~~~~~~~~~~~~~~
207-
208-
You can add functions to the ones available to FTL authors by passing a
209-
``functions`` dictionary to the ``FluentBundle`` constructor:
210-
211-
.. code-block:: python
212-
213-
>>> import platform
214-
>>> def os_name():
215-
... """Returns linux/mac/windows/other"""
216-
... return {'Linux': 'linux',
217-
... 'Darwin': 'mac',
218-
... 'Windows': 'windows'}.get(platform.system(), 'other')
219-
220-
>>> bundle = FluentBundle(['en-US'], functions={'OS': os_name})
221-
>>> bundle.add_messages("""
222-
... welcome = { OS() ->
223-
... [linux] Welcome to Linux
224-
... [mac] Welcome to Mac
225-
... [windows] Welcome to Windows
226-
... *[other] Welcome
227-
... }
228-
... """)
229-
>>> print(bundle.format('welcome')[0]
230-
Welcome to Linux
231-
232-
These functions can accept positional and keyword arguments (like the
233-
``NUMBER`` and ``DATETIME`` builtins), and in this case must accept the
234-
following types of arguments:
235-
236-
- unicode strings (i.e. ``unicode`` on Python 2, ``str`` on Python 3)
237-
- ``fluent.runtime.types.FluentType`` subclasses, namely:
238-
- ``FluentNumber`` - ``int``, ``float`` or ``Decimal`` objects passed
239-
in externally, or expressed as literals, are wrapped in these. Note
240-
that these objects also subclass builtin ``int``, ``float`` or
241-
``Decimal``, so can be used as numbers in the normal way.
242-
- ``FluentDateType`` - ``date`` or ``datetime`` objects passed in are
243-
wrapped in these. Again, these classes also subclass ``date`` or
244-
``datetime``, and can be used as such.
245-
- ``FluentNone`` - in error conditions, such as a message referring to
246-
an argument that hasn't been passed in, objects of this type are
247-
passed in.
248-
249-
Custom functions should not throw errors, but return ``FluentNone``
250-
instances to indicate an error or missing data. Otherwise they should
251-
return unicode strings, or instances of a ``FluentType`` subclass as
252-
above.
253205
254206
Known limitations and bugs
255207
~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -268,3 +220,10 @@ Known limitations and bugs
268220
<https://github.com/andyearnshaw/Intl.js/blob/master/src/12.datetimeformat.js>`_.
269221

270222
Help with the above would be welcome!
223+
224+
225+
Other features and further information
226+
--------------------------------------
227+
228+
* :doc:`functions`
229+
* :doc:`errors`

fluent.runtime/fluent/runtime/resolver.py

+11-8
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313

1414
from .errors import FluentCyclicReferenceError, FluentFormatError, FluentReferenceError
1515
from .types import FluentDateType, FluentNone, FluentNumber, fluent_date, fluent_number
16-
from .utils import numeric_to_native, reference_to_id, unknown_reference_error_obj
16+
from .utils import (args_match, inspect_function_args, numeric_to_native,
17+
reference_to_id, unknown_reference_error_obj)
1718

1819
try:
1920
from functools import singledispatch
@@ -110,8 +111,8 @@ def fully_resolve(expr, env):
110111

111112
@singledispatch
112113
def handle(expr, env):
113-
raise NotImplementedError("Cannot handle object of type {0}"
114-
.format(type(expr).__name__))
114+
raise TypeError("Cannot handle object {0} of type {1}"
115+
.format(expr, type(expr).__name__))
115116

116117

117118
@handle.register(Message)
@@ -245,6 +246,7 @@ def handle_variable_reference(argument, env):
245246
FluentReferenceError("Unknown external: {0}".format(name)))
246247
return FluentNone(name)
247248

249+
# The code below should be synced with fluent.runtime.runtime.handle_argument
248250
if isinstance(arg_val,
249251
(int, float, Decimal,
250252
date, datetime,
@@ -383,11 +385,12 @@ def handle_call_expression(expression, env):
383385
.format(function_name)))
384386
return FluentNone(function_name + "()")
385387

386-
try:
387-
return function(*args, **kwargs)
388-
except Exception as e:
389-
env.errors.append(e)
390-
return FluentNone(function_name + "()")
388+
arg_spec = inspect_function_args(function, function_name, env.errors)
389+
match, sanitized_args, sanitized_kwargs, errors = args_match(function_name, args, kwargs, arg_spec)
390+
env.errors.extend(errors)
391+
if match:
392+
return function(*sanitized_args, **sanitized_kwargs)
393+
return FluentNone(function_name + "()")
391394

392395

393396
@handle.register(FluentNumber)

fluent.runtime/fluent/runtime/types.py

+37-3
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ class NumberFormatOptions(object):
6767
# rather than using underscores as per PEP8, so that
6868
# we can stick to Fluent spec more easily.
6969

70+
# Keyword args available to FTL authors must be synced to fluent_number.ftl_arg_spec below
71+
7072
# See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NumberFormat
7173
style = attr.ib(default=FORMAT_STYLE_DECIMAL,
7274
validator=attr.validators.in_(FORMAT_STYLE_OPTIONS))
@@ -81,7 +83,7 @@ class NumberFormatOptions(object):
8183
maximumSignificantDigits = attr.ib(default=None)
8284

8385

84-
class FluentNumber(object):
86+
class FluentNumber(FluentType):
8587

8688
default_number_format_options = NumberFormatOptions()
8789

@@ -228,10 +230,24 @@ def fluent_number(number, **kwargs):
228230
elif isinstance(number, FluentNone):
229231
return number
230232
else:
231-
raise TypeError("Can't use fluent_number with object {0} for type {1}"
233+
raise TypeError("Can't use fluent_number with object {0} of type {1}"
232234
.format(number, type(number)))
233235

234236

237+
# Specify arg spec manually, for three reasons:
238+
# 1. To avoid having to specify kwargs explicitly, which results
239+
# in duplication, and in unnecessary work inside FluentNumber
240+
# 2. To stop 'style' and 'currency' being used inside FTL files
241+
# 3. To avoid needing inspection to do this work.
242+
fluent_number.ftl_arg_spec = (1, ['currencyDisplay',
243+
'useGrouping',
244+
'minimumIntegerDigits',
245+
'minimumFractionDigits',
246+
'maximumFractionDigits',
247+
'minimumSignificantDigits',
248+
'maximumSignificantDigits'])
249+
250+
235251
_UNGROUPED_PATTERN = parse_pattern("#0")
236252

237253

@@ -255,6 +271,8 @@ class DateFormatOptions(object):
255271
timeZone = attr.ib(default=None)
256272

257273
# Other
274+
# Keyword args available to FTL authors must be synced to fluent_date.ftl_arg_spec below
275+
258276
hour12 = attr.ib(default=None)
259277
weekday = attr.ib(default=None)
260278
era = attr.ib(default=None)
@@ -276,7 +294,7 @@ class DateFormatOptions(object):
276294
_SUPPORTED_DATETIME_OPTIONS = ['dateStyle', 'timeStyle', 'timeZone']
277295

278296

279-
class FluentDateType(object):
297+
class FluentDateType(FluentType):
280298
# We need to match signature of `__init__` and `__new__` due to the way
281299
# some Python implementation (e.g. PyPy) implement some methods.
282300
# So we leave those alone, and implement another `_init_options`
@@ -361,3 +379,19 @@ def fluent_date(dt, **kwargs):
361379
else:
362380
raise TypeError("Can't use fluent_date with object {0} of type {1}"
363381
.format(dt, type(dt)))
382+
383+
384+
fluent_date.ftl_arg_spec = (1,
385+
['hour12',
386+
'weekday',
387+
'era',
388+
'year',
389+
'month',
390+
'day',
391+
'hour',
392+
'minute',
393+
'second',
394+
'timeZoneName',
395+
'dateStyle',
396+
'timeStyle',
397+
])

0 commit comments

Comments
 (0)