OLD | NEW |
(Empty) | |
| 1 #!/usr/bin/python |
| 2 # Copyright (c) 2010 The Chromium Authors. All rights reserved. |
| 3 # Use of this source code is governed by a BSD-style license that can be |
| 4 # found in the LICENSE file. |
| 5 |
| 6 """Class for parsing metadata about extension samples.""" |
| 7 |
| 8 import os |
| 9 import os.path |
| 10 import re |
| 11 import hashlib |
| 12 import simplejson as json |
| 13 |
| 14 def parse_json_file(path, encoding="utf-8"): |
| 15 """ Load the specified file and parse it as JSON. |
| 16 |
| 17 Args: |
| 18 path: Path to a file containing JSON-encoded data. |
| 19 encoding: Encoding used in the file. Defaults to utf-8. |
| 20 |
| 21 Returns: |
| 22 A Python object representing the data encoded in the file. |
| 23 |
| 24 Raises: |
| 25 Exception: If the file could not be read or its contents could not be |
| 26 parsed as JSON data. |
| 27 """ |
| 28 try: |
| 29 json_file = open(path, 'r') |
| 30 except IOError, msg: |
| 31 raise Exception("Failed to read the file at %s: %s" % (path, msg)) |
| 32 |
| 33 try: |
| 34 json_obj = json.load(json_file, encoding) |
| 35 except ValueError, msg: |
| 36 raise Exception("Failed to parse JSON out of file %s: %s" % (path, msg)) |
| 37 |
| 38 json_file.close() |
| 39 return json_obj |
| 40 |
| 41 class ApiManifest(object): |
| 42 """ Represents the list of API methods contained in extension_api.json """ |
| 43 |
| 44 _MODULE_DOC_KEYS = ['functions', 'events'] |
| 45 """ Keys which may be passed to the _parseModuleDocLinksByKey method.""" |
| 46 |
| 47 def __init__(self, manifest_path): |
| 48 """ Read the supplied manifest file and parse its contents. |
| 49 |
| 50 Args: |
| 51 manifest_path: Path to extension_api.json |
| 52 """ |
| 53 self._manifest = parse_json_file(manifest_path) |
| 54 |
| 55 def _getDocLink(self, method, hashprefix): |
| 56 """ |
| 57 Given an API method, return a partial URL corresponding to the doc |
| 58 file for that method. |
| 59 |
| 60 Args: |
| 61 method: A string like 'chrome.foo.bar' or 'chrome.experimental.foo.onBar' |
| 62 hashprefix: The prefix to put in front of hash links - 'method' for |
| 63 methods and 'event' for events. |
| 64 |
| 65 Returns: |
| 66 A string like 'foo.html#method-bar' or 'experimental.foo.html#event-onBar' |
| 67 """ |
| 68 urlpattern = '%%s.html#%s-%%s' % hashprefix |
| 69 urlparts = tuple(method.replace('chrome.', '').rsplit('.', 1)) |
| 70 return urlpattern % urlparts |
| 71 |
| 72 def _parseModuleDocLinksByKey(self, module, key): |
| 73 """ |
| 74 Given a specific API module, returns a dict of methods or events mapped to |
| 75 documentation URLs. |
| 76 |
| 77 Args: |
| 78 module: The data in extension_api.json corresponding to a single module. |
| 79 key: A key belonging to _MODULE_DOC_KEYS to determine which set of |
| 80 methods to parse, and what kind of documentation URL to generate. |
| 81 |
| 82 Returns: |
| 83 A dict of extension methods mapped to file and hash URL parts for the |
| 84 corresponding documentation links, like: |
| 85 { |
| 86 "chrome.tabs.remove": "tabs.html#method-remove", |
| 87 "chrome.tabs.onDetached" : "tabs.html#event-onDetatched" |
| 88 } |
| 89 |
| 90 If the API namespace is defined "nodoc" then an empty dict is returned. |
| 91 |
| 92 Raises: |
| 93 Exception: If the key supplied is not a member of _MODULE_DOC_KEYS. |
| 94 """ |
| 95 methods = [] |
| 96 api_dict = {} |
| 97 namespace = module['namespace'] |
| 98 if module.has_key('nodoc'): |
| 99 return api_dict |
| 100 if key not in self._MODULE_DOC_KEYS: |
| 101 raise Exception("key %s must be one of %s" % (key, self._MODULE_DOC_KEYS)) |
| 102 if module.has_key(key): |
| 103 methods.extend(module[key]) |
| 104 for method in methods: |
| 105 method_name = 'chrome.%s.%s' % (namespace, method['name']) |
| 106 hashprefix = 'method' |
| 107 if key == 'events': |
| 108 hashprefix = 'event' |
| 109 api_dict[method_name] = self._getDocLink(method_name, hashprefix) |
| 110 return api_dict |
| 111 |
| 112 def getModuleNames(self): |
| 113 """ Returns the names of individual modules in the API. |
| 114 |
| 115 Returns: |
| 116 The namespace """ |
| 117 # Exclude modules with a "nodoc" property. |
| 118 return set(module['namespace'].encode() for module in self._manifest |
| 119 if "nodoc" not in module) |
| 120 |
| 121 def getDocumentationLinks(self): |
| 122 """ Parses the extension_api.json manifest and returns a dict of all |
| 123 events and methods for every module, mapped to relative documentation links. |
| 124 |
| 125 Returns: |
| 126 A dict of methods/events => partial doc links for every module. |
| 127 """ |
| 128 api_dict = {} |
| 129 for module in self._manifest: |
| 130 api_dict.update(self._parseModuleDocLinksByKey(module, 'functions')) |
| 131 api_dict.update(self._parseModuleDocLinksByKey(module, 'events')) |
| 132 return api_dict |
| 133 |
| 134 class SamplesManifest(object): |
| 135 """ Represents a manifest file containing information about the sample |
| 136 extensions available in the codebase. """ |
| 137 |
| 138 def __init__(self, base_sample_path, base_dir, api_manifest): |
| 139 """ Reads through the filesystem and obtains information about any Chrome |
| 140 extensions which exist underneath the specified folder. |
| 141 |
| 142 Args: |
| 143 base_sample_path: The directory under which to search for samples. |
| 144 base_dir: The base directory samples will be referenced from. |
| 145 api_manifest: An instance of the ApiManifest class, which will indicate |
| 146 which API methods are available. |
| 147 """ |
| 148 self._base_dir = base_dir |
| 149 manifest_paths = self._locateManifestsFromPath(base_sample_path) |
| 150 self._manifest_data = self._parseManifestData(manifest_paths, api_manifest) |
| 151 |
| 152 def _locateManifestsFromPath(self, path): |
| 153 """ |
| 154 Returns a list of paths to sample extension manifest.json files. |
| 155 |
| 156 Args: |
| 157 base_path: Base path in which to start the search. |
| 158 Returns: |
| 159 A list of paths below base_path pointing at manifest.json files. |
| 160 """ |
| 161 manifest_paths = [] |
| 162 for root, directories, files in os.walk(path): |
| 163 if 'manifest.json' in files: |
| 164 directories = [] # Don't go any further down this tree |
| 165 manifest_paths.append(os.path.join(root, 'manifest.json')) |
| 166 if '.svn' in directories: |
| 167 directories.remove('.svn') # Don't go into SVN metadata directories |
| 168 return manifest_paths |
| 169 |
| 170 def _parseManifestData(self, manifest_paths, api_manifest): |
| 171 """ Returns metadata about the sample extensions given their manifest |
| 172 paths. |
| 173 |
| 174 Args: |
| 175 manifest_paths: A list of paths to extension manifests |
| 176 api_manifest: An instance of the ApiManifest class, which will indicate |
| 177 which API methods are available. |
| 178 |
| 179 Returns: |
| 180 Manifest data containing a list of samples and available API methods. |
| 181 """ |
| 182 api_method_dict = api_manifest.getDocumentationLinks() |
| 183 api_methods = api_method_dict.keys() |
| 184 |
| 185 samples = [] |
| 186 for path in manifest_paths: |
| 187 sample = Sample(path, api_methods, self._base_dir) |
| 188 # Don't render apps |
| 189 if sample.is_app() == False: |
| 190 samples.append(sample) |
| 191 samples.sort(lambda x,y: cmp(x['name'].upper(), y['name'].upper())) |
| 192 |
| 193 manifest_data = {'samples': samples, 'api': api_method_dict} |
| 194 return manifest_data |
| 195 |
| 196 def writeToFile(self, path): |
| 197 """ Writes the contents of this manifest file as a JSON-encoded text file. |
| 198 |
| 199 Args: |
| 200 path: The path to write the samples manifest file to. |
| 201 """ |
| 202 manifest_text = json.dumps(self._manifest_data, indent=2) |
| 203 output_path = os.path.realpath(path) |
| 204 try: |
| 205 output_file = open(output_path, 'w') |
| 206 except IOError, msg: |
| 207 raise Exception("Failed to write the samples manifest file." |
| 208 "The specific error was: %s." % msg) |
| 209 output_file.write(manifest_text) |
| 210 output_file.close() |
| 211 |
| 212 class Sample(dict): |
| 213 """ Represents metadata about a Chrome extension sample. |
| 214 |
| 215 Extends dict so that it can be easily JSON serialized. |
| 216 """ |
| 217 |
| 218 def __init__(self, manifest_path, api_methods, base_dir): |
| 219 """ Initializes a Sample instance given a path to a manifest. |
| 220 |
| 221 Args: |
| 222 manifest_path: A filesystem path to a manifest file. |
| 223 api_methods: A list of strings containing all possible Chrome extension |
| 224 API calls. |
| 225 base_dir: The base directory where this sample will be referenced from - |
| 226 paths will be made relative to this directory. |
| 227 """ |
| 228 self._base_dir = base_dir |
| 229 self._manifest_path = manifest_path |
| 230 self._manifest = parse_json_file(self._manifest_path) |
| 231 self._locale_data = self._parse_locale_data() |
| 232 |
| 233 # The following properties will be serialized when converting this object |
| 234 # to JSON. |
| 235 |
| 236 self['id'] = hashlib.sha1(manifest_path).hexdigest() |
| 237 self['api_calls'] = self._parse_api_calls(api_methods) |
| 238 self['name'] = self._parse_name() |
| 239 self['description'] = self._parse_description() |
| 240 self['icon'] = self._parse_icon() |
| 241 self['features'] = self._parse_features() |
| 242 self['protocols'] = self._parse_protocols() |
| 243 self['path'] = self._get_relative_path() |
| 244 self['search_string'] = self._get_search_string() |
| 245 self['source_files'] = self._parse_source_files() |
| 246 |
| 247 _FEATURE_ATTRIBUTES = ( |
| 248 'browser_action', |
| 249 'page_action', |
| 250 'background_page', |
| 251 'options_page', |
| 252 'plugins', |
| 253 'theme', |
| 254 'chrome_url_overrides' |
| 255 ) |
| 256 """ Attributes that will map to "features" if their corresponding key is |
| 257 present in the extension manifest. """ |
| 258 |
| 259 _SOURCE_FILE_EXTENSIONS = ('.html', '.json', '.js', '.css', '.htm') |
| 260 """ File extensions to files which may contain source code.""" |
| 261 |
| 262 _ENGLISH_LOCALES = ['en_US', 'en', 'en_GB'] |
| 263 """ Locales from which translations may be used in the sample gallery. """ |
| 264 |
| 265 def _get_localized_manifest_value(self, key): |
| 266 """ Returns a localized version of the requested manifest value. |
| 267 |
| 268 Args: |
| 269 key: The manifest key whose value the caller wants translated. |
| 270 |
| 271 Returns: |
| 272 If the supplied value exists and contains a ___MSG_token___ value, this |
| 273 method will resolve the appropriate translation and return the result. |
| 274 If no token exists, the manifest value will be returned. If the key does |
| 275 not exist, an empty string will be returned. |
| 276 |
| 277 Raises: |
| 278 Exception: If the localized value for the given token could not be found. |
| 279 """ |
| 280 if self._manifest.has_key(key): |
| 281 if self._manifest[key][:6] == '__MSG_': |
| 282 try: |
| 283 return self._get_localized_value(self._manifest[key]) |
| 284 except Exception, msg: |
| 285 raise Exception("Could not translate manifest value for key %s: %s" % |
| 286 (key, msg)) |
| 287 else: |
| 288 return self._manifest[key] |
| 289 else: |
| 290 return '' |
| 291 |
| 292 def _get_localized_value(self, message_token): |
| 293 """ Returns the localized version of the requested MSG bundle token. |
| 294 |
| 295 Args: |
| 296 message_token: A message bundle token like __MSG_extensionName__. |
| 297 |
| 298 Returns: |
| 299 The translated text corresponding to the token, with any placeholders |
| 300 automatically resolved and substituted in. |
| 301 |
| 302 Raises: |
| 303 Exception: If a message bundle token is not found in the translations. |
| 304 """ |
| 305 placeholder_pattern = re.compile('\$(\w*)\$') |
| 306 token = message_token[6:-2] |
| 307 if self._locale_data.has_key(token): |
| 308 message = self._locale_data[token]['message'] |
| 309 |
| 310 placeholder_match = placeholder_pattern.search(message) |
| 311 if placeholder_match: |
| 312 # There are placeholders in the translation - substitute them. |
| 313 placeholder_name = placeholder_match.group(1) |
| 314 placeholders = self._locale_data[token]['placeholders'] |
| 315 if placeholders.has_key(placeholder_name.lower()): |
| 316 placeholder_value = placeholders[placeholder_name.lower()]['content'] |
| 317 placeholder_token = '$%s$' % placeholder_name |
| 318 message = message.replace(placeholder_token, placeholder_value) |
| 319 return message |
| 320 else: |
| 321 raise Exception('Could not find localized string: %s' % message_token) |
| 322 |
| 323 def _get_relative_path(self): |
| 324 """ Returns a relative path from the supplied base dir to the manifest dir. |
| 325 |
| 326 This method is used because we may not be able to rely on os.path.relpath |
| 327 which was introduced in Python 2.6 and only works on Windows and Unix. |
| 328 |
| 329 Since the example extensions should always be subdirectories of the |
| 330 base sample manifest path, we can get a relative path through a simple |
| 331 string substitution. |
| 332 |
| 333 Returns: |
| 334 A relative directory path from the sample manifest's directory to the |
| 335 directory containing this sample's manifest.json. |
| 336 """ |
| 337 real_manifest_path = os.path.realpath(self._manifest_path) |
| 338 real_base_path = os.path.realpath(self._base_dir) |
| 339 return real_manifest_path.replace(real_base_path, '')\ |
| 340 .replace('manifest.json', '')[1:] |
| 341 |
| 342 def _get_search_string(self): |
| 343 """ Constructs a string to be used when searching the samples list. |
| 344 |
| 345 To make the implementation of the JavaScript-based search very direct, a |
| 346 string is constructed containing the title, description, API calls, and |
| 347 features that this sample uses, and is converted to uppercase. This makes |
| 348 JavaScript sample searching very fast and easy to implement. |
| 349 |
| 350 Returns: |
| 351 An uppercase string containing information to match on for searching |
| 352 samples on the client. |
| 353 """ |
| 354 search_terms = [ |
| 355 self['name'], |
| 356 self['description'], |
| 357 ] |
| 358 search_terms.extend(self['features']) |
| 359 search_terms.extend(self['api_calls']) |
| 360 search_string = ' '.join(search_terms).replace('"', '')\ |
| 361 .replace('\'', '')\ |
| 362 .upper() |
| 363 return search_string |
| 364 |
| 365 def _parse_api_calls(self, api_methods): |
| 366 """ Returns a list of Chrome extension API calls the sample makes. |
| 367 |
| 368 Parses any *.html and *.js files in the sample directory and matches them |
| 369 against the supplied list of all available API methods, returning methods |
| 370 which show up in the sample code. |
| 371 |
| 372 Args: |
| 373 api_methods: A list of strings containing the potential |
| 374 API calls the and the extension sample could be making. |
| 375 |
| 376 Returns: |
| 377 A set of every member of api_methods that appears in any *.html or *.js |
| 378 file contained in this sample's directory (or subdirectories). |
| 379 |
| 380 Raises: |
| 381 Exception: If any of the *.html or *.js files cannot be read. |
| 382 """ |
| 383 api_calls = set() |
| 384 extension_dir_path = os.path.dirname(self._manifest_path) |
| 385 for root, dirs, files in os.walk(extension_dir_path): |
| 386 for file in files: |
| 387 if file[-5:] == '.html' or file[-3:] == '.js': |
| 388 path = os.path.join(root, file) |
| 389 try: |
| 390 code_file = open(path) |
| 391 except IOError, msg: |
| 392 raise Exception("Failed to read %s: %s" % (path, msg)) |
| 393 code_contents = code_file.read() |
| 394 code_file.close() |
| 395 |
| 396 for method in api_methods: |
| 397 if (code_contents.find(method) > -1): |
| 398 api_calls.add(method) |
| 399 return sorted(api_calls) |
| 400 |
| 401 def _parse_source_files(self): |
| 402 """ Returns a list of paths to source files present in the extenion. |
| 403 |
| 404 Returns: |
| 405 A list of paths relative to the manifest file directory. |
| 406 """ |
| 407 source_paths = [] |
| 408 base_path = os.path.realpath(os.path.dirname(self._manifest_path)) |
| 409 for root, directories, files in os.walk(base_path): |
| 410 if '.svn' in directories: |
| 411 directories.remove('.svn') # Don't go into SVN metadata directories |
| 412 |
| 413 for file_name in files: |
| 414 ext = os.path.splitext(file_name)[1] |
| 415 if ext in self._SOURCE_FILE_EXTENSIONS: |
| 416 path = os.path.realpath(os.path.join(root, file_name)) |
| 417 path = path.replace(base_path, '')[1:] |
| 418 source_paths.append(path) |
| 419 return source_paths |
| 420 |
| 421 def _parse_description(self): |
| 422 """ Returns a localized description of the extension. |
| 423 |
| 424 Returns: |
| 425 A localized version of the sample's description. |
| 426 """ |
| 427 return self._get_localized_manifest_value('description') |
| 428 |
| 429 def _parse_features(self): |
| 430 """ Returns a list of features the sample uses. |
| 431 |
| 432 Returns: |
| 433 A list of features the extension uses, as determined by |
| 434 self._FEATURE_ATTRIBUTES. |
| 435 """ |
| 436 features = set() |
| 437 for feature_attr in self._FEATURE_ATTRIBUTES: |
| 438 if self._manifest.has_key(feature_attr): |
| 439 features.add(feature_attr) |
| 440 |
| 441 if self._uses_popup(): |
| 442 features.add('popup') |
| 443 |
| 444 if self._manifest.has_key('permissions'): |
| 445 for permission in self._manifest['permissions']: |
| 446 split = permission.split('://') |
| 447 if (len(split) == 1): |
| 448 features.add(split[0]) |
| 449 return sorted(features) |
| 450 |
| 451 def _parse_icon(self): |
| 452 """ Returns the path to the 128px icon for this sample. |
| 453 |
| 454 Returns: |
| 455 The path to the 128px icon if defined in the manifest, None otherwise. |
| 456 """ |
| 457 if (self._manifest.has_key('icons') and |
| 458 self._manifest['icons'].has_key('128')): |
| 459 return self._manifest['icons']['128'] |
| 460 else: |
| 461 return None |
| 462 |
| 463 def _parse_locale_data(self): |
| 464 """ Parses this sample's locale data into a dict. |
| 465 |
| 466 Because the sample gallery is in English, this method only looks for |
| 467 translations as defined by self._ENGLISH_LOCALES. |
| 468 |
| 469 Returns: |
| 470 A dict containing the translation keys and corresponding English text |
| 471 for this extension. |
| 472 |
| 473 Raises: |
| 474 Exception: If the messages file cannot be read, or if it is improperly |
| 475 formatted JSON. |
| 476 """ |
| 477 en_messages = {} |
| 478 extension_dir_path = os.path.dirname(self._manifest_path) |
| 479 for locale in self._ENGLISH_LOCALES: |
| 480 en_messages_path = os.path.join(extension_dir_path, '_locales', locale, |
| 481 'messages.json') |
| 482 if (os.path.isfile(en_messages_path)): |
| 483 break |
| 484 |
| 485 if (os.path.isfile(en_messages_path)): |
| 486 try: |
| 487 en_messages_file = open(en_messages_path, 'r') |
| 488 except IOError, msg: |
| 489 raise Exception("Failed to read %s: %s" % (en_messages_path, msg)) |
| 490 en_messages_contents = en_messages_file.read() |
| 491 en_messages_file.close() |
| 492 try: |
| 493 en_messages = json.loads(en_messages_contents) |
| 494 except ValueError, msg: |
| 495 raise Exception("File %s has a syntax error: %s" % |
| 496 (en_messages_path, msg)) |
| 497 return en_messages |
| 498 |
| 499 def _parse_name(self): |
| 500 """ Returns a localized name for the extension. |
| 501 |
| 502 Returns: |
| 503 A localized version of the sample's name. |
| 504 """ |
| 505 return self._get_localized_manifest_value('name') |
| 506 |
| 507 def _parse_protocols(self): |
| 508 """ Returns a list of protocols this extension requests permission for. |
| 509 |
| 510 Returns: |
| 511 A list of every unique protocol listed in the manifest's permssions. |
| 512 """ |
| 513 protocols = [] |
| 514 if self._manifest.has_key('permissions'): |
| 515 for permission in self._manifest['permissions']: |
| 516 split = permission.split('://') |
| 517 if (len(split) == 2) and (split[0] not in protocols): |
| 518 protocols.append(split[0] + "://") |
| 519 return protocols |
| 520 |
| 521 def _uses_background(self): |
| 522 """ Returns true if the extension defines a background page. """ |
| 523 return self._manifest.has_key('background_page') |
| 524 |
| 525 def _uses_browser_action(self): |
| 526 """ Returns true if the extension defines a browser action. """ |
| 527 return self._manifest.has_key('browser_action') |
| 528 |
| 529 def _uses_content_scripts(self): |
| 530 """ Returns true if the extension uses content scripts. """ |
| 531 return self._manifest.has_key('content_scripts') |
| 532 |
| 533 def _uses_options(self): |
| 534 """ Returns true if the extension defines an options page. """ |
| 535 return self._manifest.has_key('options_page') |
| 536 |
| 537 def _uses_page_action(self): |
| 538 """ Returns true if the extension uses a page action. """ |
| 539 return self._manifest.has_key('page_action') |
| 540 |
| 541 def _uses_popup(self): |
| 542 """ Returns true if the extension defines a popup on a page or browser |
| 543 action. """ |
| 544 has_b_popup = (self._uses_browser_action() and |
| 545 self._manifest['browser_action'].has_key('popup')) |
| 546 has_p_popup = (self._uses_page_action() and |
| 547 self._manifest['page_action'].has_key('popup')) |
| 548 return has_b_popup or has_p_popup |
| 549 |
| 550 def is_app(self): |
| 551 """ Returns true if the extension has an 'app' section in its manifest.""" |
| 552 return self._manifest.has_key('app') |
OLD | NEW |