Skip to content

Commit fee4c63

Browse files
authored
Merge pull request #24 from stasm/1321279-read-ftl-when-migrating
Bug 1321279 - Read target FTL files before migrations.
2 parents e12bf03 + 9de0f5d commit fee4c63

20 files changed

+388
-156
lines changed

fluent/migrate/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# coding=utf8
22

33
from .context import MergeContext # noqa: F401
4+
from .errors import ( # noqa: F401
5+
MigrationError, NotSupportedError, UnreadableReferenceError
6+
)
47
from .transforms import ( # noqa: F401
58
Source, COPY, REPLACE_IN_TEXT, REPLACE, PLURALS, CONCAT
69
)

fluent/migrate/context.py

Lines changed: 76 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from __future__ import unicode_literals
33

44
import os
5+
import sys
56
import codecs
67
import logging
78

@@ -18,7 +19,7 @@ def getParser(path):
1819
from .cldr import get_plural_categories
1920
from .transforms import Source
2021
from .merge import merge_resource
21-
from .util import get_message
22+
from .errors import NotSupportedError, UnreadableReferenceError
2223

2324

2425
class MergeContext(object):
@@ -79,6 +80,10 @@ def read_ftl_resource(self, path):
7980
f = codecs.open(path, 'r', 'utf8')
8081
try:
8182
contents = f.read()
83+
except UnicodeDecodeError as err:
84+
logger = logging.getLogger('migrate')
85+
logger.warn('Unable to read file {}: {}'.format(path, err))
86+
raise err
8287
finally:
8388
f.close()
8489

@@ -94,7 +99,7 @@ def read_ftl_resource(self, path):
9499
logger = logging.getLogger('migrate')
95100
for annot in annots:
96101
msg = annot.message
97-
logger.warn(u'Syntax error in {}: {}'.format(path, msg))
102+
logger.warn('Syntax error in {}: {}'.format(path, msg))
98103

99104
return ast
100105

@@ -105,52 +110,33 @@ def read_legacy_resource(self, path):
105110
# Transform the parsed result which is an iterator into a dict.
106111
return {entity.key: entity.val for entity in parser}
107112

108-
def add_reference(self, path, realpath=None):
109-
"""Add an FTL AST to this context's reference resources."""
110-
fullpath = os.path.join(self.reference_dir, realpath or path)
111-
try:
112-
ast = self.read_ftl_resource(fullpath)
113-
except IOError as err:
114-
logger = logging.getLogger('migrate')
115-
logger.error(u'Missing reference file: {}'.format(path))
116-
raise err
117-
except UnicodeDecodeError as err:
118-
logger = logging.getLogger('migrate')
119-
logger.error(u'Error reading file {}: {}'.format(path, err))
120-
raise err
121-
else:
122-
self.reference_resources[path] = ast
113+
def maybe_add_localization(self, path):
114+
"""Add a localization resource to migrate translations from.
123115
124-
def add_localization(self, path):
125-
"""Add an existing localization resource.
116+
Only legacy resources can be added as migration sources. The resource
117+
may be missing on disk.
126118
127-
If it's an FTL resource, add an FTL AST. Otherwise, it's a legacy
128-
resource. Use a compare-locales parser to create a dict of (key,
129-
string value) tuples.
119+
Uses a compare-locales parser to create a dict of (key, string value)
120+
tuples.
130121
"""
131-
fullpath = os.path.join(self.localization_dir, path)
132-
if fullpath.endswith('.ftl'):
133-
try:
134-
ast = self.read_ftl_resource(fullpath)
135-
except IOError:
136-
logger = logging.getLogger('migrate')
137-
logger.warn(u'Missing localization file: {}'.format(path))
138-
except UnicodeDecodeError as err:
139-
logger = logging.getLogger('migrate')
140-
logger.warn(u'Error reading file {}: {}'.format(path, err))
141-
else:
142-
self.localization_resources[path] = ast
122+
if path.endswith('.ftl'):
123+
error_message = (
124+
'Migrating translations from Fluent files is not supported '
125+
'({})'.format(path))
126+
logging.getLogger('migrate').error(error_message)
127+
raise NotSupportedError(error_message)
128+
129+
try:
130+
fullpath = os.path.join(self.localization_dir, path)
131+
collection = self.read_legacy_resource(fullpath)
132+
except IOError:
133+
logger = logging.getLogger('migrate')
134+
logger.warn('Missing localization file: {}'.format(path))
143135
else:
144-
try:
145-
collection = self.read_legacy_resource(fullpath)
146-
except IOError:
147-
logger = logging.getLogger('migrate')
148-
logger.warn(u'Missing localization file: {}'.format(path))
149-
else:
150-
self.localization_resources[path] = collection
136+
self.localization_resources[path] = collection
151137

152-
def add_transforms(self, path, transforms):
153-
"""Define transforms for path.
138+
def add_transforms(self, path, reference, transforms):
139+
"""Define transforms for path using reference as template.
154140
155141
Each transform is an extended FTL node with `Transform` nodes as some
156142
values. Transforms are stored in their lazy AST form until
@@ -165,6 +151,22 @@ def get_sources(acc, cur):
165151
acc.add((cur.path, cur.key))
166152
return acc
167153

154+
refpath = os.path.join(self.reference_dir, reference)
155+
try:
156+
ast = self.read_ftl_resource(refpath)
157+
except IOError as err:
158+
error_message = 'Missing reference file: {}'.format(refpath)
159+
logging.getLogger('migrate').error(error_message)
160+
raise UnreadableReferenceError(error_message)
161+
except UnicodeDecodeError as err:
162+
error_message = 'Error reading file {}: {}'.format(refpath, err)
163+
logging.getLogger('migrate').error(error_message)
164+
raise UnreadableReferenceError(error_message)
165+
else:
166+
# The reference file will be used by the merge function as
167+
# a template for serializing the merge results.
168+
self.reference_resources[path] = ast
169+
168170
for node in transforms:
169171
# Scan `node` for `Source` nodes and collect the information they
170172
# store into a set of dependencies.
@@ -175,17 +177,30 @@ def get_sources(acc, cur):
175177
path_transforms = self.transforms.setdefault(path, [])
176178
path_transforms += transforms
177179

180+
if path not in self.localization_resources:
181+
fullpath = os.path.join(self.localization_dir, path)
182+
try:
183+
ast = self.read_ftl_resource(fullpath)
184+
except IOError:
185+
logger = logging.getLogger('migrate')
186+
logger.info(
187+
'Localization file {} does not exist and '
188+
'it will be created'.format(path))
189+
except UnicodeDecodeError:
190+
logger = logging.getLogger('migrate')
191+
logger.warn(
192+
'Localization file {} will be re-created and some '
193+
'translations might be lost'.format(path))
194+
else:
195+
self.localization_resources[path] = ast
196+
178197
def get_source(self, path, key):
179-
"""Get an entity value from the localized source.
198+
"""Get an entity value from a localized legacy source.
180199
181200
Used by the `Source` transform.
182201
"""
183-
if path.endswith('.ftl'):
184-
resource = self.localization_resources[path]
185-
return get_message(resource.body, key)
186-
else:
187-
resource = self.localization_resources[path]
188-
return resource.get(key, None)
202+
resource = self.localization_resources[path]
203+
return resource.get(key, None)
189204

190205
def merge_changeset(self, changeset=None):
191206
"""Return a generator of FTL ASTs for the changeset.
@@ -200,10 +215,11 @@ def merge_changeset(self, changeset=None):
200215
"""
201216

202217
if changeset is None:
203-
# Merge all known legacy translations.
218+
# Merge all known legacy translations. Used in tests.
204219
changeset = {
205220
(path, key)
206221
for path, strings in self.localization_resources.iteritems()
222+
if not path.endswith('.ftl')
207223
for key in strings.iterkeys()
208224
}
209225

@@ -240,10 +256,15 @@ def in_changeset(ident):
240256
self, reference, current, transforms, in_changeset
241257
)
242258

243-
# If none of the transforms is in the given changeset, the merged
244-
# snapshot is identical to the current translation. We compare
245-
# JSON trees rather then use filtering by `in_changeset` to account
246-
# for translations removed from `reference`.
259+
# Skip this path if the merged snapshot is identical to the current
260+
# state of the localization file. This may happen when:
261+
#
262+
# - none of the transforms is in the changset, or
263+
# - all messages which would be migrated by the context's
264+
# transforms already exist in the current state.
265+
#
266+
# We compare JSON trees rather then use filtering by `in_changeset`
267+
# to account for translations removed from `reference`.
247268
if snapshot.to_json() == current.to_json():
248269
continue
249270

fluent/migrate/errors.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class MigrationError(ValueError):
2+
pass
3+
4+
5+
class NotSupportedError(MigrationError):
6+
pass
7+
8+
9+
class UnreadableReferenceError(MigrationError):
10+
pass

fluent/migrate/merge.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ def merge_resource(ctx, reference, current, transforms, in_changeset):
1111
"""Transform legacy translations into FTL.
1212
1313
Use the `reference` FTL AST as a template. For each en-US string in the
14-
reference, first check if it's in the currently processed changeset with
15-
`in_changeset`; then check for an existing translation in the current FTL
16-
`localization` or for a migration specification in `transforms`.
14+
reference, first check for an existing translation in the current FTL
15+
`localization` and use it if it's present; then if the string has
16+
a transform defined in the migration specification and if it's in the
17+
currently processed changeset, evaluate the transform.
1718
"""
1819

1920
def merge_body(body):

fluent/migrate/transforms.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
from __future__ import unicode_literals
6767

6868
import fluent.syntax.ast as FTL
69+
from .errors import NotSupportedError
6970

7071

7172
def pattern_from_text(value):
@@ -113,6 +114,11 @@ class Source(Transform):
113114
# not be replaced?
114115

115116
def __init__(self, path, key):
117+
if path.endswith('.ftl'):
118+
raise NotSupportedError(
119+
'Migrating translations from Fluent files is not supported '
120+
'({})'.format(path))
121+
116122
self.path = path
117123
self.key = key
118124

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4+
5+
dnt-description =
6+
Send websites a “Do Not Track” signal
7+
that you don’t want to be tracked
8+
dnt-learn-more = Learn more
9+
dnt-always = Always

tests/migrate/fixtures/pl/privacy.dtd

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<!-- This Source Code Form is subject to the terms of the Mozilla Public
2+
- License, v. 2.0. If a copy of the MPL was not distributed with this
3+
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
4+
5+
<!ENTITY doNotTrack.description "Old Description in Polish">
6+
<!ENTITY doNotTrack.learnMore.label "Więcej informacji">
7+
<!ENTITY doNotTrack.always.label "Zawsze">

tests/migrate/fixtures/pl/privacy.ftl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4+
5+
dnt-description = New Description in Polish

tests/migrate/test_concat.py

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
class MockContext(unittest.TestCase):
1818
def get_source(self, path, key):
19+
# Ignore path (test.properties) and get translations from self.strings
20+
# defined in setUp.
1921
return self.strings.get(key, None).val
2022

2123

@@ -36,7 +38,7 @@ def test_concat_one(self):
3638
msg = FTL.Message(
3739
FTL.Identifier('hello'),
3840
value=CONCAT(
39-
COPY(self.strings, 'hello'),
41+
COPY('test.properties', 'hello'),
4042
)
4143
)
4244

@@ -51,8 +53,8 @@ def test_concat_two(self):
5153
msg = FTL.Message(
5254
FTL.Identifier('hello'),
5355
value=CONCAT(
54-
COPY(self.strings, 'hello.start'),
55-
COPY(self.strings, 'hello.end'),
56+
COPY('test.properties', 'hello.start'),
57+
COPY('test.properties', 'hello.end'),
5658
)
5759
)
5860

@@ -86,8 +88,8 @@ def test_concat_whitespace_begin(self):
8688
msg = FTL.Message(
8789
FTL.Identifier('hello'),
8890
value=CONCAT(
89-
COPY(self.strings, 'whitespace.begin.start'),
90-
COPY(self.strings, 'whitespace.begin.end'),
91+
COPY('test.properties', 'whitespace.begin.start'),
92+
COPY('test.properties', 'whitespace.begin.end'),
9193
)
9294
)
9395

@@ -103,8 +105,8 @@ def test_concat_whitespace_end(self):
103105
msg = FTL.Message(
104106
FTL.Identifier('hello'),
105107
value=CONCAT(
106-
COPY(self.strings, 'whitespace.end.start'),
107-
COPY(self.strings, 'whitespace.end.end'),
108+
COPY('test.properties', 'whitespace.end.start'),
109+
COPY('test.properties', 'whitespace.end.end'),
108110
)
109111
)
110112

@@ -129,11 +131,11 @@ def test_concat_literal(self):
129131
msg = FTL.Message(
130132
FTL.Identifier('update-failed'),
131133
value=CONCAT(
132-
COPY(self.strings, 'update.failed.start'),
134+
COPY('test.properties', 'update.failed.start'),
133135
FTL.TextElement('<a>'),
134-
COPY(self.strings, 'update.failed.linkText'),
136+
COPY('test.properties', 'update.failed.linkText'),
135137
FTL.TextElement('</a>'),
136-
COPY(self.strings, 'update.failed.end'),
138+
COPY('test.properties', 'update.failed.end'),
137139
)
138140
)
139141

@@ -157,9 +159,9 @@ def test_concat_replace(self):
157159
msg = FTL.Message(
158160
FTL.Identifier('channel-desc'),
159161
value=CONCAT(
160-
COPY(self.strings, 'channel.description.start'),
162+
COPY('test.properties', 'channel.description.start'),
161163
FTL.Placeable(EXTERNAL_ARGUMENT('channelname')),
162-
COPY(self.strings, 'channel.description.end'),
164+
COPY('test.properties', 'channel.description.end'),
163165
)
164166
)
165167

@@ -187,7 +189,7 @@ def test_concat_replace(self):
187189
FTL.Identifier('community'),
188190
value=CONCAT(
189191
REPLACE(
190-
self.strings,
192+
'test.properties',
191193
'community.start',
192194
{
193195
'&brandShortName;': MESSAGE_REFERENCE(
@@ -197,7 +199,7 @@ def test_concat_replace(self):
197199
),
198200
FTL.TextElement('<a>'),
199201
REPLACE(
200-
self.strings,
202+
'test.properties',
201203
'community.mozillaLink',
202204
{
203205
'&vendorShortName;': MESSAGE_REFERENCE(
@@ -206,11 +208,11 @@ def test_concat_replace(self):
206208
}
207209
),
208210
FTL.TextElement('</a>'),
209-
COPY(self.strings, 'community.middle'),
211+
COPY('test.properties', 'community.middle'),
210212
FTL.TextElement('<a>'),
211-
COPY(self.strings, 'community.creditsLink'),
213+
COPY('test.properties', 'community.creditsLink'),
212214
FTL.TextElement('</a>'),
213-
COPY(self.strings, 'community.end')
215+
COPY('test.properties', 'community.end')
214216
)
215217
)
216218

0 commit comments

Comments
 (0)