From 85e7a8aa8ab82cea16cdcd1bec3d09fce84fa998 Mon Sep 17 00:00:00 2001 From: "aharper@chromium.org" Date: Thu, 8 Jan 2009 23:08:44 +0000 Subject: Add first cut at tool for Win32 users to manipulate Xcode project files pending the grand overall solution. Review URL: http://codereview.chromium.org/15015 git-svn-id: svn://svn.chromium.org/chrome/trunk/src@7773 0039d316-1c4b-4281-b951-d872f2087c98 --- tools/xcodebodge/xcodebodge.py | 1229 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1229 insertions(+) create mode 100755 tools/xcodebodge/xcodebodge.py diff --git a/tools/xcodebodge/xcodebodge.py b/tools/xcodebodge/xcodebodge.py new file mode 100755 index 0000000..1995e6e --- /dev/null +++ b/tools/xcodebodge/xcodebodge.py @@ -0,0 +1,1229 @@ +#!/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() -- cgit v1.1