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__ = [ | |
Vasily Kuznetsov
2018/09/26 10:43:16
I have an impression that some of these functions
Tudor Avram
2018/10/04 06:48:05
The idea of this module was to have all the small
| |
27 'extract_strings', 'resolve_locales', 'map_locales', 'get_local_files', | |
28 'resolve_remote_filename', 'run_and_wait', 'resolve_naming_conflicts', | |
29 'read_token', 'get_files_to_upload', 'log_resulting_jobs', 'clear_files', | |
30 'input_fn', | |
31 ] | |
32 | |
33 | |
34 def log_resulting_jobs(jobs): | |
35 """Log the jobs created as a result of uploading files/ creating projects. | |
36 | |
37 Parameters | |
38 ---------- | |
39 jobs: iterable | |
40 Of dicts, as returned by XTM. | |
41 | |
42 """ | |
43 if len(jobs) == 0: | |
44 logging.info(const.InfoMessages.NO_JOBS_CREATED) | |
45 return | |
46 | |
47 for job in jobs: | |
48 logging.info( | |
49 const.InfoMessages.CREATED_JOB.format( | |
50 job['jobId'], job['fileName'], job['targetLanguage'], | |
51 ), | |
52 ) | |
53 | |
54 | |
55 def get_files_to_upload(files, page_strings): | |
56 """Return the files to upload in the format supported by XTMCloudAPI. | |
57 | |
58 Parameters | |
59 ---------- | |
60 files: set | |
61 Of tuples, with (<local_name>, <remote_name>) format. | |
62 page_strings: dict | |
63 Containing the files data. | |
64 | |
65 Returns | |
66 ------- | |
67 generator | |
68 Of (<file_name>, <file_data>) tuples. | |
69 | |
70 """ | |
71 files_to_upload = [] | |
72 for file in files: | |
73 page = os.path.splitext(file[0])[0] | |
74 files_to_upload.append((file[1], json.dumps(page_strings[page]))) | |
75 | |
76 return files_to_upload | |
77 | |
78 | |
79 def read_token(): | |
80 """Read token from pre-defined environment variable.""" | |
81 token = os.environ.get(const.Token.ENV_VAR) | |
82 if token: | |
83 return token | |
84 | |
85 raise Exception(const.ErrorMessages.NO_TOKEN_PROVIDED.format( | |
86 const.Token.CREATION_CMD, | |
87 )) | |
88 | |
89 # !!! QUESTION !!!: Do we want to add the option for the user to enter | |
90 # the token from the console, pretty much the same way as the password, | |
91 # when logging in? | |
92 | |
93 | |
94 def resolve_naming_conflicts(name): | |
Vasily Kuznetsov
2018/09/26 10:43:16
It seems like this doesn't really handle name conf
Tudor Avram
2018/10/04 06:48:04
Done.
| |
95 """Handle project name conflicts. | |
96 | |
97 Parameters | |
98 ---------- | |
99 name: str | |
100 The name of the project. | |
101 | |
102 Returns | |
103 ------- | |
104 str | |
105 The new name of the project, with the length cut down to | |
106 const.ProjectName.MAX_LENGTH and invalid characters replaced with | |
107 const.ProjectName.NAME_WILDCARD. | |
108 | |
109 """ | |
110 # !!!QUESTION!!!: https://gitlab.com/eyeo/websites/cms/issues/4 defined | |
111 # the naming guidelines when we were going with one project per | |
112 # issue approach. How do they change now that we have a project per | |
113 # website, instead of GitLab issue? | |
114 valid_name = ''.join( | |
115 [const.ProjectName.NAME_WILDCARD | |
116 if c in const.ProjectName.INVALID_CHARS else c for c in name], | |
117 ) | |
118 if len(valid_name) > const.ProjectName.MAX_LENGTH: | |
119 return valid_name[:const.ProjectName.MAX_LENGTH] | |
Vasily Kuznetsov
2018/09/26 10:43:16
I would just always do this and remove the if. It
Tudor Avram
2018/10/04 06:48:04
Done.
| |
120 return valid_name | |
121 | |
122 | |
123 def extract_strings(source): | |
124 """Extract strings from a website. | |
125 | |
126 Parameters | |
127 ---------- | |
128 source: cms.sources.Source | |
129 The source representing the website. | |
130 | |
131 Returns | |
132 ------- | |
133 dict | |
134 With the extracted strings. | |
135 | |
136 """ | |
137 logging.info(const.InfoMessages.EXTRACTING_STRINGS) | |
138 page_strings = collections.defaultdict(collections.OrderedDict) | |
139 | |
140 defaultlocale = source.read_config().get( | |
141 const.Config.MAIN_SECTION, const.Config.DEFAULT_LOCALE_OPTION, | |
142 ) | |
143 | |
144 def record_string(page, locale, name, value, comment, fixed_strings): | |
145 if locale != defaultlocale: | |
146 return | |
147 | |
148 store = page_strings[page] | |
149 store[name] = {'message': value} | |
150 | |
151 if fixed_strings: | |
152 comment = comment + '\n' if comment else '' | |
153 comment += ', '.join('{{{0}}}: {1}'.format(*i_s) | |
154 for i_s in enumerate(fixed_strings, 1)) | |
155 if comment: | |
156 store[name]['description'] = comment | |
157 | |
158 for page, page_format in source.list_pages(): | |
159 process_page(source, defaultlocale, page, format=page_format, | |
160 localized_string_callback=record_string) | |
161 return page_strings | |
162 | |
163 | |
164 def resolve_locales(api, source): | |
165 """Sync a website's locales with the target languages of the API. | |
166 | |
167 Parameters | |
168 ---------- | |
169 api: cms.bin.xtm_translations.xtm_api.XTMCloudAPI | |
170 Handler used to make requests to the API. | |
171 source: cms.sources.Source | |
172 Source representing a website. | |
173 | |
174 """ | |
175 logging.info(const.InfoMessages.RESOLVING_LOCALES) | |
176 local_locales = map_locales(source) | |
177 project_id = source.read_config().get( | |
178 const.Config.XTM_SECTION, const.Config.PROJECT_OPTION, | |
179 ) | |
180 | |
181 languages = run_and_wait( | |
182 api.get_target_languages, | |
183 XTMCloudException, | |
184 const.UNDER_ANALYSIS_MESSAGE, | |
185 const.InfoMessages.WAITING_FOR_PROJECT, | |
186 project_id=project_id, | |
187 ) | |
188 | |
189 enabled_locales = {l.encode('utf-8') for l in languages} | |
190 | |
191 if len(enabled_locales - local_locales) != 0: | |
192 raise Exception(const.ErrorMessages.LOCALES_NOT_PRESENT.format( | |
193 enabled_locales - local_locales, project_id, | |
194 )) | |
195 | |
196 if not local_locales == enabled_locales: | |
197 # Add languages to the project | |
198 langs_to_add = list(local_locales - enabled_locales) | |
199 logging.info(const.InfoMessages.ADDING_LANGUAGES.format( | |
200 project_id, langs_to_add, | |
201 )) | |
202 run_and_wait( | |
203 api.add_target_languages, | |
204 XTMCloudException, | |
205 const.UNDER_ANALYSIS_MESSAGE, | |
206 const.InfoMessages.WAITING_FOR_PROJECT, | |
207 project_id=project_id, | |
208 target_languages=langs_to_add, | |
209 ) | |
210 | |
211 | |
212 def map_locales(source): | |
213 """Map website locale to target languages supported by XTM. | |
214 | |
215 Parameters | |
216 ---------- | |
217 source: cms.sources.Source | |
218 Source representing a website. | |
219 | |
220 Returns | |
221 ------- | |
222 set | |
223 Of the resulting mapped locales. | |
224 | |
225 """ | |
226 config = source.read_config() | |
227 defaultlocale = config.get(const.Config.MAIN_SECTION, | |
228 const.Config.DEFAULT_LOCALE_OPTION) | |
229 locales = source.list_locales() - {defaultlocale} | |
230 | |
231 mapped_locales = set() | |
232 | |
233 for locale in locales: | |
234 if locale in const.SUPPORTED_LOCALES: | |
235 mapped_locales.add(locale) | |
236 else: | |
237 xtm_locale = '{0}_{1}'.format(locale, locale.upper()) | |
238 if xtm_locale in const.SUPPORTED_LOCALES: | |
239 mapped_locales.add(xtm_locale) | |
240 else: | |
241 logging.warning( | |
242 const.WarningMessages.LOCALE_NOT_SUPPORTED.format(locale), | |
243 ) | |
244 | |
245 return mapped_locales | |
246 | |
247 | |
248 def get_local_files(page_strings): | |
Vasily Kuznetsov
2018/09/26 10:43:16
This function does 3 things:
- select pages that h
Tudor Avram
2018/10/04 06:48:04
Done.
| |
249 """List the local files and directories from the page_strings dictionary. | |
250 | |
251 Parameters | |
252 ---------- | |
253 page_strings: dir | |
254 The parsed strings for all the pages. | |
255 | |
256 Returns | |
257 ------- | |
258 set | |
Vasily Kuznetsov
2018/09/26 10:43:16
This data structure looks kind of like it wants to
Tudor Avram
2018/10/04 06:48:07
Done.
| |
259 With the local files. Each element in the set is a tuple with the | |
260 format: | |
261 (<local_file_name>, <remote_file_name>) | |
262 | |
263 """ | |
264 files = set() | |
265 | |
266 for page, strings in page_strings.iteritems(): | |
267 if strings: | |
268 page += '.json' | |
269 file = ( | |
270 page, | |
271 page.replace(os.path.sep, const.FILENAMES['path_sep_rep']), | |
272 ) | |
273 if len(file[1]) > const.FILENAMES['max_length']: | |
274 raise Exception(const.ErrorMessages.FILENAME_TOO_LONG.format( | |
275 file[0], const.FILENAMES['max_length'], | |
276 )) | |
277 files.add(file) | |
278 | |
279 return files | |
280 | |
281 | |
282 def resolve_remote_filename(filename, source_dir, locales): | |
Vasily Kuznetsov
2018/09/26 10:43:15
Maybe a better name would be to_local_filename() o
Tudor Avram
2018/10/04 06:48:05
Done.
| |
283 """Parse a remote filename and construct a local filesystem name. | |
284 | |
285 Parameters | |
286 ---------- | |
287 filename: str | |
288 The remote filename. | |
289 source_dir: str | |
290 The path to the source directory of the file. | |
291 locales: iterator | |
292 Of local language directories. | |
293 | |
294 Returns | |
295 ------- | |
296 str | |
297 The full path of the file. | |
298 | |
299 """ | |
300 path_elements = filename.split(const.FILENAMES['path_sep_rep']) | |
301 if path_elements[0] == '': | |
302 path_elements = path_elements[1:] | |
303 if path_elements[0] not in locales: | |
304 candidate_locale = path_elements[0].split('_')[0] | |
305 if candidate_locale not in locales: | |
306 raise Exception( | |
307 const.ErrorMessages.CANT_RESOLVE_REMOTE_LANG.format( | |
308 path_elements[0], | |
309 ), | |
310 ) | |
311 | |
312 path_elements[0] = ''.join([ | |
313 candidate_locale, | |
314 path_elements[0].split('_')[1][len(candidate_locale):], | |
315 ]) | |
316 | |
317 return os.path.join(source_dir, *path_elements) | |
318 | |
319 | |
320 def run_and_wait(func, exp, err_msg, user_msg=None, wait=1, max_tries=10, | |
321 step=1, **kw): | |
322 """Run a function and, if a specific exception occurs, try again. | |
323 | |
324 Tries to run the function, and if an exception with a specific message | |
325 is raised, it sleeps and tries again. | |
326 Parameters | |
327 ---------- | |
328 func: function | |
329 The function to be run. | |
330 wait: int | |
Vasily Kuznetsov
2018/09/26 10:43:16
Maybe this could be called "retry_delay" for more
Tudor Avram
2018/10/04 06:48:03
Done.
| |
331 The amount of time to wait on this specific run | |
332 exp: Exception | |
Vasily Kuznetsov
2018/09/26 10:43:15
I think a better name for exception is exc, exp so
Tudor Avram
2018/10/04 06:48:06
Done.
| |
333 The exception we expect to be raised. | |
334 err_msg: str | |
335 The message we expect to be in the exception. | |
336 user_msg: str | |
337 Message to be displayed to the user if we waited for more than 3 steps. | |
338 max_tries: int | |
Vasily Kuznetsov
2018/09/26 10:43:15
Idea: You can replace this and the following param
Tudor Avram
2018/10/04 06:48:06
Done.
| |
339 The maximum number of tries until giving up. | |
340 step: int | |
341 The try we're at. | |
342 kw: dict | |
343 The keyword arguments for the function. | |
344 | |
345 Returns | |
346 ------- | |
347 The return result of the function. | |
348 | |
349 """ | |
350 if step == 3 and user_msg: | |
351 logging.info(user_msg) | |
352 try: | |
353 result = func(**kw) | |
354 return result | |
355 except exp as err: | |
356 if step > max_tries: | |
357 raise | |
358 if err_msg in str(err): | |
359 time.sleep(wait) | |
360 return run_and_wait( | |
361 func, exp, err_msg, user_msg, | |
362 min(wait * 2, const.MAX_WAIT_TIME), max_tries, step + 1, **kw | |
363 ) | |
364 raise | |
365 | |
366 | |
367 def clear_files(dir_path, required_locales, extension='.json'): | |
368 """Delete files with an extension from the tree starting with dir_path. | |
Vasily Kuznetsov
2018/09/26 10:43:16
It seems like this function is about deleting tran
Tudor Avram
2018/10/04 06:48:07
Done.
| |
369 | |
370 Parameters | |
371 ---------- | |
372 dir_path: str | |
373 Path to the root of the subtree we want to delete the files from. | |
374 required_locales: iterable | |
375 Only directories form required_locales will be included | |
376 extension: str | |
377 The extension of the files to delete. | |
378 | |
379 """ | |
380 for root, dirs, files in os.walk(dir_path, topdown=True): | |
381 if root == dir_path: | |
382 dirs[:] = [d for d in dirs if d in required_locales] | |
383 for f in files: | |
384 if f.lower().endswith(extension.lower()): | |
385 os.remove(os.path.join(root, f)) | |
386 | |
387 | |
388 def get_locales(path, default): | |
Vasily Kuznetsov
2018/09/26 10:43:16
Why don't we use source.list_locales() for this? W
Tudor Avram
2018/10/04 06:48:06
As discussed in person, I need to see all the loca
Vasily Kuznetsov
2018/10/05 10:56:25
Acknowledged.
| |
389 """List the locales available in a website. | |
390 | |
391 It will exclude the default language from the list. | |
392 | |
393 Parameters | |
394 ---------- | |
395 path: str | |
396 The path to the locales directory. | |
397 default: str | |
398 The default language for the website. | |
399 | |
400 Returns | |
401 ------- | |
402 iterable | |
403 Of the available locales. | |
404 | |
405 """ | |
406 full_contents = os.listdir(path) | |
407 | |
408 return [ | |
409 d for d in full_contents | |
410 if os.path.isdir(os.path.join(path, d)) and d != default | |
411 ] | |
412 | |
413 | |
414 def write_to_file(data, file_path): | |
Vasily Kuznetsov
2018/09/26 10:43:16
I wonder if it makes sense to use a similar functi
Tudor Avram
2018/10/04 06:48:05
As discussed in person, there's no such function i
Vasily Kuznetsov
2018/10/05 10:56:24
Acknowledged.
| |
415 """Write data to a given file path. | |
416 | |
417 If the directory path does not exist, then it will be created by default. | |
418 | |
419 Parameters | |
420 ---------- | |
421 data: bytes | |
422 The data to be written to the file. | |
423 file_path: str | |
424 The path of the file we want to write. | |
425 | |
426 """ | |
427 dirs_path = os.path.join(*os.path.split(file_path)[:-1]) | |
428 if not os.path.isdir(dirs_path): | |
429 os.makedirs(dirs_path) | |
430 | |
431 with open(file_path, 'wb') as f: | |
432 f.write(data) | |
433 | |
434 | |
435 def input_fn(text): | |
436 try: | |
437 return raw_input(text) | |
438 except Exception: | |
439 return input(text) | |
OLD | NEW |