Rietveld Code Review Tool
Help | Bug tracker | Discussion group | Source code

Side by Side Diff: cms/bin/xtm_translations/utils.py

Issue 29886648: Issue #6942 - Add XTM integration in CMS (Closed)
Patch Set: Addressed comments on patch set #2 Created Oct. 3, 2018, 2:50 p.m.
Left:
Right:
Use n/p to move between diff chunks; N/P to move between comments.
Jump to:
View unified diff | Download patch
OLDNEW
(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)
OLDNEW

Powered by Google App Engine
This is Rietveld