Skip to content

Commit 41bd74f

Browse files
authored
Merge pull request #81 from django-ftl/implement_format_namespace_package
Implement fluent.runtime namespace package
2 parents 80bff9a + 2a7f298 commit 41bd74f

35 files changed

+2751
-9
lines changed

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
.tox
22
*.pyc
33
.eggs/
4-
fluent.egg-info/
4+
*.egg-info/

.travis.yml

+3
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ python:
44
- "2.7"
55
- "3.5"
66
- "3.6"
7+
- "pypy"
8+
- "pypy3"
79
- "nightly"
810
env:
911
- PACKAGE=fluent.syntax
12+
- PACKAGE=fluent.runtime
1013
install: pip install tox-travis
1114
script: cd $PACKAGE; tox
1215
notifications:

README.md

+245
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,251 @@ you're a tool author you may be interested in the formal [EBNF grammar][].
3030
[EBNF grammar]: https://github.com/projectfluent/fluent/tree/master/spec
3131

3232

33+
Installation
34+
------------
35+
36+
python-fluent consists of two packages:
37+
38+
* `fluent.syntax` - includes AST classes and parser. Most end users will not
39+
need this directly. Documentation coming soon!
40+
41+
To install:
42+
43+
pip install fluent.syntax
44+
45+
46+
* `fluent.runtime` - methods for generating translations from FTL files.
47+
Documentation below.
48+
49+
To install:
50+
51+
pip install fluent.runtime
52+
53+
(The correct version of ``fluent.syntax`` will be installed automatically)
54+
55+
56+
PyPI also contains an old `fluent` package which is an older version of just
57+
`fluent.syntax`.
58+
59+
Usage
60+
-----
61+
62+
To generate translations using the ``fluent.runtime`` package, you start with
63+
the `FluentBundle` class:
64+
65+
>>> from fluent.runtime import FluentBundle
66+
67+
You pass a list of locales to the constructor - the first being the desired
68+
locale, with fallbacks after that:
69+
70+
>>> bundle = FluentBundle(["en-US"])
71+
72+
73+
You must then add messages. These would normally come from a `.ftl` file stored
74+
on disk, here we will just add them directly:
75+
76+
>>> bundle.add_messages("""
77+
... welcome = Welcome to this great app!
78+
... greet-by-name = Hello, { $name }!
79+
... """)
80+
81+
To generate translations, use the `format` method, passing a message ID and an
82+
optional dictionary of substitution parameters. If the the message ID is not
83+
found, a `LookupError` is raised. Otherwise, as per the Fluent philosophy, the
84+
implementation tries hard to recover from any formatting errors and generate the
85+
most human readable representation of the value. The `format` method therefore
86+
returns a tuple containing `(translated string, errors)`, as below.
87+
88+
>>> translated, errs = bundle.format('welcome')
89+
>>> translated
90+
"Welcome to this great app!"
91+
>>> errs
92+
[]
93+
94+
>>> translated, errs = bundle.format('greet-by-name', {'name': 'Jane'})
95+
>>> translated
96+
'Hello, \u2068Jane\u2069!'
97+
98+
>>> translated, errs = bundle.format('greet-by-name', {})
99+
>>> translated
100+
'Hello, \u2068name\u2069!'
101+
>>> errs
102+
[FluentReferenceError('Unknown external: name')]
103+
104+
You will notice the extra characters `\u2068` and `\u2069` in the output. These
105+
are Unicode bidi isolation characters that help to ensure that the interpolated
106+
strings are handled correctly in the situation where the text direction of the
107+
substitution might not match the text direction of the localized text. These
108+
characters can be disabled if you are sure that is not possible for your app by
109+
passing `use_isolating=False` to the `FluentBundle` constructor.
110+
111+
Python 2
112+
--------
113+
114+
The above examples assume Python 3. Since Fluent uses unicode everywhere
115+
internally (and doesn't accept bytestrings), if you are using Python 2 you will
116+
need to make adjustments to the above example code. Either add `u` unicode
117+
literal markers to strings or add this at the top of the module or the start of
118+
your repl session:
119+
120+
from __future__ import unicode_literals
121+
122+
123+
Numbers
124+
-------
125+
126+
When rendering translations, Fluent passes any numeric arguments (int or float)
127+
through locale-aware formatting functions:
128+
129+
>>> bundle.add_messages("show-total-points = You have { $points } points.")
130+
>>> val, errs = bundle.format("show-total-points", {'points': 1234567})
131+
>>> val
132+
'You have 1,234,567 points.'
133+
134+
135+
You can specify your own formatting options on the arguments passed in by
136+
wrapping your numeric arguments with `fluent.runtime.types.fluent_number`:
137+
138+
>>> from fluent.runtime.types import fluent_number
139+
>>> points = fluent_number(1234567, useGrouping=False)
140+
>>> bundle.format("show-total-points", {'points': points})[0]
141+
'You have 1234567 points.'
142+
143+
>>> amount = fluent_number(1234.56, style="currency", currency="USD")
144+
>>> bundle.add_messages("your-balance = Your balance is { $amount }")
145+
>>> bundle.format("your-balance", {'amount': amount})[0]
146+
'Your balance is $1,234.56'
147+
148+
Thee options available are defined in the Fluent spec for
149+
[NUMBER](https://projectfluent.org/fluent/guide/functions.html#number). Some of
150+
these options can also be defined in the FTL files, as described in the Fluent
151+
spec, and the options will be merged.
152+
153+
Date and time
154+
-------------
155+
156+
Python `datetime.datetime` and `datetime.date` objects are also passed through
157+
locale aware functions:
158+
159+
>>> from datetime import date
160+
>>> bundle.add_messages("today-is = Today is { $today }")
161+
>>> val, errs = bundle.format("today-is", {"today": date.today() })
162+
>>> val
163+
'Today is Jun 16, 2018'
164+
165+
You can explicitly call the `DATETIME` builtin to specify options:
166+
167+
>>> bundle.add_messages('today-is = Today is { DATETIME($today, dateStyle: "short") }')
168+
169+
See the [DATETIME
170+
docs](https://projectfluent.org/fluent/guide/functions.html#datetime). However,
171+
currently the only supported options to `DATETIME` are:
172+
173+
* `timeZone`
174+
* `dateStyle` and `timeStyle` which are [proposed
175+
additions](https://github.com/tc39/proposal-ecma402-datetime-style) to the ECMA i18n spec.
176+
177+
To specify options from Python code, use `fluent.runtime.types.fluent_date`:
178+
179+
>>> from fluent.runtime.types import fluent_date
180+
>>> today = date.today()
181+
>>> short_today = fluent_date(today, dateStyle='short')
182+
>>> val, errs = bundle.format("today-is", {"today": short_today })
183+
>>> val
184+
'Today is 6/17/18'
185+
186+
You can also specify timezone for displaying `datetime` objects in two ways:
187+
188+
* Create timezone aware `datetime` objects, and pass these to the `format` call
189+
e.g.:
190+
191+
>>> import pytz
192+
>>> from datetime import datetime
193+
>>> utcnow = datime.utcnow().replace(tzinfo=pytz.utc)
194+
>>> moscow_timezone = pytz.timezone('Europe/Moscow')
195+
>>> now_in_moscow = utcnow.astimezone(moscow_timezone)
196+
197+
* Or, use timezone naive `datetime` objects, or ones with a UTC timezone, and
198+
pass the `timeZone` argument to `fluent_date` as a string:
199+
200+
>>> utcnow = datetime.utcnow()
201+
>>> utcnow
202+
datetime.datetime(2018, 6, 17, 12, 15, 5, 677597)
203+
204+
>>> bundle.add_messages("now-is = Now is { $now }")
205+
>>> val, errs = bundle.format("now-is",
206+
... {"now": fluent_date(utcnow,
207+
... timeZone="Europe/Moscow",
208+
... dateStyle="medium",
209+
... timeStyle="medium")})
210+
>>> val
211+
'Now is Jun 17, 2018, 3:15:05 PM'
212+
213+
214+
Custom functions
215+
----------------
216+
217+
You can add functions to the ones available to FTL authors by passing
218+
a `functions` dictionary to the `FluentBundle` constructor:
219+
220+
221+
>>> import platform
222+
>>> def os_name():
223+
... """Returns linux/mac/windows/other"""
224+
... return {'Linux': 'linux',
225+
... 'Darwin': 'mac',
226+
... 'Windows': 'windows'}.get(platform.system(), 'other')
227+
228+
>>> bundle = FluentBundle(['en-US'], functions={'OS': os_name})
229+
>>> bundle.add_messages("""
230+
... welcome = { OS() ->
231+
... [linux] Welcome to Linux
232+
... [mac] Welcome to Mac
233+
... [windows] Welcome to Windows
234+
... *[other] Welcome
235+
... }
236+
... """)
237+
>>> print(bundle.format('welcome')[0]
238+
Welcome to Linux
239+
240+
These functions can accept positioal and keyword arguments (like the `NUMBER`
241+
and `DATETIME` builtins), and in this case must accept the following types of
242+
arguments:
243+
244+
* unicode strings (i.e. `unicode` on Python 2, `str` on Python 3)
245+
* `fluent.runtime.types.FluentType` subclasses, namely:
246+
* `FluentNumber` - `int`, `float` or `Decimal` objects passed in externally,
247+
or expressed as literals, are wrapped in these. Note that these objects also
248+
subclass builtin `int`, `float` or `Decimal`, so can be used as numbers in
249+
the normal way.
250+
* `FluentDateType` - `date` or `datetime` objects passed in are wrapped in
251+
these. Again, these classes also subclass `date` or `datetime`, and can be
252+
used as such.
253+
* `FluentNone` - in error conditions, such as a message referring to an argument
254+
that hasn't been passed in, objects of this type are passed in.
255+
256+
Custom functions should not throw errors, but return `FluentNone` instances to
257+
indicate an error or missing data. Otherwise they should return unicode strings,
258+
or instances of a `FluentType` subclass as above.
259+
260+
261+
Known limitations and bugs
262+
--------------------------
263+
264+
* We do not yet support `NUMBER(..., currencyDisplay="name")` - see [this python-babel
265+
pull request](https://github.com/python-babel/babel/pull/585) which needs to
266+
be merged and released.
267+
268+
* Most options to `DATETIME` are not yet supported. See the [MDN docs for
269+
Intl.DateTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat),
270+
the [ECMA spec for
271+
BasicFormatMatcher](http://www.ecma-international.org/ecma-402/1.0/#BasicFormatMatcher)
272+
and the [Intl.js
273+
polyfill](https://github.com/andyearnshaw/Intl.js/blob/master/src/12.datetimeformat.js).
274+
275+
Help with the above would be welcome!
276+
277+
33278
Discuss
34279
-------
35280

fluent.runtime/fluent/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__path__ = __import__('pkgutil').extend_path(__path__, __name__)
+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from __future__ import absolute_import, unicode_literals
2+
3+
import babel
4+
import babel.numbers
5+
import babel.plural
6+
7+
from fluent.syntax import FluentParser
8+
from fluent.syntax.ast import Message, Term
9+
10+
from .builtins import BUILTINS
11+
from .resolver import resolve
12+
13+
14+
class FluentBundle(object):
15+
"""
16+
Message contexts are single-language stores of translations. They are
17+
responsible for parsing translation resources in the Fluent syntax and can
18+
format translation units (entities) to strings.
19+
20+
Always use `FluentBundle.format` to retrieve translation units from
21+
a context. Translations can contain references to other entities or
22+
external arguments, conditional logic in form of select expressions, traits
23+
which describe their grammatical features, and can use Fluent builtins.
24+
See the documentation of the Fluent syntax for more information.
25+
"""
26+
27+
def __init__(self, locales, functions=None, use_isolating=True):
28+
self.locales = locales
29+
_functions = BUILTINS.copy()
30+
if functions:
31+
_functions.update(functions)
32+
self._functions = _functions
33+
self._use_isolating = use_isolating
34+
self._messages_and_terms = {}
35+
self._babel_locale = self._get_babel_locale()
36+
self._plural_form = babel.plural.to_python(self._babel_locale.plural_form)
37+
38+
def add_messages(self, source):
39+
parser = FluentParser()
40+
resource = parser.parse(source)
41+
# TODO - warn/error about duplicates
42+
for item in resource.body:
43+
if isinstance(item, (Message, Term)):
44+
if item.id.name not in self._messages_and_terms:
45+
self._messages_and_terms[item.id.name] = item
46+
47+
def has_message(self, message_id):
48+
if message_id.startswith('-'):
49+
return False
50+
return message_id in self._messages_and_terms
51+
52+
def format(self, message_id, args=None):
53+
message = self._get_message(message_id)
54+
if args is None:
55+
args = {}
56+
return resolve(self, message, args)
57+
58+
def _get_message(self, message_id):
59+
if message_id.startswith('-'):
60+
raise LookupError(message_id)
61+
if '.' in message_id:
62+
name, attr_name = message_id.split('.', 1)
63+
msg = self._messages_and_terms[name]
64+
for attribute in msg.attributes:
65+
if attribute.id.name == attr_name:
66+
return attribute.value
67+
raise LookupError(message_id)
68+
else:
69+
return self._messages_and_terms[message_id]
70+
71+
def _get_babel_locale(self):
72+
for l in self.locales:
73+
try:
74+
return babel.Locale.parse(l.replace('-', '_'))
75+
except babel.UnknownLocaleError:
76+
continue
77+
# TODO - log error
78+
return babel.Locale.default()
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from .types import fluent_date, fluent_number
2+
3+
NUMBER = fluent_number
4+
DATETIME = fluent_date
5+
6+
7+
BUILTINS = {
8+
'NUMBER': NUMBER,
9+
'DATETIME': DATETIME,
10+
}
+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from __future__ import absolute_import, unicode_literals
2+
3+
4+
class FluentFormatError(ValueError):
5+
def __eq__(self, other):
6+
return ((other.__class__ == self.__class__) and
7+
other.args == self.args)
8+
9+
10+
class FluentReferenceError(FluentFormatError):
11+
pass
12+
13+
14+
class FluentCyclicReferenceError(FluentFormatError):
15+
pass

0 commit comments

Comments
 (0)