|
1
|
1
|
"""
|
|
2
|
|
-Migration script for running migration recipes.
|
|
|
2
|
+Migration script for running Tor Browser migration recipes.
|
|
3
|
3
|
|
|
4
|
|
-Requires the mozilla fluent.migrate package (available with pip).
|
|
|
4
|
+Based on the Firefox migration script, adapted for Tor Browser. See
|
|
|
5
|
+https://firefox-source-docs.mozilla.org/l10n/migrations/ for more context and
|
|
|
6
|
+information on how to write the migration recipes.
|
|
5
|
7
|
|
|
6
|
|
-NOTE: This will likely send queries to the weblate API as part of the run.
|
|
7
|
|
-Weblate may have a query limit of 100 requests per day for unauthorised users.
|
|
8
|
8
|
|
|
|
9
|
+## Concepts
|
|
|
10
|
+
|
|
|
11
|
++ String. Here, when we say "string", it refers to a piece of text that can be
|
|
|
12
|
+ translated. In practice, you can think of it as a being defined by a triple:
|
|
|
13
|
+ a file path relative to a locale's directory, an ID, and some content. For
|
|
|
14
|
+ example `("./tor-browser.ftl", "my-id", "my-id =\\n .label=Content")`.
|
|
|
15
|
+ When a string is translated the file path and ID stay the same, whilst the
|
|
|
16
|
+ translatable part of the content can vary.
|
|
|
17
|
++ Reference files. These are the en-US source Fluent files that contain the
|
|
|
18
|
+ *manual* string changes made by a developer. For most migrations, there is
|
|
|
19
|
+ only one reference file.
|
|
|
20
|
++ Migration recipe. A collection of instructions that take some string inputs
|
|
|
21
|
+ and transforms them into new strings. These are meant to mirror the manual
|
|
|
22
|
+ changes that were made in the reference files, in a way that can be applied
|
|
|
23
|
+ to any locale.
|
|
|
24
|
++ Translation repository. This refers to a local copy of the `translation`
|
|
|
25
|
+ repository (https://gitlab.torproject.org/tpo/translation). This repository
|
|
|
26
|
+ contains a different branch for different collections of strings. For
|
|
|
27
|
+ Tor Browser we usually use `tor-browser`, `base-browser` and
|
|
|
28
|
+ `fenix-torbrowserstringsxml`. Each branch will contain an `en-US` directory,
|
|
|
29
|
+ which contains the "source" files for translators to translate. These source
|
|
|
30
|
+ files are taken from the `tor-browser` repository. The branches also contain
|
|
|
31
|
+ a directory for each locale that is to be translated, in a file structure that
|
|
|
32
|
+ reflects the source `en-US` directory. This is where the migration script will
|
|
|
33
|
+ write changes to the translated files.
|
|
|
34
|
+
|
|
|
35
|
+
|
|
|
36
|
+## Usage
|
|
|
37
|
+
|
|
|
38
|
+You will likely want to run this with `./mach python`, so that it uses the
|
|
|
39
|
+`fluent.migrate` and `fluent.syntax` packages provided with the `tor-browser`
|
|
|
40
|
+repository.
|
|
|
41
|
+
|
|
|
42
|
+For example, you might run this as
|
|
9
|
43
|
|
|
10
|
|
-Based on the firefox migration script, adapted for tor browser. See
|
|
11
|
|
-https://firefox-source-docs.mozilla.org/l10n/migrations/ for more context and
|
|
12
|
|
-information on how to write the migration recipes.
|
|
|
44
|
+```
|
|
|
45
|
+ ./mach python migrate.py --translation-git /home/user/translation --locales 'es-ES sr' ./migrations/bug-xxxx-my-migration-recipe.py
|
|
|
46
|
+```
|
|
|
47
|
+
|
|
|
48
|
+Here `/home/user/translation` is a local translation repository with one of its
|
|
|
49
|
+branches checked out (see below). This will run the migration recipe given in
|
|
|
50
|
+`./migrations/bug-xxxx-my-migration-recipe.py` for the `es-ES` and `sr`
|
|
|
51
|
+sub-directories within the `translation/` directory.
|
|
|
52
|
+
|
|
|
53
|
+### Changing the reference location
|
|
|
54
|
+
|
|
|
55
|
+You can optionally run the script with `--reference_directory` to read the
|
|
|
56
|
+reference en-US files from somewhere other than the `translation` directory.
|
|
|
57
|
+This can be useful when your manual changes have yet to reach the translation
|
|
|
58
|
+repository, so you want to point the script to read the reference from the
|
|
|
59
|
+`tor-browser` working directory instead. E.g.
|
|
|
60
|
+
|
|
|
61
|
+```
|
|
|
62
|
+ --reference-directory /home/user/tor-browser/toolkit/locales/en-US/toolkit/global/
|
|
|
63
|
+```
|
|
|
64
|
+
|
|
|
65
|
+
|
|
|
66
|
+## Recipes
|
|
|
67
|
+
|
|
|
68
|
+Migration recipes are meant to mirror some manual change that was made to the
|
|
|
69
|
+en-US source strings, in a way that can be reproduced in another locale.
|
|
13
|
70
|
|
|
|
71
|
+### Example 1
|
|
14
|
72
|
|
|
15
|
|
-Usage with an example:
|
|
|
73
|
+For this example, a developer wants to remove the string content from
|
|
|
74
|
+`old-strings.properties`:
|
|
16
|
75
|
|
|
17
|
|
-We have some en-US file "updated.ftl" in the tor-browser repository that we have
|
|
18
|
|
-added new strings to. Some of these strings are copies or concatenations of some
|
|
19
|
|
-already existing strings in a `.dtd`, `.properties` or `.ftl` file (including
|
|
20
|
|
-strings within "updated.ftl").
|
|
|
76
|
+```
|
|
|
77
|
+my.old.string=Some string content
|
|
|
78
|
+my.old.title=This is the tooltip
|
|
|
79
|
+```
|
|
|
80
|
+
|
|
|
81
|
+And add them to `tor-browser.ftl` instead, as:
|
|
21
|
82
|
|
|
22
|
|
-So we also write a migration recipe in
|
|
23
|
|
-`./l10n_migrations/bug-xxxx-my-migration-recipe.py`
|
|
24
|
|
-which includes a `migrate(ctx)` method. This method takes the old strings and
|
|
25
|
|
-create new Messages or Terms for "updated.ftl" using `ctx.add_transforms`. These
|
|
26
|
|
-should match what was already done manually in "updated.ftl".
|
|
|
83
|
+```
|
|
|
84
|
+my-new-string = Some string content
|
|
|
85
|
+ .title = This is the tooltip
|
|
|
86
|
+```
|
|
|
87
|
+
|
|
|
88
|
+For the en-US source strings, the developer simply deletes the two lines in the
|
|
|
89
|
+`old-strings.properties` file (or the whole file, if these are the last two).
|
|
|
90
|
+And they manually write in the content of `my-new-string` to `tor-browser.ftl`.
|
|
|
91
|
+These manual changes will then eventually reach the `translation` repository to
|
|
|
92
|
+be translated.
|
|
|
93
|
+
|
|
|
94
|
+But to save translators making the same manual changes, or re-translating, the
|
|
|
95
|
+developer can write a migration recipe that mirror's their manual changes. In
|
|
|
96
|
+this case, this migration recipe would contain:
|
|
|
97
|
+
|
|
|
98
|
+```
|
|
|
99
|
+ctx.add_transforms(
|
|
|
100
|
+ "tor-browser.ftl", # The path to write to.
|
|
|
101
|
+ "tor-browser.ftl", # The path to the reference file.
|
|
|
102
|
+ transforms_from(
|
|
|
103
|
+ \"\"\"
|
|
|
104
|
+my-new-string = { COPY(path, "my.old.string") }
|
|
|
105
|
+ .title = { COPY(path, "my.old.title") }
|
|
|
106
|
+\"\"\",
|
|
|
107
|
+ path="old-strings.properties", # The path to read from.
|
|
|
108
|
+ ),
|
|
|
109
|
+)
|
|
|
110
|
+```
|
|
|
111
|
+
|
|
|
112
|
+Note, the first argument of `add_transforms` is the target path, which is
|
|
|
113
|
+`tor-browser.ftl` in this case. This means that the migration script will try to
|
|
|
114
|
+write the resulting `my-new-string` to the locale's `./tor-browser.ftl` file.
|
|
|
115
|
+For example, to `translation/es-ES/tor-browser.ftl`.
|
|
|
116
|
+
|
|
|
117
|
+The second argument refers to the *reference* path. In almost all cases the
|
|
|
118
|
+reference file will have the same name and path as the target path, but it has a
|
|
|
119
|
+different role. Here the path is relative to the reference directory, which
|
|
|
120
|
+contains the en-US source files. In this case, by default this means that
|
|
|
121
|
+`translation/en-US/tor-browser.ftl` will act as the reference, but the exact
|
|
|
122
|
+path could be changed with the `--reference-directory` argument. The existing
|
|
|
123
|
+`my-new-string` will be read from this file to confirm that the migration
|
|
|
124
|
+recipe produces the correct result.
|
|
|
125
|
+
|
|
|
126
|
+The file `old-strings.properties` is used as an input. This means that the
|
|
|
127
|
+existing strings in the locale's `old-strings.properties` file will be read to
|
|
|
128
|
+construct the new string `my-new-string`. For example, it will be read from
|
|
|
129
|
+`translation/es-ES/old-strings.properties`.
|
|
|
130
|
+
|
|
|
131
|
+The `COPY` acts like a Fluent function, which asks the transformation to copy
|
|
|
132
|
+the content from the translated string directly.
|
|
|
133
|
+
|
|
|
134
|
+### Example 2
|
|
|
135
|
+
|
|
|
136
|
+For this example, a developer wants to swap a string in `tor-browser.ftl` from:
|
|
|
137
|
+
|
|
|
138
|
+```
|
|
|
139
|
+my-old-string2 = Some string content
|
|
|
140
|
+ .title = This is the tooltip
|
|
|
141
|
+```
|
|
|
142
|
+
|
|
|
143
|
+to
|
|
|
144
|
+
|
|
|
145
|
+```
|
|
|
146
|
+my-new-string2 =
|
|
|
147
|
+ .label = Some string content
|
|
|
148
|
+ .title = This is the tooltip
|
|
|
149
|
+```
|
|
27
|
150
|
|
|
28
|
|
-Then we copy the en-US file to our local translation repository, say at
|
|
29
|
|
-`/home/user/translation/en-US/updated.ftl`. The en-US file is needed as a
|
|
30
|
|
-reference during migration to know where in the new file the new Messages or
|
|
31
|
|
-Terms should be placed. Moreover, the migration can be verified against the
|
|
32
|
|
-en-US file to ensure it is working as intended.
|
|
|
151
|
+The developer removes `my-old-string2` from the file and adds `my-new-string2`
|
|
|
152
|
+instead. This manual change will then eventually reach the `translation`
|
|
|
153
|
+repository for `my-new-string2` to be translated.
|
|
33
|
154
|
|
|
34
|
|
-Then we run the migration for individual locales:
|
|
|
155
|
+The developer also writes a migration recipe to mirror this change:
|
|
35
|
156
|
|
|
36
|
157
|
```
|
|
37
|
|
- python migrate_l10n.py --translation-git /home/user/translation --locales 'es-ES sr' l10n_migrations.bug-xxxx-my-migration-recipe
|
|
|
158
|
+ctx.add_transforms(
|
|
|
159
|
+ "tor-browser.ftl",
|
|
|
160
|
+ "tor-browser.ftl",
|
|
|
161
|
+ transforms_from(
|
|
|
162
|
+ \"\"\"
|
|
|
163
|
+my-new-string2 =
|
|
|
164
|
+ .label = { COPY_PATTERN(path, "my-old-string2") }
|
|
|
165
|
+ .title = { COPY_PATTERN(path, "my-old-string2.title") }
|
|
|
166
|
+\"\"\",
|
|
|
167
|
+ path="tor-browser.ftl",
|
|
|
168
|
+ ),
|
|
|
169
|
+)
|
|
38
|
170
|
```
|
|
39
|
171
|
|
|
40
|
|
-This will first run a mock migration using the en-US localization files. This is
|
|
41
|
|
-to help ensure that the migration will work as intended. I.e. if we ran the
|
|
42
|
|
-migration recipe for en-US, using it as both the source of old strings and as a
|
|
43
|
|
-reference, then each transformation using these old strings should produce a
|
|
44
|
|
-Message or Term that is identical to the one in the reference file.
|
|
|
172
|
+Note that in this case the locale's `tor-browser.ftl` file is being used as both
|
|
|
173
|
+the input and the output.
|
|
|
174
|
+
|
|
|
175
|
+Note that for a Fluent-to-Fluent migration we use `COPY_PATTERN` rather than
|
|
|
176
|
+`COPY`. The second argument provides an ID that either point's to the
|
|
|
177
|
+Fluent Messages' value (roughly, the text content) by giving the Message's ID,
|
|
|
178
|
+or one of its attributes by adding a corresponding suffix to the ID.
|
|
|
179
|
+
|
|
|
180
|
+### Other examples
|
|
|
181
|
+
|
|
|
182
|
+Migration recipes can support a lot more, including more advanced
|
|
|
183
|
+transformations like removing or replacing HTML tags from content. There are
|
|
|
184
|
+some existing recipes in the `./migrations` directory that can be used as a
|
|
|
185
|
+reference.
|
|
|
186
|
+
|
|
|
187
|
+
|
|
|
188
|
+## translation directory
|
|
|
189
|
+
|
|
|
190
|
+In most cases, you will just need to check out a `translation` branch that
|
|
|
191
|
+contains the translated strings used by the migration recipe, as well as the
|
|
|
192
|
+Fluent file that will be written to. Usually, this will be a branch based on
|
|
|
193
|
+the origin/upstream `tor-browser` or `base-browser` branches.
|
|
|
194
|
+
|
|
|
195
|
+However, if the migration recipe requires source files that are in a different
|
|
|
196
|
+branch to the Fluent file that is being written to, then you will need to
|
|
|
197
|
+manually copy those files into the working directory before running the script.
|
|
|
198
|
+
|
|
|
199
|
+
|
|
|
200
|
+## Script procedure
|
|
45
|
201
|
|
|
46
|
|
-Then this will run `migrate(ctx)` for the es-ES and sr locales. Some of the
|
|
47
|
|
-transformations will not be completed for a locale if that locale does not
|
|
48
|
|
-contain a translated version of the old strings the transformation requires
|
|
49
|
|
-(because there is no string to copy).
|
|
|
202
|
+### Test en-US migration
|
|
50
|
203
|
|
|
51
|
|
-If one of the old strings is from a `.dtd` or `.properties` file, this will
|
|
52
|
|
-check whether they are marked as translated on weblate using its API. This
|
|
53
|
|
-prevents copying "placeholder" en-US values into the locale's fluent files.
|
|
|
204
|
+The script will first run a test migration using the en-US strings. It will run
|
|
|
205
|
+the migration using the files from `translation/en-US/` as the input. In the
|
|
|
206
|
+previous example 1, this will read `my.old.string` and `my.old.title` from
|
|
|
207
|
+`translation/en-US/old-strings.properties`. And in example 2, this will read
|
|
|
208
|
+`my-old-string2` from `translation/en-US/tor-browser.ftl`.
|
|
54
|
209
|
|
|
55
|
|
-Any warnings or errors will be logged to stderr in the console.
|
|
|
210
|
+If any of the input strings are missing, this indicates that there is likely a
|
|
|
211
|
+mistake in the recipe or the translation directory and the script will be
|
|
|
212
|
+aborted without running the migration for the other locales.
|
|
|
213
|
+
|
|
|
214
|
+Otherwise, it then compares the result of this migration with the strings found
|
|
|
215
|
+in the reference files. In the previous example 1, this will read the entry for
|
|
|
216
|
+`my-new-string` from `<reference-dir>/tor-browser.ftl`, where `<reference-dir>`
|
|
|
217
|
+is `translation/en-US/` unless `--reference-directory` is given to use a
|
|
|
218
|
+different directory. If the reference entry for `my-new-string` differs from the
|
|
|
219
|
+entry that the recipe automatically produced, then this indicates that there is
|
|
|
220
|
+a mistake in the recipe and the script will be aborted.
|
|
|
221
|
+
|
|
|
222
|
+### Locale migrations
|
|
|
223
|
+
|
|
|
224
|
+If the test migration passed, then the recipe will be re-run for each of the
|
|
|
225
|
+specified locales. If the locale is missing some strings or files that a single
|
|
|
226
|
+transformation requires, then that transformation will be skipped and the
|
|
|
227
|
+corresponding entry won't be written to the Fluent file. Otherwise the new
|
|
|
228
|
+entries will be appended to the existing Fluent files.
|
|
|
229
|
+
|
|
|
230
|
+#### Non-Fluent strings and Weblate queries
|
|
|
231
|
+
|
|
|
232
|
+Some non-Fluent formats, like DTD and properties, will fill untranslated strings
|
|
|
233
|
+with the source en-US content. And we don't want these untranslated strings to
|
|
|
234
|
+be used for migrations. There is no metadata within the files to determine this,
|
|
|
235
|
+and simply being the same as the en-US string is not a reliable indicator.
|
|
|
236
|
+
|
|
|
237
|
+So for these strings the script will additionally make a remote query to the
|
|
|
238
|
+Weblate API to fetch the metadata that indicates whether the string is
|
|
|
239
|
+considered translated or not. If a transformation requires a string that is
|
|
|
240
|
+marked as untranslated, it will be skipped.
|
|
|
241
|
+
|
|
|
242
|
+NOTE: The Weblate metadata is not always reliable, so you should still manually
|
|
|
243
|
+review the changes.
|
|
|
244
|
+
|
|
|
245
|
+NOTE: When testing before, the Weblate API was limited to 100 requests per day
|
|
|
246
|
+per IP address for unauthorised users. If this becomes an issue you may want to
|
|
|
247
|
+run the migrations in limited batches of locales with new IP addresses. Or, you
|
|
|
248
|
+can create a Weblate account and change this script to pass an authorisation
|
|
|
249
|
+token with the request.
|
|
56
|
250
|
"""
|
|
57
|
251
|
|
|
58
|
252
|
import argparse
|
| ... |
... |
@@ -63,142 +257,299 @@ import logging |
|
63
|
257
|
import os
|
|
64
|
258
|
import sys
|
|
65
|
259
|
import urllib.request
|
|
|
260
|
+from collections.abc import Iterator
|
|
|
261
|
+from typing import Any, NamedTuple, Protocol, cast
|
|
66
|
262
|
|
|
67
|
263
|
from fluent.migrate.context import MigrationContext
|
|
68
|
264
|
from fluent.migrate.errors import EmptyLocalizationError, UnreadableReferenceError
|
|
69
|
265
|
from fluent.syntax import ast, parse, serialize
|
|
70
|
266
|
|
|
71
|
267
|
|
|
72
|
|
-def in_red(string):
|
|
|
268
|
+def in_red(string: str) -> str:
|
|
73
|
269
|
"""
|
|
74
|
270
|
Wrap a string so that it is shown as red in the console.
|
|
|
271
|
+ :param string: The string to wrap.
|
|
|
272
|
+ :returns: The wrapped string.
|
|
75
|
273
|
"""
|
|
76
|
274
|
return f"\x1b[1;91m{string}\x1b[0m"
|
|
77
|
275
|
|
|
78
|
276
|
|
|
|
277
|
+def assert_keys(
|
|
|
278
|
+ check_dict: dict[Any, Any], expected_keys: tuple[str, ...], url: str
|
|
|
279
|
+) -> None:
|
|
|
280
|
+ """
|
|
|
281
|
+ Verify that the dictionary contains *at least* the expected keys,
|
|
|
282
|
+ otherwise this throws.
|
|
|
283
|
+ :param check_dict: The dictionary to check.
|
|
|
284
|
+ :param url: The url where the dictionary was fetched from.
|
|
|
285
|
+ """
|
|
|
286
|
+ for key in expected_keys:
|
|
|
287
|
+ if key not in check_dict:
|
|
|
288
|
+ raise ValueError(f"Missing '{key}' in response for {url}")
|
|
|
289
|
+
|
|
|
290
|
+
|
|
|
291
|
+class WeblateUnit:
|
|
|
292
|
+ """
|
|
|
293
|
+ Represents a Weblate "unit": roughly corresponds to a translated string.
|
|
|
294
|
+
|
|
|
295
|
+ See https://docs.weblate.org/en/latest/api.html#units.
|
|
|
296
|
+ """
|
|
|
297
|
+
|
|
|
298
|
+ def __init__(self, data: dict[Any, Any], url: str) -> None:
|
|
|
299
|
+ """
|
|
|
300
|
+ :param data: The Weblate data response.
|
|
|
301
|
+ :param url: The URL that the data came from.
|
|
|
302
|
+ """
|
|
|
303
|
+ assert_keys(data, ("context", "translated"), url)
|
|
|
304
|
+ string_id = data["context"]
|
|
|
305
|
+ translated = data["translated"]
|
|
|
306
|
+ if not isinstance(string_id, str):
|
|
|
307
|
+ raise ValueError(f"Unexpected 'context' type in response for {url}")
|
|
|
308
|
+ if not isinstance(translated, bool):
|
|
|
309
|
+ raise ValueError(f"Unexpected 'translated' type in response for {url}")
|
|
|
310
|
+
|
|
|
311
|
+ self.string_id = string_id
|
|
|
312
|
+ self.translated = translated
|
|
|
313
|
+
|
|
|
314
|
+
|
|
|
315
|
+class WeblateTranslation:
|
|
|
316
|
+ """
|
|
|
317
|
+ Represents a Weblate "translation": roughly corresponds to the translation
|
|
|
318
|
+ of a file for a given locale/language.
|
|
|
319
|
+
|
|
|
320
|
+ See https://docs.weblate.org/en/latest/api.html#translations.
|
|
|
321
|
+ """
|
|
|
322
|
+
|
|
|
323
|
+ def __init__(self, data: dict[Any, Any], url: str) -> None:
|
|
|
324
|
+ """
|
|
|
325
|
+ :param data: The Weblate data response.
|
|
|
326
|
+ :param url: The URL that the data came from.
|
|
|
327
|
+ """
|
|
|
328
|
+ assert_keys(data, ("filename", "units_list_url"), url)
|
|
|
329
|
+ locale_path = data["filename"]
|
|
|
330
|
+ units_url = data["units_list_url"]
|
|
|
331
|
+ if not isinstance(locale_path, str):
|
|
|
332
|
+ raise ValueError(f"Unexpected 'filename' type in response for {url}")
|
|
|
333
|
+ if not isinstance(units_url, str):
|
|
|
334
|
+ raise ValueError(f"Unexpected 'units_list_url' type in response for {url}")
|
|
|
335
|
+
|
|
|
336
|
+ self.locale_path = locale_path
|
|
|
337
|
+ self.units_url = units_url
|
|
|
338
|
+ # The units, indexed by their string ID. Lazy-loaded and `None` whilst
|
|
|
339
|
+ # unfilled.
|
|
|
340
|
+ self.units: None | dict[str, WeblateUnit] = None
|
|
|
341
|
+
|
|
|
342
|
+
|
|
|
343
|
+class WeblateComponent:
|
|
|
344
|
+ """
|
|
|
345
|
+ Represents a Weblate "component": roughly corresponds to a source (en-US)
|
|
|
346
|
+ file that can be translated.
|
|
|
347
|
+
|
|
|
348
|
+ See https://docs.weblate.org/en/latest/api.html#components.
|
|
|
349
|
+ And https://docs.weblate.org/en/latest/user/basic.html.
|
|
|
350
|
+ """
|
|
|
351
|
+
|
|
|
352
|
+ def __init__(self, data: dict[Any, Any], url: str) -> None:
|
|
|
353
|
+ """
|
|
|
354
|
+ :param data: The Weblate data response.
|
|
|
355
|
+ :param url: The URL that the data came from.
|
|
|
356
|
+ """
|
|
|
357
|
+ assert_keys(data, ("template", "translations_url", "branch"), url)
|
|
|
358
|
+ template_path = data["template"]
|
|
|
359
|
+ translations_url = data["translations_url"]
|
|
|
360
|
+ branch = data["branch"]
|
|
|
361
|
+ if not isinstance(template_path, str):
|
|
|
362
|
+ raise ValueError(f"Unexpected 'template' type in response for {url}")
|
|
|
363
|
+ if not isinstance(translations_url, str):
|
|
|
364
|
+ raise ValueError(
|
|
|
365
|
+ f"Unexpected 'translations_url' type in response for {url}"
|
|
|
366
|
+ )
|
|
|
367
|
+ if not isinstance(branch, str):
|
|
|
368
|
+ raise ValueError(f"Unexpected 'branch' type in response for {url}")
|
|
|
369
|
+
|
|
|
370
|
+ self.branch = branch
|
|
|
371
|
+ # The path to the source (en-US) file.
|
|
|
372
|
+ self.template_path = template_path
|
|
|
373
|
+ # The URL to fetch the component's translations from.
|
|
|
374
|
+ self.translations_url = translations_url
|
|
|
375
|
+ # The translations data, indexed by the locale file paths. Lazy-loaded
|
|
|
376
|
+ # and `None` whilst unfilled.
|
|
|
377
|
+ self.translations: None | dict[str, WeblateTranslation] = None
|
|
|
378
|
+
|
|
|
379
|
+
|
|
79
|
380
|
class WeblateMetadata:
|
|
80
|
381
|
"""
|
|
81
|
|
- Manages and fetches string metadata from weblate.
|
|
|
382
|
+ Manages and fetches string metadata from Weblate.
|
|
82
|
383
|
"""
|
|
83
|
384
|
|
|
84
|
|
- def __init__(self):
|
|
|
385
|
+ # The translation branches that we expect to be used for Weblate.
|
|
|
386
|
+ _BROWSER_BRANCHES = ["tor-browser", "base-browser", "fenix-torbrowserstringsxml"]
|
|
|
387
|
+ _WEBLATE_API_URL = "https://hosted.weblate.org/api/"
|
|
|
388
|
+ _WEBLATE_API_TOR_COMPONENTS_URL = _WEBLATE_API_URL + "projects/tor/components/"
|
|
|
389
|
+
|
|
|
390
|
+ def __init__(self) -> None:
|
|
|
391
|
+ """
|
|
|
392
|
+ Create a new instance for fetching Weblate metadata.
|
|
|
393
|
+ """
|
|
85
|
394
|
self.logger = logging.getLogger("weblate-metadata")
|
|
86
|
395
|
self.logger.setLevel(logging.INFO)
|
|
87
|
396
|
|
|
88
|
|
- # The _components property will be lazy-loaded with weblate data.
|
|
89
|
|
- #
|
|
90
|
|
- # On weblate, for monolingual formats, the component will have a
|
|
91
|
|
- # "template" file, which matches the en-US file path in the
|
|
92
|
|
- # "translation" git repo. So we can find a component through a matching
|
|
93
|
|
- # template.
|
|
94
|
|
- #
|
|
95
|
|
- # Each translation in that component will have a "filename" that will
|
|
96
|
|
- # match the locale's file path in the "translation" git repo. So we can
|
|
97
|
|
- # find a translation through a matching filename.
|
|
98
|
|
- #
|
|
99
|
|
- # Each translation can be queried for its units, including metadata.
|
|
100
|
|
- # Each unit's "context" will match the string's id in the corresponding
|
|
101
|
|
- # file.
|
|
102
|
|
- #
|
|
103
|
|
- # Expect the final structure to be:
|
|
104
|
|
- # {
|
|
105
|
|
- # template: {
|
|
106
|
|
- # "translations-url": str, # Used for API translations query.
|
|
107
|
|
- # "translations": {
|
|
108
|
|
- # filename: {
|
|
109
|
|
- # "units-url": str, # Used for API units query.
|
|
110
|
|
- # "units": {
|
|
111
|
|
- # context: {
|
|
112
|
|
- # "translated": bool,
|
|
113
|
|
- # },
|
|
114
|
|
- # },
|
|
115
|
|
- # },
|
|
116
|
|
- # },
|
|
117
|
|
- # },
|
|
118
|
|
- # }
|
|
119
|
|
- self._components = None
|
|
120
|
|
-
|
|
121
|
|
- def _get_weblate_response(self, url):
|
|
|
397
|
+ # The _components property will be lazy-loaded with Weblate data.
|
|
|
398
|
+ self._components: dict[str, WeblateComponent] | None = None
|
|
|
399
|
+
|
|
|
400
|
+ def _get_weblate_response(self, url: str) -> Any:
|
|
|
401
|
+ """
|
|
|
402
|
+ Make a single query to Weblate.
|
|
|
403
|
+ :param url: The URL to fetch.
|
|
|
404
|
+ :returns: The JSON response.
|
|
|
405
|
+ """
|
|
|
406
|
+ if not url.startswith(self._WEBLATE_API_URL):
|
|
|
407
|
+ raise ValueError(f"Unexpected URL request: {url}")
|
|
|
408
|
+
|
|
122
|
409
|
weblate_request = urllib.request.Request(
|
|
123
|
410
|
url,
|
|
124
|
411
|
# NOTE: can set the request header:
|
|
125
|
412
|
# "Authorization": "Token WEBLATE_TOKEN"
|
|
126
|
|
- # to get around rate-limiting
|
|
|
413
|
+ # to get around rate-limiting.
|
|
|
414
|
+ # NOTE: We empty the User-Agent to override the default
|
|
|
415
|
+ # value of "Python-urllib/{major}.{minor}".
|
|
127
|
416
|
headers={"User-Agent": ""},
|
|
128
|
417
|
)
|
|
129
|
418
|
|
|
130
|
|
- with urllib.request.urlopen(weblate_request, timeout=20) as response:
|
|
|
419
|
+ with urllib.request.urlopen(weblate_request) as response:
|
|
131
|
420
|
return json.load(response)
|
|
132
|
421
|
|
|
133
|
|
- def _get_from_weblate(self, url):
|
|
134
|
|
- ret = []
|
|
|
422
|
+ def _get_from_weblate(self, url: str) -> list[dict[Any, Any]]:
|
|
|
423
|
+ """
|
|
|
424
|
+ Fetch a list of results from Weblate.
|
|
|
425
|
+ :param url: The initial URL to fetch a response from.
|
|
|
426
|
+ :returns: The returned list of results.
|
|
|
427
|
+ """
|
|
|
428
|
+ ret: list[dict[Any, Any]] = []
|
|
135
|
429
|
while url:
|
|
136
|
430
|
response = self._get_weblate_response(url)
|
|
|
431
|
+ if not isinstance(response, dict):
|
|
|
432
|
+ raise ValueError(f"Unexpected response type for {url}")
|
|
|
433
|
+ assert_keys(response, ("results", "next"), url)
|
|
|
434
|
+
|
|
|
435
|
+ res_val = response["results"]
|
|
|
436
|
+ if not isinstance(res_val, list):
|
|
|
437
|
+ raise ValueError(f"Unexpected 'results' type for {url}")
|
|
|
438
|
+ for res_item in res_val:
|
|
|
439
|
+ if not isinstance(res_item, dict):
|
|
|
440
|
+ raise ValueError(f"Unexpected 'results' item type for {url}")
|
|
|
441
|
+ ret.append(res_item)
|
|
|
442
|
+
|
|
137
|
443
|
# Continue to fetch the next page, if it is present.
|
|
138
|
|
- # Expect the "next" url to be empty
|
|
|
444
|
+ # Expect the "next" url to be null
|
|
139
|
445
|
# or the same url with "?page=2", etc.
|
|
140
|
|
- url = response["next"]
|
|
141
|
|
- ret.extend(response["results"])
|
|
|
446
|
+ next_val = response["next"]
|
|
|
447
|
+ if next_val is not None and not isinstance(next_val, str):
|
|
|
448
|
+ raise ValueError(f"Unexpected 'next' type in response for {url}")
|
|
|
449
|
+ url = "" if next_val is None else next_val
|
|
142
|
450
|
return ret
|
|
143
|
451
|
|
|
144
|
|
- def _get_components(self):
|
|
|
452
|
+ def _get_components(self) -> dict[str, WeblateComponent]:
|
|
|
453
|
+ """
|
|
|
454
|
+ Get all the Tor Browser Weblate components.
|
|
|
455
|
+
|
|
|
456
|
+ We identify each Weblate component by its template path: the en-US
|
|
|
457
|
+ file path in the translation repository.
|
|
|
458
|
+
|
|
|
459
|
+ NOTE: We assume that the template path uniquely identifies the
|
|
|
460
|
+ component, even though this is not guaranteed in general by Weblate.
|
|
|
461
|
+
|
|
|
462
|
+ :returns: A dictionary of components, indexed by their en-US template
|
|
|
463
|
+ path.
|
|
|
464
|
+ """
|
|
|
465
|
+ url = self._WEBLATE_API_TOR_COMPONENTS_URL
|
|
145
|
466
|
if self._components is None:
|
|
146
|
467
|
self._components = {
|
|
147
|
|
- comp["template"]: {
|
|
148
|
|
- "translations": None,
|
|
149
|
|
- "translations-url": comp["translations_url"],
|
|
150
|
|
- }
|
|
151
|
|
- for comp in self._get_from_weblate(
|
|
152
|
|
- "https://hosted.weblate.org/api/projects/tor/components/"
|
|
|
468
|
+ comp.template_path: comp
|
|
|
469
|
+ for comp in (
|
|
|
470
|
+ WeblateComponent(data, url) for data in self._get_from_weblate(url)
|
|
153
|
471
|
)
|
|
154
|
|
- if comp["template"]
|
|
|
472
|
+ if comp.template_path and comp.branch in self._BROWSER_BRANCHES
|
|
155
|
473
|
}
|
|
156
|
474
|
return self._components
|
|
157
|
475
|
|
|
158
|
|
- def _get_translations(self, template):
|
|
159
|
|
- component = self._get_components().get(template, None)
|
|
|
476
|
+ def _get_translations(
|
|
|
477
|
+ self, template_path: str
|
|
|
478
|
+ ) -> dict[str, WeblateTranslation] | None:
|
|
|
479
|
+ """
|
|
|
480
|
+ Get the Weblate translations for a given Weblate component.
|
|
|
481
|
+
|
|
|
482
|
+ We identify each translation by its file path: the locale-specific path
|
|
|
483
|
+ that the translated file is written to.
|
|
|
484
|
+
|
|
|
485
|
+ :param template_path: The component's en-US template path.
|
|
|
486
|
+ :returns: A dictionary of translations, indexed by their locale file
|
|
|
487
|
+ paths. Or `None` if no components were found.
|
|
|
488
|
+ """
|
|
|
489
|
+ component = self._get_components().get(template_path, None)
|
|
160
|
490
|
if not component:
|
|
161
|
|
- self.logger.warning(f"No component in weblate for {template}.")
|
|
|
491
|
+ self.logger.warning(f"No component in Weblate for {template_path}.")
|
|
162
|
492
|
return None
|
|
163
|
|
- if component["translations"] is None:
|
|
164
|
|
- component["translations"] = {
|
|
165
|
|
- trans["filename"]: {
|
|
166
|
|
- "units": None,
|
|
167
|
|
- "units-url": trans["units_list_url"],
|
|
168
|
|
- }
|
|
169
|
|
- for trans in self._get_from_weblate(component["translations-url"])
|
|
|
493
|
+ if component.translations is None:
|
|
|
494
|
+ url = component.translations_url
|
|
|
495
|
+ component.translations = {
|
|
|
496
|
+ trans.locale_path: trans
|
|
|
497
|
+ for trans in (
|
|
|
498
|
+ WeblateTranslation(data, url)
|
|
|
499
|
+ for data in self._get_from_weblate(url)
|
|
|
500
|
+ )
|
|
170
|
501
|
}
|
|
171
|
|
- return component["translations"]
|
|
|
502
|
+ return component.translations
|
|
|
503
|
+
|
|
|
504
|
+ def _get_units(
|
|
|
505
|
+ self, template_path: str, locale_path: str
|
|
|
506
|
+ ) -> dict[str, WeblateUnit] | None:
|
|
|
507
|
+ """
|
|
|
508
|
+ Get the Weblate units for a given Weblate translation.
|
|
|
509
|
+
|
|
|
510
|
+ We identify each unit by its string ID.
|
|
172
|
511
|
|
|
173
|
|
- def _get_units(self, template, file):
|
|
174
|
|
- translation_dict = self._get_translations(template)
|
|
|
512
|
+ :param template_path: The containing component's en-US template path.
|
|
|
513
|
+ :param locale_path: The translation's locale file path.
|
|
|
514
|
+ :returns: A dictionary of units, indexed by their string ID. Or `None`
|
|
|
515
|
+ if no translations could be found.
|
|
|
516
|
+ """
|
|
|
517
|
+ translation_dict = self._get_translations(template_path)
|
|
175
|
518
|
if translation_dict is None:
|
|
176
|
519
|
return None
|
|
177
|
|
- translation = translation_dict.get(file, None)
|
|
|
520
|
+ translation = translation_dict.get(locale_path, None)
|
|
178
|
521
|
if translation is None:
|
|
179
|
|
- self.logger.warning(f"No translation in weblate for {file}.")
|
|
|
522
|
+ self.logger.warning(f"No translation in Weblate for {locale_path}.")
|
|
180
|
523
|
return None
|
|
181
|
|
- if translation["units"] is None:
|
|
182
|
|
- translation["units"] = {
|
|
183
|
|
- unit["context"]: {
|
|
184
|
|
- "translated": unit["translated"],
|
|
185
|
|
- }
|
|
186
|
|
- for unit in self._get_from_weblate(translation["units-url"])
|
|
|
524
|
+ if translation.units is None:
|
|
|
525
|
+ url = translation.units_url
|
|
|
526
|
+ translation.units = {
|
|
|
527
|
+ unit.string_id: unit
|
|
|
528
|
+ for unit in (
|
|
|
529
|
+ WeblateUnit(data, url) for data in self._get_from_weblate(url)
|
|
|
530
|
+ )
|
|
187
|
531
|
}
|
|
188
|
|
- return translation["units"]
|
|
|
532
|
+ return translation.units
|
|
189
|
533
|
|
|
190
|
|
- def is_translated(self, template_path, locale_path, string_id):
|
|
|
534
|
+ def is_translated(self, file_path: str, locale: str, string_id: str) -> bool:
|
|
191
|
535
|
"""
|
|
192
|
|
- Whether the given string is marked as translated on weblate.
|
|
|
536
|
+ Test whether the given string is marked as translated on Weblate.
|
|
|
537
|
+
|
|
|
538
|
+ :param file_path: The file to check the translation of, relative to the
|
|
|
539
|
+ locale's directory.
|
|
|
540
|
+ :param locale: The locale to check for.
|
|
|
541
|
+ :param string_id: The ID of the string to check.
|
|
193
|
542
|
"""
|
|
|
543
|
+ template_path = os.path.join("en-US", file_path)
|
|
|
544
|
+ locale_path = os.path.join(locale, file_path)
|
|
194
|
545
|
unit_dict = self._get_units(template_path, locale_path)
|
|
195
|
546
|
if unit_dict is None:
|
|
196
|
547
|
return False
|
|
197
|
548
|
unit = unit_dict.get(string_id, None)
|
|
198
|
549
|
if unit is None:
|
|
199
|
|
- self.logger.warning(f"No unit in weblate for {locale_path}:{string_id}.")
|
|
|
550
|
+ self.logger.warning(f"No unit in Weblate for {locale_path}:{string_id}.")
|
|
200
|
551
|
return False
|
|
201
|
|
- return unit["translated"]
|
|
|
552
|
+ return unit.translated
|
|
202
|
553
|
|
|
203
|
554
|
|
|
204
|
555
|
class TorBrowserMigrationContext(MigrationContext):
|
| ... |
... |
@@ -206,12 +557,18 @@ class TorBrowserMigrationContext(MigrationContext): |
|
206
|
557
|
Extension which adds some extra methods to use for tor-browser.
|
|
207
|
558
|
"""
|
|
208
|
559
|
|
|
209
|
|
- def __init__(self, locale, reference_dir, localization_dir):
|
|
|
560
|
+ def __init__(self, locale: str, reference_dir: str, localization_dir: str) -> None:
|
|
210
|
561
|
super().__init__(locale, reference_dir, localization_dir)
|
|
211
|
562
|
|
|
212
|
|
- def _fluent_keys(self, resource):
|
|
|
563
|
+ def _fluent_keys(self, resource: ast.Resource) -> Iterator[str]:
|
|
|
564
|
+ """
|
|
|
565
|
+ Generate all the keys within a Fluent resource, where a key is an
|
|
|
566
|
+ identifier for a Term, a Message, a Term attribute, or a Message
|
|
|
567
|
+ attribute.
|
|
|
568
|
+ :yield: The Fluent keys.
|
|
|
569
|
+ """
|
|
213
|
570
|
# ast.Resource, want to extract all Message and Term identifiers, as
|
|
214
|
|
- # well as their
|
|
|
571
|
+ # well as their attributes.
|
|
215
|
572
|
for entry in resource.body:
|
|
216
|
573
|
if not isinstance(entry, (ast.Term, ast.Message)):
|
|
217
|
574
|
continue
|
| ... |
... |
@@ -223,12 +580,13 @@ class TorBrowserMigrationContext(MigrationContext): |
|
223
|
580
|
for attr in entry.attributes:
|
|
224
|
581
|
yield f"{key}.{attr.id.name}"
|
|
225
|
582
|
|
|
226
|
|
- def tb_get_available_strings(self):
|
|
|
583
|
+ def tb_get_available_strings(self) -> set[tuple[str, str]]:
|
|
227
|
584
|
"""
|
|
228
|
|
- Return all the (path, string_id) pairs for all loaded localization
|
|
|
585
|
+ Get all the (path, string_id) pairs for all loaded localization
|
|
229
|
586
|
resources.
|
|
|
587
|
+ :returns: The string pairs.
|
|
230
|
588
|
"""
|
|
231
|
|
- all_strings = set()
|
|
|
589
|
+ all_strings: set[tuple[str, str]] = set()
|
|
232
|
590
|
# ctx.localization_resources is a dict containing all the loaded
|
|
233
|
591
|
# localization_resources that have been added during add_transforms.
|
|
234
|
592
|
# { localization_path: resource }
|
| ... |
... |
@@ -237,14 +595,13 @@ class TorBrowserMigrationContext(MigrationContext): |
|
237
|
595
|
all_strings.update((path, key) for key in self._fluent_keys(resource))
|
|
238
|
596
|
else:
|
|
239
|
597
|
# dictionary of { identifies: value } in legacy resource.
|
|
240
|
|
- # For tor-browser,
|
|
241
|
|
- # Only include references that differ from the en-US strings.
|
|
242
|
598
|
all_strings.update((path, key) for key in resource.keys())
|
|
243
|
599
|
return all_strings
|
|
244
|
600
|
|
|
245
|
|
- def tb_get_missing_resources(self):
|
|
|
601
|
+ def tb_get_missing_resources(self) -> set[str]:
|
|
246
|
602
|
"""
|
|
247
|
|
- Return the missing localization resources.
|
|
|
603
|
+ Get the missing localization resources.
|
|
|
604
|
+ :returns: The set of resource paths.
|
|
248
|
605
|
"""
|
|
249
|
606
|
return set(
|
|
250
|
607
|
path
|
| ... |
... |
@@ -253,25 +610,51 @@ class TorBrowserMigrationContext(MigrationContext): |
|
253
|
610
|
if path not in self.localization_resources
|
|
254
|
611
|
)
|
|
255
|
612
|
|
|
256
|
|
- def tb_get_transform(self, target_path, transform_id):
|
|
|
613
|
+ def tb_get_transform(
|
|
|
614
|
+ self, target_path: str, transform_id: str
|
|
|
615
|
+ ) -> ast.Message | ast.Term | None:
|
|
257
|
616
|
"""
|
|
258
|
|
- Find the transformation node with the given id for the given path.
|
|
|
617
|
+ Find a transformation node.
|
|
|
618
|
+ :param target_path: The target path of the transformation.
|
|
|
619
|
+ :param transform_id: The ID of the transformation, AKA the string ID.
|
|
|
620
|
+ :returns: The matching transformation node, or `None` if there is no
|
|
|
621
|
+ match.
|
|
259
|
622
|
"""
|
|
260
|
623
|
for node in self.transforms[target_path]:
|
|
261
|
624
|
if node.id.name == transform_id:
|
|
262
|
625
|
return node
|
|
263
|
626
|
return None
|
|
264
|
627
|
|
|
265
|
|
- def tb_get_reference_entry(self, target_path, entry_id):
|
|
|
628
|
+ def tb_get_reference_entry(
|
|
|
629
|
+ self, target_path: str, transform_id: str
|
|
|
630
|
+ ) -> ast.Message | ast.Term | None:
|
|
266
|
631
|
"""
|
|
267
|
|
- Find the reference node that would be used for the given id and path.
|
|
|
632
|
+ Find a reference Message or Term for a given transformation.
|
|
|
633
|
+ :param target_path: The target path of the transformation.
|
|
|
634
|
+ :param transform_id: The ID of the transformation, AKA the string ID.
|
|
|
635
|
+ :returns: The matching reference entry for the transformation, or `None`
|
|
|
636
|
+ if there is no match.
|
|
268
|
637
|
"""
|
|
269
|
638
|
for entry in self.reference_resources[target_path].body:
|
|
270
|
|
- if isinstance(entry, (ast.Term, ast.Message)) and entry.id.name == entry_id:
|
|
|
639
|
+ if (
|
|
|
640
|
+ isinstance(entry, (ast.Term, ast.Message))
|
|
|
641
|
+ and entry.id.name == transform_id
|
|
|
642
|
+ ):
|
|
271
|
643
|
return entry.clone()
|
|
272
|
644
|
return None
|
|
273
|
645
|
|
|
274
|
646
|
|
|
|
647
|
+class RecipeModule(Protocol):
|
|
|
648
|
+ def migrate(self, context: MigrationContext) -> None: ...
|
|
|
649
|
+
|
|
|
650
|
+
|
|
|
651
|
+class FluentSyntaxError(NamedTuple):
|
|
|
652
|
+ path: str
|
|
|
653
|
+ message: str
|
|
|
654
|
+ line: int
|
|
|
655
|
+ sample: str
|
|
|
656
|
+
|
|
|
657
|
+
|
|
275
|
658
|
class TorBrowserMigrator:
|
|
276
|
659
|
"""
|
|
277
|
660
|
Performs a tor-browser migration.
|
| ... |
... |
@@ -279,26 +662,38 @@ class TorBrowserMigrator: |
|
279
|
662
|
|
|
280
|
663
|
def __init__(
|
|
281
|
664
|
self,
|
|
282
|
|
- en_US_dir,
|
|
283
|
|
- locale_dirs,
|
|
284
|
|
- migrate_module,
|
|
285
|
|
- weblate_metadata,
|
|
286
|
|
- ):
|
|
|
665
|
+ en_US_ref_dir: str,
|
|
|
666
|
+ en_US_target_dir: str,
|
|
|
667
|
+ locale_dirs: dict[str, str],
|
|
|
668
|
+ recipe_module: RecipeModule,
|
|
|
669
|
+ weblate_metadata: WeblateMetadata,
|
|
|
670
|
+ ) -> None:
|
|
|
671
|
+ """
|
|
|
672
|
+ :param en_US_ref_dir: The path to the en-US directory where reference
|
|
|
673
|
+ Fluent entries for the recipe can be found.
|
|
|
674
|
+ :param en_US_target_dir: The path to the en-US directory where existing
|
|
|
675
|
+ strings used by the recipe can be found.
|
|
|
676
|
+ :param locale_dirs: The collection of locales to run the migrations for.
|
|
|
677
|
+ :param recipe_module: The python module that contains the recipe
|
|
|
678
|
+ instructions.
|
|
|
679
|
+ :param weblate_metadata: The object to fetch Weblate metadata from.
|
|
|
680
|
+ """
|
|
287
|
681
|
self.logger = logging.getLogger("tor-browser-migrator")
|
|
288
|
682
|
self.logger.setLevel(logging.INFO)
|
|
289
|
|
- self.en_US_dir = en_US_dir
|
|
|
683
|
+ self.en_US_ref_dir = en_US_ref_dir
|
|
|
684
|
+ self.en_US_target_dir = en_US_target_dir
|
|
290
|
685
|
self.locale_dirs = locale_dirs
|
|
291
|
|
- self.migrate_module = migrate_module
|
|
|
686
|
+ self.recipe_module = recipe_module
|
|
292
|
687
|
self.weblate_metadata = weblate_metadata
|
|
293
|
688
|
|
|
294
|
|
- def run(self):
|
|
|
689
|
+ def run(self) -> None:
|
|
295
|
690
|
"""
|
|
296
|
691
|
Run the migration.
|
|
297
|
692
|
"""
|
|
298
|
693
|
if not self._check_en_US_resources():
|
|
299
|
694
|
sys.exit(1)
|
|
300
|
695
|
|
|
301
|
|
- fluent_errors = []
|
|
|
696
|
+ fluent_errors: list[FluentSyntaxError] = []
|
|
302
|
697
|
for locale, locale_dir in self.locale_dirs.items():
|
|
303
|
698
|
if locale == "en-US":
|
|
304
|
699
|
print("", file=sys.stderr)
|
| ... |
... |
@@ -316,12 +711,18 @@ class TorBrowserMigrator: |
|
316
|
711
|
"Migration does not need to be run again, but the following "
|
|
317
|
712
|
"syntax errors should be fixed manually.\n"
|
|
318
|
713
|
+ "\n".join(
|
|
319
|
|
- f"{in_red(full_path)}: line {line}: {message}: [[{sample}]]"
|
|
320
|
|
- for full_path, message, line, sample in fluent_errors
|
|
|
714
|
+ f"{in_red(err.path)}: line {err.line}: {err.message}: [[{err.sample}]]"
|
|
|
715
|
+ for err in fluent_errors
|
|
321
|
716
|
)
|
|
322
|
717
|
)
|
|
323
|
718
|
|
|
324
|
|
- def _run_locale(self, locale, locale_dir):
|
|
|
719
|
+ def _run_locale(self, locale: str, locale_dir: str) -> list[FluentSyntaxError]:
|
|
|
720
|
+ """
|
|
|
721
|
+ Run a migration for a given locale.
|
|
|
722
|
+ :param locale: The name of the locale.
|
|
|
723
|
+ :param locale_dir: The path to the locale's directory.
|
|
|
724
|
+ :returns: A list of Fluent errors in the written files.
|
|
|
725
|
+ """
|
|
325
|
726
|
print("\n\n", file=sys.stderr)
|
|
326
|
727
|
self.logger.info(f"Migrating '{in_red(locale)}' locale\n")
|
|
327
|
728
|
|
| ... |
... |
@@ -358,7 +759,7 @@ class TorBrowserMigrator: |
|
358
|
759
|
|
|
359
|
760
|
available_strings = ctx.tb_get_available_strings()
|
|
360
|
761
|
wrote_file = False
|
|
361
|
|
- errors = []
|
|
|
762
|
+ errors: list[FluentSyntaxError] = []
|
|
362
|
763
|
|
|
363
|
764
|
for target_path, reference in ctx.reference_resources.items():
|
|
364
|
765
|
translated_ids = [
|
| ... |
... |
@@ -422,13 +823,13 @@ class TorBrowserMigrator: |
|
422
|
823
|
break
|
|
423
|
824
|
# Strings in legacy formats might have an entry in the file
|
|
424
|
825
|
# that is just a copy of the en-US strings.
|
|
425
|
|
- # For these we want to check the weblate metadata to ensure
|
|
|
826
|
+ # For these we want to check the Weblate metadata to ensure
|
|
426
|
827
|
# it is a translated string.
|
|
427
|
828
|
if not path.endswith(
|
|
428
|
829
|
".ftl"
|
|
429
|
830
|
) and not self.weblate_metadata.is_translated(
|
|
430
|
|
- os.path.join("en-US", path),
|
|
431
|
|
- os.path.join(locale, path),
|
|
|
831
|
+ path,
|
|
|
832
|
+ locale,
|
|
432
|
833
|
string_id,
|
|
433
|
834
|
):
|
|
434
|
835
|
can_transform = False
|
| ... |
... |
@@ -436,7 +837,7 @@ class TorBrowserMigrator: |
|
436
|
837
|
f"Skipping transform {target_path}:{transform_id} "
|
|
437
|
838
|
f"for '{locale}' locale because the string "
|
|
438
|
839
|
f"{path}:{string_id} has not been translated on "
|
|
439
|
|
- "weblate."
|
|
|
840
|
+ "Weblate."
|
|
440
|
841
|
)
|
|
441
|
842
|
break
|
|
442
|
843
|
if not can_transform:
|
| ... |
... |
@@ -463,7 +864,9 @@ class TorBrowserMigrator: |
|
463
|
864
|
wrote_file = True
|
|
464
|
865
|
# Collect any fluent parsing errors from the newly written file.
|
|
465
|
866
|
errors.extend(
|
|
466
|
|
- (full_path, message, line, sample)
|
|
|
867
|
+ FluentSyntaxError(
|
|
|
868
|
+ path=full_path, message=message, line=line, sample=sample
|
|
|
869
|
+ )
|
|
467
|
870
|
for message, line, sample in self._fluent_errors(full_content)
|
|
468
|
871
|
)
|
|
469
|
872
|
|
| ... |
... |
@@ -471,27 +874,42 @@ class TorBrowserMigrator: |
|
471
|
874
|
self.logger.info(f"No files written for '{locale}' locale.")
|
|
472
|
875
|
return errors
|
|
473
|
876
|
|
|
474
|
|
- def _fluent_errors(self, fluent):
|
|
|
877
|
+ def _fluent_errors(self, fluent_content: str) -> Iterator[tuple[str, int, str]]:
|
|
475
|
878
|
"""
|
|
476
|
|
- Verify that the given fluent string can be parsed correctly.
|
|
|
879
|
+ Parse the given content as a Fluent file and generate all the syntax
|
|
|
880
|
+ errors.
|
|
|
881
|
+ :param fluent_content: The file content to parse.
|
|
|
882
|
+ :yield: Each encountered Fluent syntax error as a triple of the message,
|
|
|
883
|
+ the line number, and a sample.
|
|
477
|
884
|
"""
|
|
478
|
|
- resource = parse(fluent)
|
|
|
885
|
+ resource = parse(fluent_content)
|
|
479
|
886
|
for entry in resource.body:
|
|
480
|
887
|
if not isinstance(entry, ast.Junk):
|
|
481
|
888
|
continue
|
|
482
|
889
|
for annotation in entry.annotations:
|
|
483
|
|
- line = fluent[0 : annotation.span.start].count("\n") + 1
|
|
484
|
|
- sample_start = max(annotation.span.start - 15, 0)
|
|
485
|
|
- sample = "…" + fluent[sample_start : sample_start + 30] + "…"
|
|
486
|
|
- yield annotation.message, line, sample
|
|
487
|
|
-
|
|
488
|
|
- def _get_migration_context(self, locale, locale_dir):
|
|
489
|
|
- prev_missing_resources = set()
|
|
|
890
|
+ start = 0
|
|
|
891
|
+ if annotation.span:
|
|
|
892
|
+ start = annotation.span.start
|
|
|
893
|
+ line = fluent_content[0:start].count("\n") + 1
|
|
|
894
|
+ sample_start = max(start - 15, 0)
|
|
|
895
|
+ sample = "…" + fluent_content[sample_start : sample_start + 30] + "…"
|
|
|
896
|
+ yield annotation.message or "", line, sample
|
|
|
897
|
+
|
|
|
898
|
+ def _get_migration_context(
|
|
|
899
|
+ self, locale: str, locale_dir: str
|
|
|
900
|
+ ) -> TorBrowserMigrationContext:
|
|
|
901
|
+ """
|
|
|
902
|
+ Create a new migration context.
|
|
|
903
|
+ :param locale: The name of the locale.
|
|
|
904
|
+ :param locale_dir: The directory path for the locale.
|
|
|
905
|
+ :returns: The new context.
|
|
|
906
|
+ """
|
|
|
907
|
+ prev_missing_resources: set[str] = set()
|
|
490
|
908
|
while True:
|
|
491
|
|
- ctx = TorBrowserMigrationContext(locale, self.en_US_dir, locale_dir)
|
|
|
909
|
+ ctx = TorBrowserMigrationContext(locale, self.en_US_ref_dir, locale_dir)
|
|
492
|
910
|
|
|
493
|
911
|
try:
|
|
494
|
|
- self.migrate_module.migrate(ctx)
|
|
|
912
|
+ self.recipe_module.migrate(ctx)
|
|
495
|
913
|
except EmptyLocalizationError:
|
|
496
|
914
|
# This case will be handled by missing_resources below.
|
|
497
|
915
|
# NOTE: At the time of writing, add_transforms only throws if
|
| ... |
... |
@@ -543,20 +961,28 @@ class TorBrowserMigrator: |
|
543
|
961
|
# before localization_resources was fully populated.
|
|
544
|
962
|
prev_missing_resources = missing_resources
|
|
545
|
963
|
|
|
546
|
|
- def _remove_if_empty(self, path):
|
|
|
964
|
+ def _remove_if_empty(self, path: str) -> None:
|
|
|
965
|
+ """
|
|
|
966
|
+ Remove a file if it is empty.
|
|
|
967
|
+ :param path: The path to remove.
|
|
|
968
|
+ """
|
|
547
|
969
|
if os.stat(path).st_size:
|
|
548
|
970
|
self.logger.warning(f"{path} is no longer empty. Not deleting.")
|
|
549
|
971
|
return
|
|
550
|
972
|
os.remove(path)
|
|
551
|
973
|
|
|
552
|
|
- def _check_en_US_resources(self):
|
|
553
|
|
- # We pass in the en-US directory as the localization directory, as well
|
|
554
|
|
- # as the reference directory.
|
|
555
|
|
- ctx = TorBrowserMigrationContext("en-US", self.en_US_dir, self.en_US_dir)
|
|
|
974
|
+ def _check_en_US_resources(self) -> bool:
|
|
|
975
|
+ """
|
|
|
976
|
+ Verify that the migration recipe works as expected using en-US strings.
|
|
|
977
|
+ :returns: Whether the recipe worked as expected.
|
|
|
978
|
+ """
|
|
|
979
|
+ ctx = TorBrowserMigrationContext(
|
|
|
980
|
+ "en-US", self.en_US_ref_dir, self.en_US_target_dir
|
|
|
981
|
+ )
|
|
556
|
982
|
|
|
557
|
983
|
have_error = False
|
|
558
|
984
|
try:
|
|
559
|
|
- self.migrate_module.migrate(ctx)
|
|
|
985
|
+ self.recipe_module.migrate(ctx)
|
|
560
|
986
|
except EmptyLocalizationError:
|
|
561
|
987
|
# Handle with localization_resources check.
|
|
562
|
988
|
# NOTE: This throwing may have prevented further add_transforms from
|
| ... |
... |
@@ -566,7 +992,9 @@ class TorBrowserMigrator: |
|
566
|
992
|
have_error = True
|
|
567
|
993
|
except UnreadableReferenceError:
|
|
568
|
994
|
# Reference filename is printed before this.
|
|
569
|
|
- self.logger.error("Missing an en-US reference file.")
|
|
|
995
|
+ self.logger.error(
|
|
|
996
|
+ "Missing an en-US reference file. Is --reference-directory correct?"
|
|
|
997
|
+ )
|
|
570
|
998
|
return False
|
|
571
|
999
|
|
|
572
|
1000
|
# Check each transform would create the same entry in the target as the
|
| ... |
... |
@@ -602,7 +1030,8 @@ class TorBrowserMigrator: |
|
602
|
1030
|
reference_entry = ctx.tb_get_reference_entry(target_path, transform_id)
|
|
603
|
1031
|
if reference_entry is None:
|
|
604
|
1032
|
self.logger.error(
|
|
605
|
|
- f"Missing en-US reference entry for transform {transform_name}"
|
|
|
1033
|
+ f"Missing en-US reference entry for transform {transform_name}. "
|
|
|
1034
|
+ "Is --reference-directory correct?"
|
|
606
|
1035
|
)
|
|
607
|
1036
|
have_error = True
|
|
608
|
1037
|
continue
|
| ... |
... |
@@ -625,9 +1054,50 @@ class TorBrowserMigrator: |
|
625
|
1054
|
return not have_error
|
|
626
|
1055
|
|
|
627
|
1056
|
|
|
|
1057
|
+def main(recipe_path: str, translation_dir: str, locales: str, ref_dir: str) -> None:
|
|
|
1058
|
+ """
|
|
|
1059
|
+ The main method.
|
|
|
1060
|
+ :param recipe_path: The path to the recipe script.
|
|
|
1061
|
+ :param translation_dir: The path to the translation repository directory.
|
|
|
1062
|
+ :param locales: The locales to run the recipe for, separated by spaces.
|
|
|
1063
|
+ :param ref_dir: The path to the reference directory.
|
|
|
1064
|
+ """
|
|
|
1065
|
+ if not os.path.isfile(recipe_path):
|
|
|
1066
|
+ print(f"{in_red(recipe_path)} is not a file.", file=sys.stderr)
|
|
|
1067
|
+ sys.exit(1)
|
|
|
1068
|
+
|
|
|
1069
|
+ recipe_module_spec = importlib.util.spec_from_file_location("_recipe", recipe_path)
|
|
|
1070
|
+ recipe_module = cast(
|
|
|
1071
|
+ RecipeModule, importlib.util.module_from_spec(recipe_module_spec)
|
|
|
1072
|
+ )
|
|
|
1073
|
+ recipe_module_spec.loader.exec_module(recipe_module)
|
|
|
1074
|
+
|
|
|
1075
|
+ def check_dir(path: str) -> str:
|
|
|
1076
|
+ if not os.path.isdir(path):
|
|
|
1077
|
+ print(f"{in_red(path)} is not a directory.", file=sys.stderr)
|
|
|
1078
|
+ sys.exit(1)
|
|
|
1079
|
+ return path
|
|
|
1080
|
+
|
|
|
1081
|
+ translation_dir = check_dir(translation_dir)
|
|
|
1082
|
+ en_US_target_dir = check_dir(os.path.join(translation_dir, "en-US"))
|
|
|
1083
|
+ en_US_ref_dir = en_US_target_dir if ref_dir is None else check_dir(ref_dir)
|
|
|
1084
|
+
|
|
|
1085
|
+ TorBrowserMigrator(
|
|
|
1086
|
+ en_US_ref_dir,
|
|
|
1087
|
+ en_US_target_dir,
|
|
|
1088
|
+ {
|
|
|
1089
|
+ locale: check_dir(os.path.join(translation_dir, locale))
|
|
|
1090
|
+ for locale in (l.strip() for l in locales.split(" "))
|
|
|
1091
|
+ if locale
|
|
|
1092
|
+ },
|
|
|
1093
|
+ recipe_module,
|
|
|
1094
|
+ WeblateMetadata(),
|
|
|
1095
|
+ ).run()
|
|
|
1096
|
+
|
|
|
1097
|
+
|
|
628
|
1098
|
parser = argparse.ArgumentParser(
|
|
629
|
1099
|
description="Run a tor-browser localization migration. "
|
|
630
|
|
- "See documentation within migrate_l10n.py."
|
|
|
1100
|
+ "See documentation within migrate.py."
|
|
631
|
1101
|
)
|
|
632
|
1102
|
parser.add_argument(
|
|
633
|
1103
|
"--translation-git",
|
| ... |
... |
@@ -638,35 +1108,30 @@ parser.add_argument( |
|
638
|
1108
|
parser.add_argument(
|
|
639
|
1109
|
"--locales",
|
|
640
|
1110
|
required=True,
|
|
641
|
|
- metavar="<locale1> <locale2> ...",
|
|
642
|
|
- help="Set of locales to restrict the migration to, separated by space.",
|
|
|
1111
|
+ metavar="'<locale1> <locale2> ...'",
|
|
|
1112
|
+ help="Set of locales to run the migration for, separated by space.",
|
|
643
|
1113
|
)
|
|
644
|
1114
|
parser.add_argument(
|
|
645
|
|
- "migration",
|
|
646
|
|
- help="Migration to run, given as a python module. "
|
|
647
|
|
- 'E.g. "l10n_migrations.my-migration-script".',
|
|
|
1115
|
+ "--reference-directory",
|
|
|
1116
|
+ metavar="<dir>",
|
|
|
1117
|
+ help="Location of the en-US directory that contains the reference changes "
|
|
|
1118
|
+ "that the recipe corresponds to. E.g. if the recipe constructs the string "
|
|
|
1119
|
+ "`my-new-string` in `./tor-browser.ftl`, it should point to a directory "
|
|
|
1120
|
+ "that contains the en-US `tor-browser.ftl` with `my-new-string` already "
|
|
|
1121
|
+ "within it. Defaults to the `en-US/` sub-directory within the "
|
|
|
1122
|
+ "translation-git directory.",
|
|
|
1123
|
+)
|
|
|
1124
|
+parser.add_argument(
|
|
|
1125
|
+ "recipe",
|
|
|
1126
|
+ help="The path to the migration recipe to run.",
|
|
648
|
1127
|
)
|
|
649
|
1128
|
|
|
650
|
1129
|
|
|
651
|
1130
|
parsed_args = parser.parse_args()
|
|
652
|
1131
|
|
|
653
|
|
-
|
|
654
|
|
-def check_dir(path):
|
|
655
|
|
- if not os.path.isdir(path):
|
|
656
|
|
- print(f"{in_red(path)} is not a directory.", file=sys.stderr)
|
|
657
|
|
- sys.exit(1)
|
|
658
|
|
- return path
|
|
659
|
|
-
|
|
660
|
|
-
|
|
661
|
|
-translation_dir = check_dir(os.path.abspath(parsed_args.translation_git))
|
|
662
|
|
-
|
|
663
|
|
-TorBrowserMigrator(
|
|
664
|
|
- check_dir(os.path.join(translation_dir, "en-US")),
|
|
665
|
|
- {
|
|
666
|
|
- locale: check_dir(os.path.join(translation_dir, locale))
|
|
667
|
|
- for locale in (l.strip() for l in parsed_args.locales.split(" "))
|
|
668
|
|
- if locale
|
|
669
|
|
- },
|
|
670
|
|
- importlib.import_module(parsed_args.migration),
|
|
671
|
|
- WeblateMetadata(),
|
|
672
|
|
-).run() |
|
|
1132
|
+main(
|
|
|
1133
|
+ parsed_args.recipe,
|
|
|
1134
|
+ parsed_args.translation_git,
|
|
|
1135
|
+ parsed_args.locales,
|
|
|
1136
|
+ parsed_args.reference_directory,
|
|
|
1137
|
+) |