Left: | ||
Right: |
OLD | NEW |
---|---|
(Empty) | |
1 # This file is part of the Adblock Plus web scripts, | |
2 # Copyright (C) 2006-present eyeo GmbH | |
3 # | |
4 # Adblock Plus is free software: you can redistribute it and/or modify | |
5 # it under the terms of the GNU General Public License version 3 as | |
6 # published by the Free Software Foundation. | |
7 # | |
8 # Adblock Plus is distributed in the hope that it will be useful, | |
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
11 # GNU General Public License for more details. | |
12 # | |
13 # You should have received a copy of the GNU General Public License | |
14 # along with Adblock Plus. If not, see <http://www.gnu.org/licenses/>. | |
15 import collections | |
16 import logging | |
17 import os | |
18 import time | |
19 import json | |
20 | |
21 from cms.utils import process_page | |
22 import cms.bin.xtm_translations.constants as const | |
23 from cms.bin.xtm_translations.xtm_api import XTMCloudException | |
24 | |
25 | |
26 __all__ = [ | |
27 'extract_strings', 'resolve_locales', 'map_locales', 'local_to_remote', | |
28 'remote_to_local', 'run_and_wait', 'sanitize_project_name', 'read_token', | |
29 'get_files_to_upload', 'log_resulting_jobs', 'clear_files', 'input_fn', | |
30 ] | |
31 | |
32 | |
33 def log_resulting_jobs(jobs): | |
34 """Log the jobs created as a result of uploading files/ creating projects. | |
35 | |
36 Parameters | |
37 ---------- | |
38 jobs: iterable | |
39 Of dicts, as returned by XTM. | |
40 | |
41 """ | |
42 if len(jobs) == 0: | |
43 logging.info(const.InfoMessages.NO_JOBS_CREATED) | |
44 return | |
45 | |
46 for job in jobs: | |
47 logging.info( | |
48 const.InfoMessages.CREATED_JOB.format( | |
49 job['jobId'], job['fileName'], job['targetLanguage'], | |
50 ), | |
51 ) | |
52 | |
53 | |
54 def get_files_to_upload(source): | |
55 """Return the files to upload in the format supported by XTMCloudAPI. | |
56 | |
57 It performs the following tasks: | |
58 1. Extracts the translation strings from the website. | |
59 2. Cleanup the extracted strings (i.e. ignore all empty ones). | |
60 3. Convert local file names to remote ones. | |
61 4. Construct a dictionary with the format required by XTMCloudAPI | |
62 | |
63 Parameters | |
64 ---------- | |
65 source: cms.sources.Source | |
66 | |
67 Returns | |
68 ------- | |
69 dict | |
70 With the following format: | |
71 <remote_filename>: <file_data> | |
72 | |
73 """ | |
74 # 1. Extracting strings. | |
75 raw_strings = extract_strings(source) | |
76 page_strings = {} | |
77 | |
78 # 2. Cleaning up. | |
79 for page, string in raw_strings.iteritems(): | |
80 if string: | |
81 page_strings[page] = string | |
82 | |
83 # 3. Converting local file names. | |
84 remote_names = local_to_remote(page_strings) | |
85 | |
86 # 4. Constructing final data structure | |
87 files_to_upload = {} | |
88 for file in remote_names: | |
89 files_to_upload[remote_names[file]] = json.dumps(page_strings[file]) | |
90 | |
91 return files_to_upload | |
92 | |
93 | |
94 def read_token(): | |
95 """Read token from pre-defined environment variable.""" | |
96 token = os.environ.get(const.Token.ENV_VAR) | |
97 if token: | |
98 return token | |
99 | |
100 raise Exception(const.ErrorMessages.NO_TOKEN_PROVIDED.format( | |
101 const.Token.CREATION_CMD, | |
102 )) | |
103 | |
104 # !!! QUESTION !!!: Do we want to add the option for the user to enter | |
105 # the token from the console, pretty much the same way as the password, | |
106 # when logging in? | |
107 | |
108 | |
109 def sanitize_project_name(name): | |
110 """Handle project name conflicts. | |
111 | |
112 Parameters | |
113 ---------- | |
114 name: str | |
115 The name of the project. | |
116 | |
117 Returns | |
118 ------- | |
119 str | |
120 The new name of the project, with the length cut down to | |
121 const.ProjectName.MAX_LENGTH and invalid characters replaced with | |
122 const.ProjectName.NAME_WILDCARD. | |
123 | |
124 """ | |
125 # !!!QUESTION!!!: https://gitlab.com/eyeo/websites/cms/issues/4 defined | |
126 # the naming guidelines when we were going with one project per | |
127 # issue approach. How do they change now that we have a project per | |
128 # website, instead of GitLab issue? | |
129 valid_name = ''.join( | |
130 [const.ProjectName.NAME_WILDCARD | |
131 if c in const.ProjectName.INVALID_CHARS else c for c in name], | |
132 ) | |
133 | |
134 return valid_name[:const.ProjectName.MAX_LENGTH] | |
135 | |
136 | |
137 def extract_strings(source): | |
138 """Extract strings from a website. | |
139 | |
140 Parameters | |
141 ---------- | |
142 source: cms.sources.Source | |
143 The source representing the website. | |
144 | |
145 Returns | |
146 ------- | |
147 dict | |
148 With the extracted strings. | |
149 | |
150 """ | |
151 logging.info(const.InfoMessages.EXTRACTING_STRINGS) | |
152 page_strings = collections.defaultdict(collections.OrderedDict) | |
153 | |
154 defaultlocale = source.read_config().get( | |
155 const.Config.MAIN_SECTION, const.Config.DEFAULT_LOCALE_OPTION, | |
156 ) | |
157 | |
158 def record_string(page, locale, name, value, comment, fixed_strings): | |
159 if locale != defaultlocale: | |
160 return | |
161 | |
162 store = page_strings[page] | |
163 store[name] = {'message': value} | |
164 | |
165 if fixed_strings: | |
166 comment = comment + '\n' if comment else '' | |
167 comment += ', '.join('{{{0}}}: {1}'.format(*i_s) | |
168 for i_s in enumerate(fixed_strings, 1)) | |
169 if comment: | |
170 store[name]['description'] = comment | |
171 | |
172 for page, page_format in source.list_pages(): | |
173 process_page(source, defaultlocale, page, format=page_format, | |
174 localized_string_callback=record_string) | |
175 | |
176 return page_strings | |
177 | |
178 | |
179 def resolve_locales(api, source): | |
180 """Sync a website's locales with the target languages of the API. | |
181 | |
182 Parameters | |
183 ---------- | |
184 api: cms.bin.xtm_translations.xtm_api.XTMCloudAPI | |
185 Handler used to make requests to the API. | |
186 source: cms.sources.Source | |
187 Source representing a website. | |
188 | |
189 """ | |
190 logging.info(const.InfoMessages.RESOLVING_LOCALES) | |
191 local_locales = map_locales(source) | |
192 project_id = source.read_config().get( | |
193 const.Config.XTM_SECTION, const.Config.PROJECT_OPTION, | |
194 ) | |
195 | |
196 languages = run_and_wait( | |
197 api.get_target_languages, | |
198 XTMCloudException, | |
199 const.UNDER_ANALYSIS_MESSAGE, | |
200 const.InfoMessages.WAITING_FOR_PROJECT, | |
201 project_id=project_id, | |
202 ) | |
203 | |
204 enabled_locales = {l.encode('utf-8') for l in languages} | |
205 | |
206 if len(enabled_locales - local_locales) != 0: | |
207 raise Exception(const.ErrorMessages.LOCALES_NOT_PRESENT.format( | |
208 enabled_locales - local_locales, project_id, | |
209 )) | |
210 | |
211 if not local_locales == enabled_locales: | |
212 # Add languages to the project | |
213 langs_to_add = list(local_locales - enabled_locales) | |
214 logging.info(const.InfoMessages.ADDING_LANGUAGES.format( | |
215 project_id, langs_to_add, | |
216 )) | |
217 run_and_wait( | |
218 api.add_target_languages, | |
219 XTMCloudException, | |
220 const.UNDER_ANALYSIS_MESSAGE, | |
221 const.InfoMessages.WAITING_FOR_PROJECT, | |
222 project_id=project_id, | |
223 target_languages=langs_to_add, | |
224 ) | |
225 | |
226 | |
227 def map_locales(source): | |
228 """Map website locale to target languages supported by XTM. | |
229 | |
230 Parameters | |
231 ---------- | |
232 source: cms.sources.Source | |
233 Source representing a website. | |
234 | |
235 Returns | |
236 ------- | |
237 set | |
238 Of the resulting mapped locales. | |
239 | |
240 """ | |
241 config = source.read_config() | |
242 defaultlocale = config.get(const.Config.MAIN_SECTION, | |
243 const.Config.DEFAULT_LOCALE_OPTION) | |
244 locales = source.list_locales() - {defaultlocale} | |
245 | |
246 mapped_locales = set() | |
247 | |
248 for locale in locales: | |
249 if locale in const.SUPPORTED_LOCALES: | |
250 mapped_locales.add(locale) | |
251 else: | |
252 xtm_locale = '{0}_{1}'.format(locale, locale.upper()) | |
253 if xtm_locale in const.SUPPORTED_LOCALES: | |
254 mapped_locales.add(xtm_locale) | |
255 else: | |
256 logging.warning( | |
257 const.WarningMessages.LOCALE_NOT_SUPPORTED.format(locale), | |
258 ) | |
259 | |
260 return mapped_locales | |
261 | |
262 | |
263 def local_to_remote(page_strings): | |
264 """List the local files and directories from the page_strings dictionary. | |
265 | |
266 Parameters | |
267 ---------- | |
268 page_strings: dir | |
Vasily Kuznetsov
2018/10/05 10:56:26
This function is now great, except for the docstri
Tudor Avram
2018/10/05 12:34:40
Done.
| |
269 The parsed strings for all the pages. | |
270 | |
271 Returns | |
272 ------- | |
273 dict | |
274 With the local files. Each element in the set is a tuple with the | |
275 format: | |
276 <local_filename>: <remote_filename> | |
277 | |
278 """ | |
279 files = {} | |
280 | |
281 for page in page_strings: | |
282 remote_name = '{}.json'.format( | |
283 page.replace(os.path.sep, const.FileNames.PATH_SEP_REP), | |
284 ) | |
285 if len(remote_name) > const.FileNames.MAX_LENGTH: | |
286 raise Exception( | |
287 const.ErrorMessages.FILENAME_TOO_LONG.format( | |
288 '{}.json'.format(page), const.FileNames.MAX_LENGTH, | |
289 )) | |
290 files[page] = remote_name | |
291 | |
292 return files | |
293 | |
294 | |
295 def remote_to_local(filename, source_dir, locales): | |
296 """Parse a remote filename and construct a local filesystem name. | |
297 | |
298 Parameters | |
299 ---------- | |
300 filename: str | |
301 The remote filename. | |
302 source_dir: str | |
303 The path to the source directory of the file. | |
304 locales: iterator | |
305 Of local language directories. | |
306 | |
307 Returns | |
308 ------- | |
309 str | |
310 The full path of the file. | |
311 | |
312 """ | |
313 path_elements = filename.split(const.FileNames.PATH_SEP_REP) | |
314 if path_elements[0] == '': | |
315 path_elements = path_elements[1:] | |
316 if path_elements[0] not in locales: | |
317 candidate_locale = path_elements[0].split('_')[0] | |
318 if candidate_locale not in locales: | |
319 raise Exception( | |
320 const.ErrorMessages.CANT_RESOLVE_REMOTE_LANG.format( | |
321 path_elements[0], | |
322 ), | |
323 ) | |
324 | |
325 path_elements[0] = ''.join([ | |
326 candidate_locale, | |
327 path_elements[0].split('_')[1][len(candidate_locale):], | |
328 ]) | |
329 | |
330 return os.path.join(source_dir, *path_elements) | |
331 | |
332 | |
333 def run_and_wait(func, exc, err_msg, user_msg=None, retry_delay=1, | |
334 retries=10, **kw): | |
335 """Run a function and, if a specific exception occurs, try again. | |
336 | |
337 Tries to run the function, and if an exception with a specific message | |
338 is raised, it sleeps and tries again. | |
339 Parameters | |
340 ---------- | |
341 func: function | |
342 The function to be run. | |
343 retry_delay: int | |
344 The amount of time to wait on this specific run (in seconds). | |
345 exc: Exception | |
346 The exception we expect to be raised. | |
347 err_msg: str | |
348 The message we expect to be in the exception. | |
349 user_msg: str | |
350 Message to be displayed to the user if we waited for more than 3 steps. | |
351 retries: int | |
352 The number of retries left until actually raising the exception. | |
353 kw: dict | |
354 The keyword arguments for the function. | |
355 | |
356 Returns | |
357 ------- | |
358 The return result of the function. | |
359 | |
360 """ | |
361 if retries == 7 and user_msg: | |
362 logging.info(user_msg) | |
363 try: | |
364 result = func(**kw) | |
365 return result | |
366 except exc as err: | |
367 if retries == 0: | |
368 raise | |
369 if err_msg in str(err): | |
370 time.sleep(retry_delay) | |
371 return run_and_wait( | |
372 func, exc, err_msg, user_msg, | |
373 min(retry_delay * 2, const.MAX_WAIT_TIME), retries - 1, **kw | |
374 ) | |
375 raise | |
376 | |
377 | |
378 def clear_files(dir_path, required_locales, extension='.json'): | |
379 """Delete translation files with a specific extension from dir_path. | |
380 | |
381 Parameters | |
382 ---------- | |
383 dir_path: str | |
384 Path to the root of the subtree we want to delete the files from. | |
385 required_locales: iterable | |
386 Only directories form required_locales will be included | |
387 extension: str | |
388 The extension of the files to delete. | |
389 | |
390 """ | |
391 for root, dirs, files in os.walk(dir_path, topdown=True): | |
392 if root == dir_path: | |
393 dirs[:] = [d for d in dirs if d in required_locales] | |
394 for f in files: | |
395 if f.lower().endswith(extension.lower()): | |
396 os.remove(os.path.join(root, f)) | |
397 | |
398 | |
399 def get_locales(path, default): | |
400 """List the locales available in a website. | |
401 | |
402 It will exclude the default language from the list. | |
403 | |
404 Parameters | |
405 ---------- | |
406 path: str | |
407 The path to the locales directory. | |
408 default: str | |
409 The default language for the website. | |
410 | |
411 Returns | |
412 ------- | |
413 iterable | |
414 Of the available locales. | |
415 | |
416 """ | |
417 full_contents = os.listdir(path) | |
418 | |
419 return [ | |
420 d for d in full_contents | |
421 if os.path.isdir(os.path.join(path, d)) and d != default | |
422 ] | |
423 | |
424 | |
425 def write_to_file(data, file_path): | |
426 """Write data to a given file path. | |
427 | |
428 If the directory path does not exist, then it will be created by default. | |
429 | |
430 Parameters | |
431 ---------- | |
432 data: bytes | |
433 The data to be written to the file. | |
434 file_path: str | |
435 The path of the file we want to write. | |
436 | |
437 """ | |
438 dirs_path = os.path.join(*os.path.split(file_path)[:-1]) | |
439 if not os.path.isdir(dirs_path): | |
440 os.makedirs(dirs_path) | |
441 | |
442 with open(file_path, 'wb') as f: | |
443 f.write(data) | |
444 | |
445 | |
446 def input_fn(text): | |
447 try: | |
448 return raw_input(text) | |
449 except Exception: | |
450 return input(text) | |
OLD | NEW |