#!/usr/bin/python # Copyright (c) 2008 The Chromium Authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """ Commandline modification of Xcode project files """ import sys import os import optparse import re import tempfile import random random.seed() # Seed the generator # All known project build path source tree path reference types PBX_VALID_SOURCE_TREE_TYPES = ('""', 'SOURCE_ROOT', '""', 'BUILT_PRODUCTS_DIR', 'DEVELOPER_DIR', 'SDKROOT', 'CONFIGURATION_TEMP_DIR') # Paths with some characters appear quoted QUOTE_PATH_RE = re.compile('\s|-|\+') # Supported Xcode file types EXTENSION_TO_XCODE_FILETYPE = { '.h' : 'sourcecode.c.h', '.c' : 'sourcecode.c.c', '.cpp' : 'sourcecode.cpp.cpp', '.cc' : 'sourcecode.cpp.cpp', '.cxx' : 'sourcecode.cpp.cpp', '.m' : 'sourcecode.c.objc', '.mm' : 'sourcecode.c.objcpp', } # File types that can be added to a Sources phase SOURCES_XCODE_FILETYPES = ( 'sourcecode.c.c', 'sourcecode.cpp.cpp', 'sourcecode.c.objc', 'sourcecode.c.objcpp' ) # Avoid inserting source files into these common Xcode group names. Because # Xcode allows any names for these groups this list cannot be authoritative, # but these are common names in the Xcode templates. NON_SOURCE_GROUP_NAMES = ( 'Frameworks', 'Resources', 'Products', 'Derived Sources', 'Configurations', 'Documentation', 'Frameworks and Libraries', 'External Frameworks and Libraries', 'Libraries' ) def NewUUID(): """Create a new random Xcode UUID""" __pychecker__ = 'unusednames=i' elements = [] for i in range(24): elements.append(hex(random.randint(0, 15))[-1].upper()) return ''.join(elements) class XcodeProject(object): """Class for reading/writing Xcode project files. This is not a general parser or representation. It is restricted to just the Xcode internal objects we need. Args: path: Absolute path to Xcode project file (including project.pbxproj filename) Attributes: path: Full path to the project.pbxproj file name: Project name (wrapper directory basename without extension) source_root_path: Absolute path for Xcode's SOURCE_ROOT """ EXPECTED_PROJECT_HEADER_RE = re.compile( r'^// !\$\*UTF8\*\$!\n' \ '\{\n' \ '\tarchiveVersion = 1;\n' \ '\tclasses = \{\n' \ '\t\};\n' \ '\tobjectVersion = \d+;\n' \ '\tobjects = \{\n' \ '\n') SECTION_BEGIN_RE = re.compile(r'^/\* Begin (.*) section \*/\n$') SECTION_END_RE = re.compile(r'^/\* End (.*) section \*/\n$') PROJECT_ROOT_OBJECT_RE = re.compile( r'^\trootObject = ([0-9A-F]{24}) /\* Project object \*/;\n$') def __init__(self, path): self.path = path self.name = os.path.splitext(os.path.basename(os.path.dirname(path)))[0] # Load project. Ideally we would use plistlib, but sadly that only reads # XML plists. A real parser with pyparsing # (http://pyparsing.wikispaces.com/) might be another option, but for now # we'll do the simple (?!?) thing. project_fh = open(self.path, 'rU') self._raw_content = project_fh.readlines() project_fh.close() # Store and check header if len(self._raw_content) < 8: print >> sys.stderr, ''.join(self._raw_content) raise RuntimeError('XcodeProject file "%s" too short' % path) self._header = tuple(self._raw_content[:8]) if not self.__class__.EXPECTED_PROJECT_HEADER_RE.match(''.join(self._header)): print >> sys.stderr, ''.join(self._header) raise RuntimeError('XcodeProject file "%s" wrong header' % path) # Find and store tail (some projects have additional whitespace at end) self._tail = [] for tail_line in reversed(self._raw_content): self._tail.insert(0, tail_line) if tail_line == '\t};\n': break # Ugly ugly project parsing, turn each commented section into a separate # set of objects. For types we don't have a custom representation for, # store the raw lines. self._section_order = [] self._sections = {} parse_line_no = len(self._header) while parse_line_no < (len(self._raw_content) - len(self._tail)): section_header_match = self.__class__.SECTION_BEGIN_RE.match( self._raw_content[parse_line_no]) # Loop to next section header if not section_header_match: parse_line_no += 1 continue section = section_header_match.group(1) self._section_order.append(section) self._sections[section] = [] # Advance to first line of the section parse_line_no += 1 # Read in the section, using custom classes where we need them section_end_match = self.__class__.SECTION_END_RE.match( self._raw_content[parse_line_no]) while not section_end_match: # Unhandled lines content = self._raw_content[parse_line_no] # Sections we can parse line-by-line if section in ('PBXBuildFile', 'PBXFileReference'): content = eval('%s.FromContent(content)' % section) # Multiline sections elif section in ('PBXGroup', 'PBXVariantGroup', 'PBXProject', 'PBXNativeTarget', 'PBXSourcesBuildPhase'): # Accumulate lines content_lines = [] while 1: content_lines.append(content) if content == '\t\t};\n': break parse_line_no += 1 content = self._raw_content[parse_line_no] content = eval('%s.FromContent(content_lines)' % section) self._sections[section].append(content) parse_line_no += 1 section_end_match = self.__class__.SECTION_END_RE.match( self._raw_content[parse_line_no]) # Validate section end if section_header_match.group(1) != section: raise RuntimeError( 'XcodeProject parse, section "%s" ended inside section "%s"' % (section_end_match.group(1), section)) # Back around parse loop # Sanity overall group structure if (not self._sections.has_key('PBXProject') or len(self._sections['PBXProject']) != 1): raise RuntimeError('PBXProject section insane') root_obj_parsed = self.__class__.PROJECT_ROOT_OBJECT_RE.match( self._tail[1]) if not root_obj_parsed: raise RuntimeError('XcodeProject unable to parse project root object:\n%s' % self._tail[1]) if root_obj_parsed.group(1) != self._sections['PBXProject'][0].uuid: raise RuntimeError('XcodeProject root object does not match PBXProject') self._root_group_uuid = self._sections['PBXProject'][0].main_group_uuid # Source root self.source_root_path = os.path.abspath( os.path.join( # Directory that contains the project package os.path.dirname(os.path.dirname(path)), # Any relative path self._sections['PBXProject'][0].project_root)) # Build the absolute paths of the groups with these helpers def GroupAbsRealPath(*elements): return os.path.abspath(os.path.realpath(os.path.join(*elements))) def GroupPathRecurse(group, parent_path): descend = False if group.source_tree == '""': group.abs_path = GroupAbsRealPath(group.path) descend = True elif group.source_tree == '""': if group.path: group.abs_path = GroupAbsRealPath(parent_path, group.path) else: group.abs_path = parent_path descend = True elif group.source_tree == 'SOURCE_ROOT': if group.path: group.abs_path = GroupAbsRealPath(self.source_root_path, group.path) else: group.abs_path = GroupAbsRealPath(self.source_root_path) descend = True if descend: for child_uuid in group.child_uuids: # Try a group first found_uuid = False for other_group in self._sections['PBXGroup']: if other_group.uuid == child_uuid: found_uuid = True GroupPathRecurse(other_group, group.abs_path) break if self._sections.has_key('PBXVariantGroup'): for other_group in self._sections['PBXVariantGroup']: if other_group.uuid == child_uuid: found_uuid = True GroupPathRecurse(other_group, group.abs_path) break if not found_uuid: for file_ref in self._sections['PBXFileReference']: if file_ref.uuid == child_uuid: found_uuid = True if file_ref.source_tree == '""': file_ref.abs_path = GroupAbsRealPath(file_ref.path) elif group.source_tree == '""': file_ref.abs_path = GroupAbsRealPath(group.abs_path, file_ref.path) elif group.source_tree == 'SOURCE_ROOT': file_ref.abs_path = GroupAbsRealPath(self.source_root_path, file_ref.path) break if not found_uuid: raise RuntimeError('XcodeProject group descent failed to find %s' % child_uuid) self._root_group = None for group in self._sections['PBXGroup']: if group.uuid == self._root_group_uuid: self._root_group = group GroupPathRecurse(group, self.source_root_path) if not self._root_group: raise RuntimeError('XcodeProject failed to find root group by UUID') def FileContent(self): """Generate and return the project file content as a list of lines""" content = [] content.extend(self._header[:-1]) for section in self._section_order: content.append('\n/* Begin %s section */\n' % section) for section_content in self._sections[section]: content.append(str(section_content)) content.append('/* End %s section */\n' % section) content.extend(self._tail) return content def Update(self): """Rewrite the project file in place with all updated metadata""" __pychecker__ = 'no-deprecated' # Not concerned with temp_path security here, just needed a unique name temp_path = tempfile.mktemp(dir=os.path.dirname(self.path)) outfile = open(temp_path, 'w') outfile.writelines(self.FileContent()) outfile.close() # Rename is weird on Win32, see the docs, os.unlink(self.path) os.rename(temp_path, self.path) def NativeTargets(self): """Obtain all PBXNativeTarget instances for this project Returns: List of PBXNativeTarget instances """ if self._sections.has_key('PBXNativeTarget'): return self._sections['PBXNativeTarget'] else: return [] def NativeTargetForName(self, name): """Obtain the target with a given name. Args: name: Target name Returns: PBXNativeTarget instance or None """ for target in self.NativeTargets(): if target.name == name: return target return None def FileReferences(self): """Obtain all PBXFileReference instances for this project Returns: List of PBXFileReference instances """ return self._sections['PBXFileReference'] def SourcesBuildPhaseForTarget(self, target): """Obtain the PBXSourcesBuildPhase instance for a target. Xcode allows only one PBXSourcesBuildPhase per target and each target has a unique PBXSourcesBuildPhase. Args: target: PBXNativeTarget instance Returns: PBXSourcesBuildPhase instance """ sources_uuid = None for i in range(len(target.build_phase_names)): if target.build_phase_names[i] == 'Sources': sources_uuid = target.build_phase_uuids[i] break if not sources_uuid: raise RuntimeError('Missing PBXSourcesBuildPhase for target "%s"' % target.name) for sources_phase in self._sections['PBXSourcesBuildPhase']: if sources_phase.uuid == sources_uuid: return sources_phase raise RuntimeError('Missing PBXSourcesBuildPhase for UUID "%s"' % sources_uuid) def BuildFileForUUID(self, uuid): """Look up a PBXBuildFile by UUID Args: uuid: UUID of the PBXBuildFile to find Raises: RuntimeError if no PBXBuildFile exists for |uuid| Returns: PBXBuildFile instance """ for build_file in self._sections['PBXBuildFile']: if build_file.uuid == uuid: return build_file raise RuntimeError('Missing PBXBuildFile for UUID "%s"' % uuid) def FileReferenceForUUID(self, uuid): """Look up a PBXFileReference by UUID Args: uuid: UUID of the PBXFileReference to find Raises: RuntimeError if no PBXFileReference exists for |uuid| Returns: PBXFileReference instance """ for file_ref in self._sections['PBXFileReference']: if file_ref.uuid == uuid: return file_ref raise RuntimeError('Missing PBXFileReference for UUID "%s"' % uuid) def RemoveSourceFileReference(self, file_ref): """Remove a source file's PBXFileReference from the project, cleaning up all PBXGroup and PBXBuildFile references to that PBXFileReference and furthermore, removing any PBXBuildFiles from all PBXNativeTarget source lists. Args: file_ref: PBXFileReference instance Raises: RuntimeError if |file_ref| is not a source file reference in PBXBuildFile """ self._sections['PBXFileReference'].remove(file_ref) # Clean up build files removed_build_files = [] for build_file in self._sections['PBXBuildFile']: if build_file.file_ref_uuid == file_ref.uuid: if build_file.type != 'Sources': raise RuntimeError('Removing PBXBuildFile not of "Sources" type') removed_build_files.append(build_file) removed_build_file_uuids = [] for build_file in removed_build_files: removed_build_file_uuids.append(build_file.uuid) self._sections['PBXBuildFile'].remove(build_file) # Clean up source references to the removed build files for source_phase in self._sections['PBXSourcesBuildPhase']: removal_indexes = [] for i in range(len(source_phase.file_uuids)): if source_phase.file_uuids[i] in removed_build_file_uuids: removal_indexes.append(i) for removal_index in removal_indexes: del source_phase.file_uuids[removal_index] del source_phase.file_names[removal_index] # Clean up group references for group in self._sections['PBXGroup']: removal_indexes = [] for i in range(len(group.child_uuids)): if group.child_uuids[i] == file_ref.uuid: removal_indexes.append(i) for removal_index in removal_indexes: del group.child_uuids[removal_index] del group.child_names[removal_index] def RelativeSourceRootPath(self, abs_path): """Convert a path to one relative to the project's SOURCE_ROOT if possible. Generally this follows Xcode semantics, that is, a path is only converted if it is a subpath of SOURCE_ROOT. Args: abs_path: Absolute path to convert Returns: String SOURCE_ROOT relative path if possible or None if not relative to SOURCE_ROOT. """ if abs_path.startswith(self.source_root_path + os.path.sep): return abs_path[len(self.source_root_path + os.path.sep):] else: return None def RelativeGroupPath(self, abs_path): """Convert a path to a group-relative path if possible Args: abs_path: Absolute path to convert Returns: Parent PBXGroup instance if possible or None """ needed_path = os.path.dirname(abs_path) possible_groups = [ g for g in self._sections['PBXGroup'] if g.abs_path == needed_path and not g.name in NON_SOURCE_GROUP_NAMES ] if len(possible_groups) < 1: return None elif len(possible_groups) == 1: return possible_groups[0] # Multiple groups match, try to find the best using some simple # heuristics. Does only one group contain source? groups_with_source = [] for group in possible_groups: for child_uuid in group.child_uuids: try: self.FileReferenceForUUID(child_uuid) except RuntimeError: pass else: groups_with_source.append(group) break if len(groups_with_source) == 1: return groups_with_source[0] # Is only one _not_ the root group? non_root_groups = [ g for g in possible_groups if g is not self._root_group ] if len(non_root_groups) == 1: return non_root_groups[0] # Best guess if len(non_root_groups): return non_root_groups[0] elif len(groups_with_source): return groups_with_source[0] else: return possible_groups[0] def AddSourceFile(self, path): """Add a source file to the project, attempting to position it in the GUI group heirarchy reasonably. NOTE: Adding a source file does not add it to any targets Args: path: Absolute path to the file to add Returns: PBXFileReference instance for the newly added source. """ # Guess at file type root, extension = os.path.splitext(path) if EXTENSION_TO_XCODE_FILETYPE.has_key(extension): source_type = EXTENSION_TO_XCODE_FILETYPE[extension] else: raise RuntimeError('Unknown source file extension "%s"' % extension) # Is group-relative possible? parent_group = self.RelativeGroupPath(os.path.abspath(path)) if parent_group: new_file_ref = PBXFileReference(NewUUID(), os.path.basename(path), source_type, None, os.path.basename(path), '""', None) # Chrome tries to keep its lists name sorted, try to match i = 0 while i < len(parent_group.child_uuids): # Only files are sorted, they keep groups at the top try: self.FileReferenceForUUID(parent_group.child_uuids[i]) if new_file_ref.name.lower() < parent_group.child_names[i].lower(): break except RuntimeError: pass # Must be a child group i += 1 parent_group.child_names.insert(i, new_file_ref.name) parent_group.child_uuids.insert(i, new_file_ref.uuid) self._sections['PBXFileReference'].append(new_file_ref) return new_file_ref # Group-relative failed, how about SOURCE_ROOT relative in the main group src_rel_path = self.RelativeSourceRootPath(os.path.abspath(path)) if src_rel_path: src_rel_path = src_rel_path.replace('\\', '/') # Convert to Unix new_file_ref = PBXFileReference(NewUUID(), os.path.basename(path), source_type, None, src_rel_path, 'SOURCE_ROOT', None) self._root_group.child_uuids.append(new_file_ref.uuid) self._root_group.child_names.append(new_file_ref.name) self._sections['PBXFileReference'].append(new_file_ref) return new_file_ref # Win to Unix absolute paths probably not practical raise RuntimeError('Could not construct group or source PBXFileReference ' 'for path "%s"' % path) def AddSourceFileToSourcesBuildPhase(self, source_ref, source_phase): """Add a PBXFileReference to a PBXSourcesBuildPhase, creating a new PBXBuildFile as needed. Args: source_ref: PBXFileReference instance appropriate for use in PBXSourcesBuildPhase source_phase: PBXSourcesBuildPhase instance """ # Prevent duplication for source_uuid in source_phase.file_uuids: build_file = self.BuildFileForUUID(source_uuid) if build_file.file_ref_uuid == source_ref.uuid: return # Create PBXBuildFile new_build_file = PBXBuildFile(NewUUID(), source_ref.name, 'Sources', source_ref.uuid, '') self._sections['PBXBuildFile'].append(new_build_file) # Add to sources phase list (name sorted) i = 0 while i < len(source_phase.file_names): if source_ref.name.lower() < source_phase.file_names[i].lower(): break i += 1 source_phase.file_names.insert(i, new_build_file.name) source_phase.file_uuids.insert(i, new_build_file.uuid) class PBXProject(object): """Class for PBXProject data section of an Xcode project file. Attributes: uuid: Project UUID main_group_uuid: UUID of the top-level PBXGroup project_root: Relative path from project file wrapper to source_root_path """ PBXPROJECT_HEADER_RE = re.compile( r'^\t\t([0-9A-F]{24}) /\* Project object \*/ = {\n$') PBXPROJECT_MAIN_GROUP_RE = re.compile( r'^\t\t\tmainGroup = ([0-9A-F]{24})(?: /\* .* \*/)?;\n$') PBXPROJECT_ROOT_RE = re.compile( r'^\t\t\tprojectRoot = (.*);\n$') @classmethod def FromContent(klass, content_lines): header_parsed = klass.PBXPROJECT_HEADER_RE.match(content_lines[0]) if not header_parsed: raise RuntimeError('PBXProject unable to parse header content:\n%s' % content_lines[0]) main_group_uuid = None project_root = '' for content_line in content_lines: group_parsed = klass.PBXPROJECT_MAIN_GROUP_RE.match(content_line) if group_parsed: main_group_uuid = group_parsed.group(1) root_parsed = klass.PBXPROJECT_ROOT_RE.match(content_line) if root_parsed: project_root = root_parsed.group(1) if project_root.startswith('"'): project_root = project_root[1:-1] if not main_group_uuid: raise RuntimeError('PBXProject missing main group') return klass(content_lines, header_parsed.group(1), main_group_uuid, project_root) def __init__(self, raw_lines, uuid, main_group_uuid, project_root): self.uuid = uuid self._raw_lines = raw_lines self.main_group_uuid = main_group_uuid self.project_root = project_root def __str__(self): return ''.join(self._raw_lines) class PBXBuildFile(object): """Class for PBXBuildFile data from an Xcode project file. Attributes: uuid: UUID for this instance name: Basename of the build file type: 'Sources' or 'Frameworks' file_ref_uuid: UUID of the PBXFileReference for this file """ PBXBUILDFILE_LINE_RE = re.compile( r'^\t\t([0-9A-F]{24}) /\* (.+) in (.+) \*/ = ' '{isa = PBXBuildFile; fileRef = ([0-9A-F]{24}) /\* (.+) \*/; (.*)};\n$') @classmethod def FromContent(klass, content_line): parsed = klass.PBXBUILDFILE_LINE_RE.match(content_line) if not parsed: raise RuntimeError('PBXBuildFile unable to parse content:\n%s' % content_line) if parsed.group(2) != parsed.group(5): raise RuntimeError('PBXBuildFile name mismatch "%s" vs "%s"' % (parsed.group(2), parsed.group(5))) if not parsed.group(3) in ('Sources', 'Frameworks', 'Resources', 'CopyFiles', 'Headers', 'Copy Into Framework', 'Rez', 'Copy Generated Headers'): raise RuntimeError('PBXBuildFile unknown type "%s"' % parsed.group(3)) return klass(parsed.group(1), parsed.group(2), parsed.group(3), parsed.group(4), parsed.group(6)) def __init__(self, uuid, name, type, file_ref_uuid, raw_extras): self.uuid = uuid self.name = name self.type = type self.file_ref_uuid = file_ref_uuid self._raw_extras = raw_extras def __str__(self): return '\t\t%s /* %s in %s */ = ' \ '{isa = PBXBuildFile; fileRef = %s /* %s */; %s};\n' % ( self.uuid, self.name, self.type, self.file_ref_uuid, self.name, self._raw_extras) class PBXFileReference(object): """Class for PBXFileReference data from an Xcode project file. Attributes: uuid: UUID for this instance name: Basename of the file file_type: current active file type (explicit or assumed) path: source_tree relative path (or absolute if source_tree is absolute) source_tree: Source tree type (see PBX_VALID_SOURCE_TREE_TYPES) abs_path: Absolute path to the file """ PBXFILEREFERENCE_HEADER_RE = re.compile( r'^\t\t([0-9A-F]{24}) /\* (.+) \*/ = {isa = PBXFileReference; ') PBXFILEREFERENCE_FILETYPE_RE = re.compile( r' (lastKnownFileType|explicitFileType) = ([^\;]+); ') PBXFILEREFERENCE_PATH_RE = re.compile(r' path = ([^\;]+); ') PBXFILEREFERENCE_SOURCETREE_RE = re.compile(r' sourceTree = ([^\;]+); ') @classmethod def FromContent(klass, content_line): header_parsed = klass.PBXFILEREFERENCE_HEADER_RE.match(content_line) if not header_parsed: raise RuntimeError('PBXFileReference unable to parse header content:\n%s' % content_line) type_parsed = klass.PBXFILEREFERENCE_FILETYPE_RE.search(content_line) if not type_parsed: raise RuntimeError('PBXFileReference unable to parse type content:\n%s' % content_line) if type_parsed.group(1) == 'lastKnownFileType': last_known_type = type_parsed.group(2) explicit_type = None else: last_known_type = None explicit_type = type_parsed.group(2) path_parsed = klass.PBXFILEREFERENCE_PATH_RE.search(content_line) if not path_parsed: raise RuntimeError('PBXFileReference unable to parse path content:\n%s' % content_line) tree_parsed = klass.PBXFILEREFERENCE_SOURCETREE_RE.search(content_line) if not tree_parsed: raise RuntimeError( 'PBXFileReference unable to parse source tree content:\n%s' % content_line) return klass(header_parsed.group(1), header_parsed.group(2), last_known_type, explicit_type, path_parsed.group(1), tree_parsed.group(1), content_line) def __init__(self, uuid, name, last_known_file_type, explicit_file_type, path, source_tree, raw_line): self.uuid = uuid self.name = name self._last_known_file_type = last_known_file_type self._explicit_file_type = explicit_file_type if explicit_file_type: self.file_type = explicit_file_type else: self.file_type = last_known_file_type self.path = path self.source_tree = source_tree self.abs_path = None self._raw_line = raw_line def __str__(self): # Raw available? if self._raw_line: return self._raw_line # Construct our own if self._last_known_file_type: print_file_type = 'lastKnownFileType = %s; ' % self._last_known_file_type elif self._explicit_file_type: print_file_type = 'explicitFileType = %s; ' % self._explicit_file_type else: raise RuntimeError('No known file type for stringification') name_attribute = '' if self.name != self.path: name_attribute = 'name = %s; ' % self.name print_path = self.path if QUOTE_PATH_RE.search(print_path): print_path = '"%s"' % print_path return '\t\t%s /* %s */ = ' \ '{isa = PBXFileReference; ' \ 'fileEncoding = 4; ' \ '%s' \ '%s' \ 'path = %s; sourceTree = %s; };\n' % ( self.uuid, self.name, print_file_type, name_attribute, print_path, self.source_tree) class PBXGroup(object): """Class for PBXGroup data from an Xcode project file. Attributes: uuid: UUID for this instance name: Group (folder) name path: source_tree relative path (or absolute if source_tree is absolute) source_tree: Source tree type (see PBX_VALID_SOURCE_TREE_TYPES) abs_path: Absolute path to the group child_uuids: Ordered list of PBXFileReference UUIDs child_names: Ordered list of PBXFileReference names """ PBXGROUP_HEADER_RE = re.compile(r'^\t\t([0-9A-F]{24}) (?:/\* .* \*/ )?= {\n$') PBXGROUP_FIELD_RE = re.compile(r'^\t\t\t(.*) = (.*);\n$') PBXGROUP_CHILD_RE = re.compile(r'^\t\t\t\t([0-9A-F]{24}) /\* (.*) \*/,\n$') @classmethod def FromContent(klass, content_lines): # Header line header_parsed = klass.PBXGROUP_HEADER_RE.match(content_lines[0]) if not header_parsed: raise RuntimeError('PBXGroup unable to parse header content:\n%s' % content_lines[0]) name = None path = '' source_tree = None tab_width = None uses_tabs = None indent_width = None child_uuids = [] child_names = [] # Parse line by line content_line_no = 0 while 1: content_line_no += 1 content_line = content_lines[content_line_no] if content_line == '\t\t};\n': break if content_line == '\t\t\tisa = PBXGroup;\n': continue if content_line == '\t\t\tisa = PBXVariantGroup;\n': continue # Child groups if content_line == '\t\t\tchildren = (\n': content_line_no += 1 content_line = content_lines[content_line_no] while content_line != '\t\t\t);\n': child_parsed = klass.PBXGROUP_CHILD_RE.match(content_line) if not child_parsed: raise RuntimeError('PBXGroup unable to parse child content:\n%s' % content_line) child_uuids.append(child_parsed.group(1)) child_names.append(child_parsed.group(2)) content_line_no += 1 content_line = content_lines[content_line_no] continue # Back to top of loop on end of children # Other fields field_parsed = klass.PBXGROUP_FIELD_RE.match(content_line) if not field_parsed: raise RuntimeError('PBXGroup unable to parse field content:\n%s' % content_line) if field_parsed.group(1) == 'name': name = field_parsed.group(2) elif field_parsed.group(1) == 'path': path = field_parsed.group(2) elif field_parsed.group(1) == 'sourceTree': if not field_parsed.group(2) in PBX_VALID_SOURCE_TREE_TYPES: raise RuntimeError('PBXGroup unknown source tree type "%s"' % field_parsed.group(2)) source_tree = field_parsed.group(2) elif field_parsed.group(1) == 'tabWidth': tab_width = field_parsed.group(2) elif field_parsed.group(1) == 'usesTabs': uses_tabs = field_parsed.group(2) elif field_parsed.group(1) == 'indentWidth': indent_width = field_parsed.group(2) else: raise RuntimeError('PBXGroup unknown field "%s"' % field_parsed.group(1)) if path and path.startswith('"'): path = path[1:-1] if name and name.startswith('"'): name = name[1:-1] return klass(header_parsed.group(1), name, path, source_tree, child_uuids, child_names, tab_width, uses_tabs, indent_width) def __init__(self, uuid, name, path, source_tree, child_uuids, child_names, tab_width, uses_tabs, indent_width): self.uuid = uuid self.name = name self.path = path self.source_tree = source_tree self.child_uuids = child_uuids self.child_names = child_names self.abs_path = None # Semantically I'm not sure these aren't an error, but they # appear in some projects self._tab_width = tab_width self._uses_tabs = uses_tabs self._indent_width = indent_width def __str__(self): if self.name: header_comment = '/* %s */ ' % self.name elif self.path: header_comment = '/* %s */ ' % self.path else: header_comment = '' if self.name: if QUOTE_PATH_RE.search(self.name): name_attribute = '\t\t\tname = "%s";\n' % self.name else: name_attribute = '\t\t\tname = %s;\n' % self.name else: name_attribute = '' if self.path: if QUOTE_PATH_RE.search(self.path): path_attribute = '\t\t\tpath = "%s";\n' % self.path else: path_attribute = '\t\t\tpath = %s;\n' % self.path else: path_attribute = '' child_lines = [] for x in range(len(self.child_uuids)): child_lines.append('\t\t\t\t%s /* %s */,\n' % (self.child_uuids[x], self.child_names[x])) children = ''.join(child_lines) tab_width_attribute = '' if self._tab_width: tab_width_attribute = '\t\t\ttabWidth = %s;\n' % self._tab_width uses_tabs_attribute = '' if self._uses_tabs: uses_tabs_attribute = '\t\t\tusesTabs = %s;\n' % self._uses_tabs indent_width_attribute = '' if self._indent_width: indent_width_attribute = '\t\t\tindentWidth = %s;\n' % self._indent_width return '\t\t%s %s= {\n' \ '\t\t\tisa = %s;\n' \ '\t\t\tchildren = (\n' \ '%s' \ '\t\t\t);\n' \ '%s' \ '%s' \ '%s' \ '\t\t\tsourceTree = %s;\n' \ '%s' \ '%s' \ '\t\t};\n' % ( self.uuid, header_comment, self.__class__.__name__, children, indent_width_attribute, name_attribute, path_attribute, self.source_tree, tab_width_attribute, uses_tabs_attribute) class PBXVariantGroup(PBXGroup): pass class PBXNativeTarget(object): """Class for PBXNativeTarget data from an Xcode project file. Attributes: name: Target name build_phase_uuids: Ordered list of build phase UUIDs build_phase_names: Ordered list of build phase names NOTE: We do not have wrapper classes for all build phase data types! """ PBXNATIVETARGET_HEADER_RE = re.compile( r'^\t\t([0-9A-F]{24}) /\* (.*) \*/ = {\n$') PBXNATIVETARGET_BUILD_PHASE_RE = re.compile( r'^\t\t\t\t([0-9A-F]{24}) /\* (.*) \*/,\n$') @classmethod def FromContent(klass, content_lines): header_parsed = klass.PBXNATIVETARGET_HEADER_RE.match(content_lines[0]) if not header_parsed: raise RuntimeError('PBXNativeTarget unable to parse header content:\n%s' % content_lines[0]) build_phase_uuids = [] build_phase_names = [] content_line_no = 0 while 1: content_line_no += 1 content_line = content_lines[content_line_no] if content_line == '\t\t};\n': break if content_line == '\t\t\tisa = PBXNativeTarget;\n': continue # Build phases groups if content_line == '\t\t\tbuildPhases = (\n': content_line_no += 1 content_line = content_lines[content_line_no] while content_line != '\t\t\t);\n': phase_parsed = klass.PBXNATIVETARGET_BUILD_PHASE_RE.match( content_line) if not phase_parsed: raise RuntimeError( 'PBXNativeTarget unable to parse build phase content:\n%s' % content_line) build_phase_uuids.append(phase_parsed.group(1)) build_phase_names.append(phase_parsed.group(2)) content_line_no += 1 content_line = content_lines[content_line_no] break # Don't care about the rest of the content return klass(content_lines, header_parsed.group(2), build_phase_uuids, build_phase_names) def __init__(self, raw_lines, name, build_phase_uuids, build_phase_names): self._raw_lines = raw_lines self.name = name self.build_phase_uuids = build_phase_uuids self.build_phase_names = build_phase_names def __str__(self): return ''.join(self._raw_lines) class PBXSourcesBuildPhase(object): """Class for PBXSourcesBuildPhase data from an Xcode project file. Attributes: uuid: UUID for this instance build_action_mask: Xcode magic mask constant run_only_for_deployment_postprocessing: deployment postprocess flag file_uuids: Ordered list of PBXBuildFile UUIDs file_names: Ordered list of PBXBuildFile names (basename) """ PBXSOURCESBUILDPHASE_HEADER_RE = re.compile( r'^\t\t([0-9A-F]{24}) /\* Sources \*/ = {\n$') PBXSOURCESBUILDPHASE_FIELD_RE = re.compile(r'^\t\t\t(.*) = (.*);\n$') PBXSOURCESBUILDPHASE_FILE_RE = re.compile( r'^\t\t\t\t([0-9A-F]{24}) /\* (.*) in Sources \*/,\n$') @classmethod def FromContent(klass, content_lines): header_parsed = klass.PBXSOURCESBUILDPHASE_HEADER_RE.match(content_lines[0]) if not header_parsed: raise RuntimeError( 'PBXSourcesBuildPhase unable to parse header content:\n%s' % content_lines[0]) # Parse line by line build_action_mask = None run_only_for_deployment_postprocessing = None file_uuids = [] file_names = [] content_line_no = 0 while 1: content_line_no += 1 content_line = content_lines[content_line_no] if content_line == '\t\t};\n': break if content_line == '\t\t\tisa = PBXSourcesBuildPhase;\n': continue # Files if content_line == '\t\t\tfiles = (\n': content_line_no += 1 content_line = content_lines[content_line_no] while content_line != '\t\t\t);\n': file_parsed = klass.PBXSOURCESBUILDPHASE_FILE_RE.match(content_line) if not file_parsed: raise RuntimeError( 'PBXSourcesBuildPhase unable to parse file content:\n%s' % content_line) file_uuids.append(file_parsed.group(1)) file_names.append(file_parsed.group(2)) content_line_no += 1 content_line = content_lines[content_line_no] continue # Back to top of loop on end of files list # Other fields field_parsed = klass.PBXSOURCESBUILDPHASE_FIELD_RE.match(content_line) if not field_parsed: raise RuntimeError( 'PBXSourcesBuildPhase unable to parse field content:\n%s' % content_line) if field_parsed.group(1) == 'buildActionMask': build_action_mask = field_parsed.group(2) elif field_parsed.group(1) == 'runOnlyForDeploymentPostprocessing': run_only_for_deployment_postprocessing = field_parsed.group(2) else: raise RuntimeError('PBXSourcesBuildPhase unknown field "%s"' % field_parsed.group(1)) return klass(header_parsed.group(1), build_action_mask, run_only_for_deployment_postprocessing, file_uuids, file_names) def __init__(self, uuid, build_action_mask, run_only_for_deployment_postprocessing, file_uuids, file_names): self.uuid = uuid self.build_action_mask = build_action_mask self.run_only_for_deployment_postprocessing = \ run_only_for_deployment_postprocessing self.file_uuids = file_uuids self.file_names = file_names def __str__(self): file_lines = [] for x in range(len(self.file_uuids)): file_lines.append('\t\t\t\t%s /* %s in Sources */,\n' % (self.file_uuids[x], self.file_names[x])) files = ''.join(file_lines) return '\t\t%s /* Sources */ = {\n' \ '\t\t\tisa = PBXSourcesBuildPhase;\n' \ '\t\t\tbuildActionMask = %s;\n' \ '\t\t\tfiles = (\n' \ '%s' \ '\t\t\t);\n' \ '\t\t\trunOnlyForDeploymentPostprocessing = %s;\n' \ '\t\t};\n' % ( self.uuid, self.build_action_mask, files, self.run_only_for_deployment_postprocessing) def Usage(optparse): optparse.print_help() print '\n' \ 'Commands:\n' \ ' list_native_targets: List Xcode "native" (source compilation)\n' \ ' targets by name.\n' \ ' list_target_sources: List project-relative source files in the\n' \ ' specified Xcode "native" target.\n' \ ' remove_source [sourcefile ...]: Remove the specified source files\n' \ ' from every target in the project (target is ignored).\n' \ ' add_source [sourcefile ...]: Add the specified source files\n' \ ' to the specified target.\n' sys.exit(2) def Main(): # Use argument structure like xcodebuild commandline option_parser = optparse.OptionParser( usage='usage: %prog -p projectname [ -t targetname ] ' \ ' [...]', add_help_option=False) option_parser.add_option( '-h', '--help', action='store_true', dest='help', default=False, help=optparse.SUPPRESS_HELP) option_parser.add_option( '-p', '--project', action='store', type='string', dest='project', metavar='projectname', help='Manipulate the project specified by projectname.') option_parser.add_option( '-t', '--target', action='store', type='string', dest='target', metavar='targetname', help='Manipulate the target specified by targetname.') (options, args) = option_parser.parse_args() # Since we have more elaborate commands, handle help if options.help: Usage(option_parser) # Xcode project file if not options.project: option_parser.error('Xcode project file must be specified.') project_path = os.path.abspath(options.project) if project_path.endswith('.xcodeproj'): project_path = os.path.join(project_path, 'project.pbxproj') if not project_path.endswith(os.sep + 'project.pbxproj'): option_parser.error('Invalid Xcode project file path \"%s\"' % project_path) if not os.path.exists(project_path): option_parser.error('Missing Xcode project file \"%s\"' % project_path) # Construct project object project = XcodeProject(project_path) # Switch on command if len(args) < 1: Usage(option_parser) # List native target names elif args[0] == 'list_native_targets': # List targets if len(args) != 1: option_parser.error('list_native_targets takes no arguments') # Ape xcodebuild output target_names = [] for target in project.NativeTargets(): target_names.append(target.name) print 'Information about project "%s"\n Native Targets:\n %s' % ( project.name, '\n '.join(target_names)) # List files in a native target elif args[0] == 'list_target_sources': if len(args) != 1: option_parser.error('list_target_sources takes no arguments') if not options.target: option_parser.error('list_target_sources requires a target') # Validate target and get list of files target = project.NativeTargetForName(options.target) if not target: option_parser.error('No native target named "%s"' % options.target) sources_phase = project.SourcesBuildPhaseForTarget(target) target_files = [] for source_uuid in sources_phase.file_uuids: build_file = project.BuildFileForUUID(source_uuid) file_ref = project.FileReferenceForUUID(build_file.file_ref_uuid) pretty_path = project.RelativeSourceRootPath(file_ref.abs_path) if pretty_path: target_files.append(pretty_path) else: target_files.append(file_ref.abs_path) # Ape xcodebuild output print 'Information about project "%s" target "%s"\n' \ ' Files:\n %s' % (project.name, options.target, '\n '.join(target_files)) # Remove source files elif args[0] == 'remove_source': if len(args) < 2: option_parser.error('remove_source needs one or more source files') if options.target: option_parser.error( 'remove_source does not support removal from a single target') for source_path in args[1:]: found = False for file_ref in project.FileReferences(): # Try undecorated path, abs_path and our prettified paths if (file_ref.path == source_path or ( file_ref.abs_path and ( file_ref.abs_path == os.path.abspath(source_path) or project.RelativeSourceRootPath(file_ref.abs_path) == source_path))): # Found a matching file ref, remove it found = True project.RemoveSourceFileReference(file_ref) if not found: option_parser.error('No matching source file "%s"' % source_path) project.Update() # Add source files elif args[0] == 'add_source': if len(args) < 2: option_parser.error('add_source needs one or more source files') if not options.target: option_parser.error('add_source requires a target') # Look for the target we want to add too. target = project.NativeTargetForName(options.target) if not target: option_parser.error('No native target named "%s"' % options.target) # Get the sources build phase sources_phase = project.SourcesBuildPhaseForTarget(target) # Loop new sources for source_path in args[1:]: if not os.path.exists(os.path.abspath(source_path)): option_parser.error('File "%s" not found' % source_path) # Don't generate duplicate file references if we don't need them source_ref = None for file_ref in project.FileReferences(): # Try undecorated path, abs_path and our prettified paths if (file_ref.path == source_path or ( file_ref.abs_path and ( file_ref.abs_path == os.path.abspath(source_path) or project.RelativeSourceRootPath(file_ref.abs_path) == source_path))): source_ref = file_ref break if not source_ref: # Create a new source file ref source_ref = project.AddSourceFile(os.path.abspath(source_path)) # Add the new source file reference to the target if its a safe type if source_ref.file_type in SOURCES_XCODE_FILETYPES: project.AddSourceFileToSourcesBuildPhase(source_ref, sources_phase) project.Update() # Private sanity check. On an unmodified project make sure our output is # the same as the input elif args[0] == 'parse_sanity': if ''.join(project.FileContent()) != ''.join(project._raw_content): option_parser.error('Project rewrite sanity fail "%s"' % project.path) else: Usage(option_parser) if __name__ == '__main__': Main()