| 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 | |
|
Vasily Kuznetsov
2018/10/05 14:51:25
Nit: empty line after the top comment -- I missed
Tudor Avram
2018/10/05 16:28:47
Done.
| |
| 16 import logging | |
| 17 import os | |
| 18 import time | |
| 19 import json | |
| 20 | |
| 21 from cms.utils import process_page | |
| 22 import cms.translations.xtm.constants as const | |
| 23 from cms.translations.xtm.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 | |
| 105 def sanitize_project_name(name): | |
| 106 """Handle project name conflicts. | |
| 107 | |
| 108 Parameters | |
| 109 ---------- | |
| 110 name: str | |
| 111 The name of the project. | |
| 112 | |
| 113 Returns | |
| 114 ------- | |
| 115 str | |
| 116 The new name of the project, with the length cut down to | |
| 117 const.ProjectName.MAX_LENGTH and invalid characters replaced with | |
| 118 const.ProjectName.NAME_WILDCARD. | |
| 119 | |
| 120 """ | |
| 121 valid_name = ''.join( | |
| 122 [const.ProjectName.NAME_WILDCARD | |
| 123 if c in const.ProjectName.INVALID_CHARS else c for c in name], | |
| 124 ) | |
| 125 | |
| 126 return valid_name[:const.ProjectName.MAX_LENGTH] | |
| 127 | |
| 128 | |
| 129 def extract_strings(source): | |
| 130 """Extract strings from a website. | |
| 131 | |
| 132 Parameters | |
| 133 ---------- | |
| 134 source: cms.sources.Source | |
| 135 The source representing the website. | |
| 136 | |
| 137 Returns | |
| 138 ------- | |
| 139 dict | |
| 140 With the extracted strings. | |
| 141 | |
| 142 """ | |
| 143 logging.info(const.InfoMessages.EXTRACTING_STRINGS) | |
| 144 page_strings = collections.defaultdict(collections.OrderedDict) | |
| 145 | |
| 146 defaultlocale = source.read_config().get( | |
| 147 const.Config.MAIN_SECTION, const.Config.DEFAULT_LOCALE_OPTION, | |
| 148 ) | |
| 149 | |
| 150 def record_string(page, locale, name, value, comment, fixed_strings): | |
| 151 if locale != defaultlocale: | |
| 152 return | |
| 153 | |
| 154 store = page_strings[page] | |
| 155 store[name] = {'message': value} | |
| 156 | |
| 157 if fixed_strings: | |
| 158 comment = comment + '\n' if comment else '' | |
| 159 comment += ', '.join('{{{0}}}: {1}'.format(*i_s) | |
| 160 for i_s in enumerate(fixed_strings, 1)) | |
| 161 if comment: | |
| 162 store[name]['description'] = comment | |
| 163 | |
| 164 for page, page_format in source.list_pages(): | |
| 165 process_page(source, defaultlocale, page, format=page_format, | |
| 166 localized_string_callback=record_string) | |
| 167 | |
| 168 return page_strings | |
| 169 | |
| 170 | |
| 171 def resolve_locales(api, source): | |
| 172 """Sync a website's locales with the target languages of the API. | |
| 173 | |
| 174 Parameters | |
| 175 ---------- | |
| 176 api: cms.bin.xtm_translations.xtm_api.XTMCloudAPI | |
| 177 Handler used to make requests to the API. | |
| 178 source: cms.sources.Source | |
| 179 Source representing a website. | |
| 180 | |
| 181 """ | |
| 182 logging.info(const.InfoMessages.RESOLVING_LOCALES) | |
| 183 local_locales = map_locales(source) | |
| 184 project_id = source.read_config().get( | |
| 185 const.Config.XTM_SECTION, const.Config.PROJECT_OPTION, | |
| 186 ) | |
| 187 | |
| 188 languages = run_and_wait( | |
| 189 api.get_target_languages, | |
| 190 XTMCloudException, | |
| 191 const.UNDER_ANALYSIS_MESSAGE, | |
| 192 const.InfoMessages.WAITING_FOR_PROJECT, | |
| 193 project_id=project_id, | |
| 194 ) | |
| 195 | |
| 196 enabled_locales = {l.encode('utf-8') for l in languages} | |
| 197 | |
| 198 if len(enabled_locales - local_locales) != 0: | |
| 199 raise Exception(const.ErrorMessages.LOCALES_NOT_PRESENT.format( | |
| 200 enabled_locales - local_locales, project_id, | |
| 201 )) | |
| 202 | |
| 203 if not local_locales == enabled_locales: | |
| 204 # Add languages to the project | |
| 205 langs_to_add = list(local_locales - enabled_locales) | |
| 206 logging.info(const.InfoMessages.ADDING_LANGUAGES.format( | |
| 207 project_id, langs_to_add, | |
| 208 )) | |
| 209 run_and_wait( | |
| 210 api.add_target_languages, | |
| 211 XTMCloudException, | |
| 212 const.UNDER_ANALYSIS_MESSAGE, | |
| 213 const.InfoMessages.WAITING_FOR_PROJECT, | |
| 214 project_id=project_id, | |
| 215 target_languages=langs_to_add, | |
| 216 ) | |
| 217 | |
| 218 | |
| 219 def map_locales(source): | |
| 220 """Map website locale to target languages supported by XTM. | |
| 221 | |
| 222 Parameters | |
| 223 ---------- | |
| 224 source: cms.sources.Source | |
| 225 Source representing a website. | |
| 226 | |
| 227 Returns | |
| 228 ------- | |
| 229 set | |
| 230 Of the resulting mapped locales. | |
| 231 | |
| 232 """ | |
| 233 config = source.read_config() | |
| 234 defaultlocale = config.get(const.Config.MAIN_SECTION, | |
| 235 const.Config.DEFAULT_LOCALE_OPTION) | |
| 236 locales = source.list_locales() - {defaultlocale} | |
| 237 | |
| 238 mapped_locales = set() | |
| 239 | |
| 240 for locale in locales: | |
| 241 if locale in const.SUPPORTED_LOCALES: | |
| 242 mapped_locales.add(locale) | |
| 243 else: | |
| 244 xtm_locale = '{0}_{1}'.format(locale, locale.upper()) | |
| 245 if xtm_locale in const.SUPPORTED_LOCALES: | |
| 246 mapped_locales.add(xtm_locale) | |
| 247 else: | |
| 248 logging.warning( | |
| 249 const.WarningMessages.LOCALE_NOT_SUPPORTED.format(locale), | |
| 250 ) | |
| 251 | |
| 252 return mapped_locales | |
| 253 | |
| 254 | |
| 255 def local_to_remote(local_names): | |
| 256 """Convert local file names to valid remote ones. | |
| 257 | |
| 258 Parameters | |
| 259 ---------- | |
| 260 local_names: iterable | |
| 261 The local file names(without any extension). | |
|
Vasily Kuznetsov
2018/10/05 14:51:24
Nit: space before the parenthesis
Tudor Avram
2018/10/05 16:28:47
Done.
| |
| 262 | |
| 263 Returns | |
| 264 ------- | |
| 265 dict | |
| 266 With the local files. Each element in the set is a tuple with the | |
| 267 format: | |
| 268 <local_filename>: <remote_filename> | |
| 269 | |
| 270 """ | |
| 271 files = {} | |
| 272 | |
| 273 for page in local_names: | |
| 274 remote_name = '{}.json'.format( | |
| 275 page.replace(os.path.sep, const.FileNames.PATH_SEP_REP), | |
| 276 ) | |
| 277 if len(remote_name) > const.FileNames.MAX_LENGTH: | |
| 278 raise Exception( | |
| 279 const.ErrorMessages.FILENAME_TOO_LONG.format( | |
| 280 '{}.json'.format(page), const.FileNames.MAX_LENGTH, | |
| 281 )) | |
| 282 files[page] = remote_name | |
| 283 | |
| 284 return files | |
| 285 | |
| 286 | |
| 287 def remote_to_local(filename, source_dir, locales): | |
| 288 """Parse a remote filename and construct a local filesystem name. | |
| 289 | |
| 290 Parameters | |
| 291 ---------- | |
| 292 filename: str | |
| 293 The remote filename. | |
| 294 source_dir: str | |
| 295 The path to the source directory of the file. | |
| 296 locales: iterator | |
| 297 Of local language directories. | |
| 298 | |
| 299 Returns | |
| 300 ------- | |
| 301 str | |
| 302 The full path of the file. | |
| 303 | |
| 304 """ | |
| 305 path_elements = filename.split(const.FileNames.PATH_SEP_REP) | |
| 306 if path_elements[0] == '': | |
| 307 path_elements = path_elements[1:] | |
| 308 if path_elements[0] not in locales: | |
| 309 candidate_locale = path_elements[0].split('_')[0] | |
| 310 if candidate_locale not in locales: | |
| 311 raise Exception( | |
| 312 const.ErrorMessages.CANT_RESOLVE_REMOTE_LANG.format( | |
| 313 path_elements[0], | |
| 314 ), | |
| 315 ) | |
| 316 | |
| 317 path_elements[0] = ''.join([ | |
| 318 candidate_locale, | |
| 319 path_elements[0].split('_')[1][len(candidate_locale):], | |
| 320 ]) | |
| 321 | |
| 322 return os.path.join(source_dir, *path_elements) | |
| 323 | |
| 324 | |
| 325 def run_and_wait(func, exc, err_msg, user_msg=None, retry_delay=1, | |
| 326 retries=10, **kw): | |
| 327 """Run a function and, if a specific exception occurs, try again. | |
| 328 | |
| 329 Tries to run the function, and if an exception with a specific message | |
| 330 is raised, it sleeps and tries again. | |
| 331 Parameters | |
| 332 ---------- | |
| 333 func: function | |
| 334 The function to be run. | |
| 335 retry_delay: int | |
| 336 The amount of time to wait on this specific run (in seconds). | |
| 337 exc: Exception | |
| 338 The exception we expect to be raised. | |
| 339 err_msg: str | |
| 340 The message we expect to be in the exception. | |
| 341 user_msg: str | |
| 342 Message to be displayed to the user if we waited for more than 3 steps. | |
| 343 retries: int | |
| 344 The number of retries left until actually raising the exception. | |
| 345 kw: dict | |
| 346 The keyword arguments for the function. | |
| 347 | |
| 348 Returns | |
| 349 ------- | |
| 350 The return result of the function. | |
| 351 | |
| 352 """ | |
| 353 if retries == 7 and user_msg: | |
| 354 logging.info(user_msg) | |
| 355 try: | |
| 356 result = func(**kw) | |
| 357 return result | |
| 358 except exc as err: | |
| 359 if retries == 0: | |
| 360 raise | |
| 361 if err_msg in str(err): | |
| 362 time.sleep(retry_delay) | |
| 363 return run_and_wait( | |
| 364 func, exc, err_msg, user_msg, | |
| 365 min(retry_delay * 2, const.MAX_WAIT_TIME), retries - 1, **kw | |
| 366 ) | |
| 367 raise | |
| 368 | |
| 369 | |
| 370 def clear_files(dir_path, required_locales, extension='.json'): | |
| 371 """Delete translation files with a specific extension from dir_path. | |
| 372 | |
| 373 Parameters | |
| 374 ---------- | |
| 375 dir_path: str | |
| 376 Path to the root of the subtree we want to delete the files from. | |
| 377 required_locales: iterable | |
| 378 Only directories form required_locales will be included | |
| 379 extension: str | |
| 380 The extension of the files to delete. | |
| 381 | |
| 382 """ | |
| 383 for root, dirs, files in os.walk(dir_path, topdown=True): | |
| 384 if root == dir_path: | |
| 385 dirs[:] = [d for d in dirs if d in required_locales] | |
| 386 for f in files: | |
| 387 if f.lower().endswith(extension.lower()): | |
| 388 os.remove(os.path.join(root, f)) | |
| 389 | |
| 390 | |
| 391 def get_locales(path, default): | |
| 392 """List the locales available in a website. | |
| 393 | |
| 394 It will exclude the default language from the list. | |
| 395 | |
| 396 Parameters | |
| 397 ---------- | |
| 398 path: str | |
| 399 The path to the locales directory. | |
| 400 default: str | |
| 401 The default language for the website. | |
| 402 | |
| 403 Returns | |
| 404 ------- | |
| 405 iterable | |
| 406 Of the available locales. | |
| 407 | |
| 408 """ | |
| 409 full_contents = os.listdir(path) | |
| 410 | |
| 411 return [ | |
| 412 d for d in full_contents | |
| 413 if os.path.isdir(os.path.join(path, d)) and d != default | |
| 414 ] | |
| 415 | |
| 416 | |
| 417 def write_to_file(data, file_path): | |
| 418 """Write data to a given file path. | |
| 419 | |
| 420 If the directory path does not exist, then it will be created by default. | |
| 421 | |
| 422 Parameters | |
| 423 ---------- | |
| 424 data: bytes | |
| 425 The data to be written to the file. | |
| 426 file_path: str | |
| 427 The path of the file we want to write. | |
| 428 | |
| 429 """ | |
| 430 dirs_path = os.path.join(*os.path.split(file_path)[:-1]) | |
| 431 if not os.path.isdir(dirs_path): | |
| 432 os.makedirs(dirs_path) | |
| 433 | |
| 434 with open(file_path, 'wb') as f: | |
| 435 f.write(data) | |
| 436 | |
| 437 | |
| 438 def input_fn(text): | |
| 439 try: | |
| 440 return raw_input(text) | |
| 441 except Exception: | |
| 442 return input(text) | |
| OLD | NEW |