├── COPYING ├── README.mdown ├── mobile_provision.py ├── update_strings.py └── xcode_project.py /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Frédéric Sagnes 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.mdown: -------------------------------------------------------------------------------- 1 | xcode-tools 2 | =========== 3 | 4 | Various xcode build, localization and iPhone-related scripts. 5 | All of these scripts can easily be imported to fit in a bigger project. 6 | Please take a look at the source code for further information. 7 | These scripts are compatible with python 2.5 and 2.6, but mainly tested with 8 | python 2.6. 9 | Mac OS X is obviously the platform of choice for these tools, although the 10 | code should be portable. 11 | 12 | 13 | xcode_project.py 14 | ---------------- 15 | This script parses an [XCode][] project and gives access to all of its targets 16 | and build settings. 17 | If you run it, it will display the contents of the project file: 18 | 19 | $ python xcode_project.py App.xcodeproj 20 | 21 | [XCode]: http://developer.apple.com/technologies/tools/xcode.html 22 | 23 | The `xcode_project.py` script needs the [`plutil`][plutil] command-line tool to run. 24 | 25 | [plutil]: http://developer.apple.com/mac/library/documentation/Darwin/Reference/ManPages/man1/plutil.1.html 26 | 27 | 28 | mobile_provision.py 29 | ------------------- 30 | This script parses a [mobile provision][] and gives access to its name, 31 | devices UDIDs, application identifier and so forth. 32 | If you run it, it will display the contents of the mobile provision file: 33 | 34 | $ python mobile_provision.py app.mobileprovision 35 | 36 | [mobile provision]: http://developer.apple.com/iphone/library/documentation/Xcode/Conceptual/iphone_development/128-Managing_Devices/devices.html 37 | 38 | update_strings.py 39 | ----------------- 40 | This script updates a given [strings file][] with the new strings found in 41 | your project's source code. 42 | You can also import an already-translated strings file that will update your 43 | current strings file. 44 | To run it: 45 | 46 | $ python update_strings.py Localizable.strings 47 | 48 | The `update_strings.py` script needs the [`genstrings`][genstrings] 49 | command-line tool to run. 50 | 51 | [genstrings]: http://developer.apple.com/mac/library/documentation/Darwin/Reference/ManPages/man1/genstrings.1.html 52 | [strings file]: http://developer.apple.com/iphone/library/documentation/MacOSX/Conceptual/BPInternational/Articles/StringsFiles.html 53 | -------------------------------------------------------------------------------- /mobile_provision.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import with_statement 5 | 6 | import plistlib 7 | import os.path 8 | 9 | 10 | class MobileProvisionReadException(Exception): 11 | pass 12 | 13 | 14 | class ApplicationIdentifier(object): 15 | def __init__(self, identifier): 16 | self.identifier = identifier 17 | self.short_identifier = identifier.split('.', 1)[1] 18 | 19 | def __str__(self): 20 | return self.identifier 21 | 22 | 23 | class MobileProvision(object): 24 | def __init__(self, provision_file_path): 25 | self.name = '' 26 | self.uuid = '' 27 | self.application_identifier = None 28 | self.creation_date = None 29 | self.expiration_date = None 30 | self.time_to_live = 0 31 | self.devices_udids = [] 32 | self.is_appstore = False 33 | 34 | if provision_file_path: 35 | self.set_from_provision_file(provision_file_path) 36 | 37 | def set_from_provision_file(self, provision_file_path): 38 | if not os.path.exists(provision_file_path): 39 | raise MobileProvisionReadException( 40 | 'Could not find mobile provision file at path %s' % 41 | provision_file_path 42 | ) 43 | 44 | provision_dict = None 45 | with open(provision_file_path) as provision_file: 46 | provision_data = provision_file.read() 47 | 48 | start_tag = '' 49 | stop_tag = '' 50 | 51 | try: 52 | start_index = provision_data.index(start_tag) 53 | stop_index = provision_data.index( 54 | stop_tag, start_index + len(start_tag) 55 | ) + len(stop_tag) 56 | except ValueError: 57 | raise MobileProvisionReadException( 58 | 'This is not a valid mobile provision file' 59 | ) 60 | 61 | plist_data = provision_data[start_index:stop_index] 62 | provision_dict = plistlib.readPlistFromString(plist_data) 63 | 64 | self.name = provision_dict['Name'] 65 | self.uuid = provision_dict['UUID'] 66 | self.application_identifier = ApplicationIdentifier( 67 | provision_dict['Entitlements']['application-identifier'], 68 | ) 69 | self.creation_date = provision_dict['CreationDate'] 70 | self.expiration_date = provision_dict['ExpirationDate'] 71 | self.time_to_live = provision_dict['TimeToLive'] 72 | 73 | devices_udids = provision_dict.get('ProvisionedDevices', None) 74 | self.devices_udids = devices_udids or [] 75 | self.is_appstore = (devices_udids is None) 76 | 77 | def __str__(self): 78 | return self.name 79 | 80 | 81 | def main(): 82 | logging.basicConfig(format='%(message)s', level=logging.DEBUG) 83 | 84 | if len(sys.argv) < 2: 85 | logging.error('Please specify a provisioning profile file') 86 | logging.error('usage: %s app.mobileprovision', sys.argv[0]) 87 | return 1 88 | 89 | provision_file_path = sys.argv[1] 90 | 91 | logging.info('Scanning file %s', provision_file_path) 92 | 93 | try: 94 | mobile_provision = MobileProvision(provision_file_path) 95 | except MobileProvisionReadException, exc: 96 | logging.error(exc) 97 | return 2 98 | 99 | logging.debug('Name: %s', mobile_provision) 100 | logging.debug('UUID: %s', mobile_provision.uuid) 101 | logging.debug( 102 | 'Application identifier: %s', 103 | mobile_provision.application_identifier 104 | ) 105 | logging.debug( 106 | 'Short application identifier: %s', 107 | mobile_provision.application_identifier.short_identifier 108 | ) 109 | logging.debug('Creation date: %s', mobile_provision.creation_date) 110 | logging.debug('Expiration date: %s', mobile_provision.expiration_date) 111 | logging.debug('Time to live: %s', mobile_provision.time_to_live) 112 | 113 | if mobile_provision.is_appstore: 114 | logging.debug('AppStore: yes') 115 | else: 116 | logging.debug('AppStore: no') 117 | logging.debug('Devices UDIDs:') 118 | 119 | for udid in mobile_provision.devices_udids: 120 | logging.debug(' * %s', udid) 121 | 122 | return 0 123 | 124 | 125 | if __name__ == '__main__': 126 | import logging 127 | import sys 128 | 129 | sys.exit(main()) 130 | -------------------------------------------------------------------------------- /update_strings.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | Update or create an Apple XCode project localization strings file. 6 | 7 | TODO: handle localization domains 8 | ''' 9 | 10 | from __future__ import with_statement 11 | 12 | import sys 13 | import os 14 | import os.path 15 | import re 16 | import tempfile 17 | import subprocess 18 | import codecs 19 | import unittest 20 | import optparse 21 | import shutil 22 | import logging 23 | 24 | 25 | ENCODINGS = ['utf16', 'utf8'] 26 | 27 | 28 | class LocalizedString(object): 29 | ''' A localized string from a strings file ''' 30 | COMMENT_EXPR = re.compile( 31 | # Line start 32 | '^\w*' 33 | # Comment 34 | '/\* (?P.+) \*/' 35 | # End of line 36 | '\w*$' 37 | ) 38 | LOCALIZED_STRING_EXPR = re.compile( 39 | # Line start 40 | '^' 41 | # Key 42 | '"(?P.+)"' 43 | # Equals 44 | ' ?= ?' 45 | # Value 46 | '"(?P.+)"' 47 | # Whitespace 48 | ';' 49 | # Comment 50 | '(?: /\* (?P.+) \*/)?' 51 | # End of line 52 | '$' 53 | ) 54 | 55 | @classmethod 56 | def parse_comment(cls, comment): 57 | ''' 58 | Extract the content of a comment line from a strings file. 59 | Returns the comment string or None if the line doesn't match. 60 | ''' 61 | result = cls.COMMENT_EXPR.match(comment) 62 | if result != None: 63 | return result.group('comment') 64 | else: 65 | return None 66 | 67 | @classmethod 68 | def from_line(cls, line): 69 | ''' 70 | Extract the content of a string line from a strings file. 71 | Returns a LocalizedString instance or None if the line doesn't match. 72 | 73 | TODO: handle whitespace restore 74 | ''' 75 | result = cls.LOCALIZED_STRING_EXPR.match(line) 76 | if result != None: 77 | return cls( 78 | result.group('key'), 79 | result.group('value'), 80 | result.group('comment') 81 | ) 82 | else: 83 | return None 84 | 85 | def __init__(self, key, value=None, comment=None): 86 | super(LocalizedString, self).__init__() 87 | self.key = key 88 | self.value = value 89 | self.comment = comment 90 | 91 | def is_raw(self): 92 | ''' 93 | Return True if the localized string has not been translated. 94 | ''' 95 | return self.value == self.key 96 | 97 | def __str__(self): 98 | if self.comment: 99 | return '"%s" = "%s"; /* %s */' % ( 100 | self.key or '', self.value or '', self.comment 101 | ) 102 | else: 103 | return '"%s" = "%s";' % (self.key or '', self.value or '') 104 | 105 | 106 | def strings_from_folder(folder_path, extensions=None): 107 | ''' 108 | Recursively scan folder_path for files containing localizable strings. 109 | Run genstrings on these files and extract the strings. 110 | Returns a dictionnary of LocalizedString instances, indexed by key. 111 | ''' 112 | localized_strings = {} 113 | code_file_paths = [] 114 | if extensions == None: 115 | extensions = frozenset(['c', 'm', 'mm']) 116 | 117 | logging.debug('Scanning for source files in %s', folder_path) 118 | for dir_path, dir_names, file_names in os.walk(folder_path): 119 | for file_name in file_names: 120 | extension = file_name.rpartition('.')[2] 121 | if extension in extensions: 122 | code_file_path = os.path.join(dir_path, file_name) 123 | code_file_paths.append(code_file_path) 124 | 125 | logging.debug('Found %d files', len(code_file_paths)) 126 | logging.debug('Running genstrings') 127 | temp_folder_path = tempfile.mkdtemp() 128 | arguments = ['genstrings', '-u', '-o', temp_folder_path] 129 | arguments.extend(code_file_paths) 130 | subprocess.call(arguments) 131 | 132 | temp_file_path = os.path.join(temp_folder_path, 'Localizable.strings') 133 | if os.path.exists(temp_file_path): 134 | logging.debug('Analysing genstrings content') 135 | localized_strings = strings_from_file(temp_file_path) 136 | os.remove(temp_file_path) 137 | else: 138 | logging.debug('No translations found') 139 | 140 | shutil.rmtree(temp_folder_path) 141 | 142 | return localized_strings 143 | 144 | 145 | def strings_from_file(file_path): 146 | ''' 147 | Try to autodetect file encoding and call strings_from_encoded_file on the 148 | file at file_path. 149 | Returns a dictionnary of LocalizedString instances, indexed by key. 150 | Returns an empty dictionnary if the encoding is wrong. 151 | ''' 152 | for current_encoding in ENCODINGS: 153 | try: 154 | return strings_from_encoded_file(file_path, current_encoding) 155 | except UnicodeError: 156 | pass 157 | 158 | logging.error( 159 | 'Cannot determine encoding for file %s among %s', 160 | file_path, 161 | ', '.join(ENCODINGS) 162 | ) 163 | 164 | return {} 165 | 166 | 167 | def strings_from_encoded_file(file_path, encoding): 168 | ''' 169 | Extract the strings from the file at file_path. 170 | Returns a dictionnary of LocalizedString instances, indexed by key. 171 | ''' 172 | localized_strings = {} 173 | 174 | with codecs.open(file_path, 'r', encoding) as content: 175 | comment = None 176 | 177 | for line in content: 178 | line = line.strip() 179 | if not line: 180 | comment = None 181 | continue 182 | 183 | current_comment = LocalizedString.parse_comment(line) 184 | if current_comment: 185 | if current_comment != 'No comment provided by engineer.': 186 | comment = current_comment 187 | continue 188 | 189 | localized_string = LocalizedString.from_line(line) 190 | if localized_string: 191 | if not localized_string.comment: 192 | localized_string.comment = comment 193 | localized_strings[localized_string.key] = localized_string 194 | else: 195 | logging.error('Could not parse: %s', line.strip()) 196 | 197 | return localized_strings 198 | 199 | 200 | def strings_to_file(localized_strings, file_path, encoding='utf16'): 201 | ''' 202 | Write a strings file at file_path containing string in 203 | the localized_strings dictionnary. 204 | The strings are alphabetically sorted. 205 | ''' 206 | with codecs.open(file_path, 'w', encoding) as output: 207 | for localized_string in sorted_strings_from_dict(localized_strings): 208 | output.write('%s\n' % localized_string) 209 | 210 | 211 | def update_file_with_strings(file_path, localized_strings): 212 | ''' 213 | Try to autodetect file encoding and call update_encoded_file_with_strings 214 | on the file at file_path. 215 | The file at file_path must exist or this function will raise an exception. 216 | ''' 217 | for current_encoding in ENCODINGS: 218 | try: 219 | return update_encoded_file_with_strings( 220 | file_path, 221 | localized_strings, 222 | current_encoding 223 | ) 224 | except UnicodeError: 225 | pass 226 | 227 | logging.error( 228 | 'Cannot determine encoding for file %s among %s', 229 | file_path, 230 | ', '.join(ENCODINGS) 231 | ) 232 | 233 | return {} 234 | 235 | 236 | def update_encoded_file_with_strings( 237 | file_path, 238 | localized_strings, 239 | encoding='utf16' 240 | ): 241 | ''' 242 | Update file at file_path with translations from localized_strings, trying 243 | to preserve the initial formatting by only removing the old translations, 244 | updating the current ones and adding the new translations at the end of 245 | the file. 246 | The file at file_path must exist or this function will raise an exception. 247 | ''' 248 | output_strings = [] 249 | 250 | keys = set() 251 | with codecs.open(file_path, 'r', encoding) as content: 252 | for line in content: 253 | current_string = LocalizedString.from_line(line.strip()) 254 | if current_string: 255 | key = current_string.key 256 | localized_string = localized_strings.get(key, None) 257 | if localized_string: 258 | keys.add(key) 259 | output_strings.append(unicode(localized_string)) 260 | else: 261 | output_strings.append(line[:-1]) 262 | 263 | new_strings = [] 264 | for value in localized_strings.itervalues(): 265 | if value.key not in keys: 266 | new_strings.append(unicode(value)) 267 | 268 | if len(new_strings) != 0: 269 | output_strings.append('') 270 | output_strings.append('/* New strings */') 271 | new_strings.sort() 272 | output_strings.extend(new_strings) 273 | 274 | with codecs.open(file_path, 'w', encoding) as output: 275 | output.write('\n'.join(output_strings)) 276 | # Always add a new line at the end of the file 277 | output.write('\n') 278 | 279 | 280 | def match_strings(scanned_strings, reference_strings): 281 | ''' 282 | Complete scanned_strings with translations from reference_strings. 283 | Return the completed scanned_strings dictionnary. 284 | scanned_strings is not affected. 285 | Strings in reference_strings and not in scanned_strings are not copied. 286 | ''' 287 | final_strings = {} 288 | 289 | for key, value in scanned_strings.iteritems(): 290 | reference_value = reference_strings.get(key, None) 291 | if reference_value: 292 | if reference_value.is_raw(): 293 | # Mark non-translated strings 294 | logging.debug('[raw] %s', key) 295 | final_strings[key] = value 296 | else: 297 | # Reference comment comes from the code 298 | reference_value.comment = value.comment 299 | final_strings[key] = reference_value 300 | else: 301 | logging.debug('[new] %s', key) 302 | final_strings[key] = value 303 | 304 | final_keys = set(final_strings.keys()) 305 | for key in reference_strings.iterkeys(): 306 | if key not in final_keys: 307 | logging.debug('[deleted] %s', key) 308 | 309 | return final_strings 310 | 311 | 312 | def merge_dictionaries(reference_dict, import_dict): 313 | ''' 314 | Return a dictionnary containing key/values from reference_dict 315 | and import_dict. 316 | In case of conflict, the value from reference_dict is chosen. 317 | ''' 318 | final_dict = reference_dict.copy() 319 | 320 | reference_dict_keys = set(reference_dict.keys()) 321 | for key, value in import_dict.iteritems(): 322 | if key not in reference_dict_keys: 323 | final_dict[key] = value 324 | 325 | return final_dict 326 | 327 | 328 | def sorted_strings_from_dict(strings): 329 | ''' 330 | Return an array containing the string objects sorted alphabetically. 331 | ''' 332 | keys = strings.keys() 333 | keys.sort() 334 | 335 | values = [] 336 | for key in keys: 337 | values.append(strings[key]) 338 | 339 | return values 340 | 341 | 342 | class Tests(unittest.TestCase): 343 | ''' Unit Tests ''' 344 | 345 | def test_comment(self): 346 | ''' Test comment pattern ''' 347 | result = LocalizedString.COMMENT_EXPR.match('/* Testing Comments */') 348 | self.assertNotEqual(result, None, 'Pattern not recognized') 349 | self.assertEqual(result.group('comment'), 'Testing Comments', 350 | 'Incorrect pattern content: [%s]' % result.group('comment') 351 | ) 352 | 353 | def test_localized_string(self): 354 | ''' Test localized string pattern ''' 355 | result = LocalizedString.LOCALIZED_STRING_EXPR.match( 356 | '"KEY" = "VALUE";' 357 | ) 358 | self.assertNotEqual(result, None, 'Pattern not recognized') 359 | self.assertEqual(result.group('key'), 'KEY', 360 | 'Incorrect comment content: [%s]' % result.group('key') 361 | ) 362 | self.assertEqual(result.group('value'), 'VALUE', 363 | 'Incorrect comment content: [%s]' % result.group('value') 364 | ) 365 | self.assertEqual(result.group('comment'), None, 366 | 'Incorrect comment content: [%s]' % result.group('comment') 367 | ) 368 | 369 | def test_localized_comment_string(self): 370 | ''' Test localized string with comment pattern ''' 371 | result = LocalizedString.LOCALIZED_STRING_EXPR.match( 372 | '"KEY" = "VALUE"; /* COMMENT */' 373 | ) 374 | self.assertNotEqual(result, None, 'Pattern not recognized') 375 | self.assertEqual(result.group('key'), 'KEY', 376 | 'Incorrect comment content: [%s]' % result.group('key') 377 | ) 378 | self.assertEqual(result.group('value'), 'VALUE', 379 | 'Incorrect comment content: [%s]' % result.group('value') 380 | ) 381 | self.assertEqual(result.group('comment'), 'COMMENT', 382 | 'Incorrect comment content: [%s]' % result.group('comment') 383 | ) 384 | 385 | 386 | def main(): 387 | ''' Parse the command line and do what it is telled to do ''' 388 | parser = optparse.OptionParser( 389 | 'usage: %prog [options] Localizable.strings [source folders]' 390 | ) 391 | parser.add_option( 392 | '-v', 393 | '--verbose', 394 | action='store_true', 395 | dest='verbose', 396 | default=False, 397 | help='Show debug messages' 398 | ) 399 | parser.add_option( 400 | '', 401 | '--dry-run', 402 | action='store_true', 403 | dest='dry_run', 404 | default=False, 405 | help='Do not write to the strings file' 406 | ) 407 | parser.add_option( 408 | '', 409 | '--import', 410 | dest='import_file', 411 | help='Import strings from FILENAME' 412 | ) 413 | parser.add_option( 414 | '', 415 | '--overwrite', 416 | action='store_true', 417 | dest='overwrite', 418 | default=False, 419 | help='Overwrite the strings file, ignores original formatting' 420 | ) 421 | parser.add_option( 422 | '', 423 | '--unittests', 424 | action='store_true', 425 | dest='unittests', 426 | default=False, 427 | help='Run unit tests (debug)' 428 | ) 429 | 430 | (options, args) = parser.parse_args() 431 | 432 | logging.basicConfig( 433 | format='%(message)s', 434 | level=options.verbose and logging.DEBUG or logging.INFO 435 | ) 436 | 437 | if options.unittests: 438 | suite = unittest.TestLoader().loadTestsFromTestCase(Tests) 439 | return unittest.TextTestRunner(verbosity=2).run(suite) 440 | 441 | if len(args) == 0: 442 | parser.error('Please specify a strings file') 443 | 444 | strings_file = args[0] 445 | 446 | input_folders = ['.'] 447 | if len(args) > 1: 448 | input_folders = args[1:] 449 | 450 | scanned_strings = {} 451 | for input_folder in input_folders: 452 | if not os.path.isdir(input_folder): 453 | logging.error('Input path is not a folder: %s', input_folder) 454 | return 1 455 | 456 | # TODO: allow to specify file extensions to scan 457 | scanned_strings = merge_dictionaries( 458 | scanned_strings, 459 | strings_from_folder(input_folder) 460 | ) 461 | 462 | if options.import_file: 463 | logging.debug( 464 | 'Reading import file: %s', 465 | options.import_file 466 | ) 467 | reference_strings = strings_from_file(options.import_file) 468 | scanned_strings = match_strings( 469 | scanned_strings, 470 | reference_strings 471 | ) 472 | 473 | if os.path.isfile(strings_file): 474 | logging.debug( 475 | 'Reading strings file: %s', 476 | strings_file 477 | ) 478 | reference_strings = strings_from_file( 479 | strings_file 480 | ) 481 | scanned_strings = match_strings( 482 | scanned_strings, 483 | reference_strings 484 | ) 485 | 486 | if options.dry_run: 487 | logging.info( 488 | 'Dry run: the strings file has not been updated' 489 | ) 490 | else: 491 | try: 492 | if os.path.exists(strings_file) and not options.overwrite: 493 | update_file_with_strings(strings_file, scanned_strings) 494 | else: 495 | strings_to_file(scanned_strings, strings_file) 496 | except IOError, exc: 497 | logging.error('Error writing to file %s: %s', strings_file, exc) 498 | return 1 499 | 500 | logging.info( 501 | 'Strings were generated in %s', 502 | strings_file 503 | ) 504 | 505 | return 0 506 | 507 | 508 | if __name__ == '__main__': 509 | sys.exit(main()) 510 | -------------------------------------------------------------------------------- /xcode_project.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import plistlib 5 | import subprocess 6 | import os.path 7 | 8 | 9 | class XCodeProjectReadException(Exception): 10 | pass 11 | 12 | 13 | class XCodeConfiguration(object): 14 | @classmethod 15 | def from_list(cls, configurations_dict, objects_dict): 16 | configurations = {} 17 | for configuration_id in configurations_dict['buildConfigurations']: 18 | configuration = cls(objects_dict[configuration_id]) 19 | configurations[configuration.name] = configuration 20 | 21 | return configurations 22 | 23 | def __init__(self, configuration_dict=None): 24 | self.name = '' 25 | self.build_settings = {} 26 | 27 | if configuration_dict: 28 | self.set_from_dict(configuration_dict) 29 | 30 | def set_from_dict(self, configuration_dict): 31 | self.name = configuration_dict['name'] 32 | self.build_settings = configuration_dict['buildSettings'] 33 | 34 | 35 | class XCodeTarget(object): 36 | def __init__(self, target_dict=None, objects_dict=None): 37 | self.name = '' 38 | self.product_name = '' 39 | self.configurations = {} 40 | 41 | if target_dict and objects_dict: 42 | self.set_from_dict(target_dict, objects_dict) 43 | 44 | def set_from_dict(self, target_dict, objects_dict): 45 | self.name = target_dict['name'] 46 | self.product_name = target_dict['productName'] 47 | 48 | configurations_dict = objects_dict[ 49 | target_dict['buildConfigurationList'] 50 | ] 51 | self.configurations = XCodeConfiguration.from_list( 52 | configurations_dict, objects_dict 53 | ) 54 | 55 | def get_build_setting(self, setting, configuration='Release'): 56 | build_settings = self.configurations.get( 57 | configuration, XCodeConfiguration() 58 | ).build_settings 59 | 60 | return build_settings.get(setting, None) 61 | 62 | 63 | class XCodeProject(object): 64 | def __init__(self, project_file_path=None): 65 | self.configurations = {} 66 | self.targets = {} 67 | 68 | if project_file_path: 69 | self.set_from_project_file(project_file_path) 70 | 71 | def set_from_project_file(self, project_file_path): 72 | pbxproj_file_path = os.path.join(project_file_path, 'project.pbxproj') 73 | if not os.path.exists(pbxproj_file_path): 74 | raise XCodeProjectReadException( 75 | 'Could not find the XCode project file: %s does not exist' % 76 | pbxproj_file_path 77 | ) 78 | 79 | arguments = 'plutil -convert xml1 -o -'.split(' ') 80 | arguments.append(pbxproj_file_path) 81 | process = subprocess.Popen(arguments, stdout=subprocess.PIPE) 82 | stdoutdata = process.communicate()[0] 83 | 84 | if process.returncode != 0: 85 | raise XCodeProjectReadException( 86 | 'Could not read the project file: %s' % stdoutdata 87 | ) 88 | 89 | objects_dict = plistlib.readPlistFromString(stdoutdata)['objects'] 90 | 91 | for item_dict in objects_dict.itervalues(): 92 | isa = item_dict['isa'] 93 | 94 | if isa == 'PBXNativeTarget': 95 | target = XCodeTarget(item_dict, objects_dict) 96 | self.targets[target.name] = target 97 | 98 | if isa in ('PBXProject'): 99 | configurations_dict = objects_dict[ 100 | item_dict['buildConfigurationList'] 101 | ] 102 | self.configurations = XCodeConfiguration.from_list( 103 | configurations_dict, objects_dict 104 | ) 105 | 106 | def get_build_setting(self, setting, configuration='Release', target=None): 107 | build_settings = self.configurations.get( 108 | configuration, XCodeConfiguration() 109 | ).build_settings 110 | 111 | value = build_settings.get(setting, None) 112 | 113 | if not target or value: 114 | return value 115 | else: 116 | target = self.targets.get(target, XCodeTarget()) 117 | 118 | return target.get_build_setting(setting, configuration) 119 | 120 | 121 | def main(): 122 | logging.basicConfig(format='%(message)s', level=logging.DEBUG) 123 | 124 | if len(sys.argv) < 2: 125 | logging.error('Please specify an XCode project file') 126 | logging.error('usage: %s app.xcodeproj', sys.argv[0]) 127 | return 1 128 | 129 | project_file_path = sys.argv[1] 130 | 131 | logging.info('Scanning file %s', project_file_path) 132 | 133 | try: 134 | project = XCodeProject(project_file_path) 135 | except XCodeProjectReadException, exc: 136 | logging.error(exc) 137 | return 2 138 | 139 | logging.debug('Project settings:') 140 | for configuration in project.configurations.itervalues(): 141 | logging.debug(' * %s:', configuration.name) 142 | for key, value in configuration.build_settings.iteritems(): 143 | logging.debug(' > %s = %s', key, value) 144 | 145 | for target in project.targets.itervalues(): 146 | logging.debug('Target %s (%s):', target.name, target.product_name) 147 | for configuration in target.configurations.itervalues(): 148 | logging.debug(' * %s:', configuration.name) 149 | for key, value in configuration.build_settings.iteritems(): 150 | logging.debug(' > %s = %s', key, value) 151 | 152 | first_target_name = project.targets.iterkeys().next() 153 | logging.debug( 154 | '%s Info.plist file: %s', 155 | first_target_name, 156 | project.get_build_setting('INFOPLIST_FILE', target=first_target_name) 157 | ) 158 | 159 | logging.debug('Main SDK: %s', project.get_build_setting('SDKROOT')) 160 | 161 | return 0 162 | 163 | 164 | if __name__ == '__main__': 165 | import logging 166 | import sys 167 | 168 | sys.exit(main()) 169 | --------------------------------------------------------------------------------