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 initial comments Created Sept. 25, 2018, 12:24 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__ = [
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)
OLDNEW

Powered by Google App Engine
This is Rietveld