Source code for batools.xcodeproject

# Released under the MIT License. See LICENSE for details.
#
"""XCode related functionality."""
from __future__ import annotations

import os
import hashlib
from typing import TYPE_CHECKING

import openstep_parser as osp
from pbxproj import XcodeProject
from pbxproj.pbxextensions import TreeType, PBXGroup

# Need to patch XcodeProject slightly to support .cc files.
# noinspection PyProtectedMember
xcft = XcodeProject._FILE_TYPES  # pylint: disable=protected-access
if '.cc' not in xcft:
    xcft['.cc'] = xcft['.cpp']

# Normally header files are added to a copy-headers build phase;
# we don't want that (its only really relevant for frameworks and
# its something else we'd need to worry about restoring uuids for).
xcft['.h'] = (xcft['.h'][0], None)

if TYPE_CHECKING:
    from typing import Any


[docs] def update_xcode_project( projroot: str, path: str, existing_data: str, all_source_files: list[str], projname: str, force: bool = False, ) -> str: """Given an xcode project, update it for the current set of files.""" # pylint: disable=too-many-positional-arguments suffixes = ['.cc', '.h', '.m', '.mm', '.swift'] updater = Updater( projroot, path, existing_data, sorted( p for p in all_source_files if os.path.splitext(p)[1] in suffixes ), # has_app_delegate_mm=True, projname=projname, ) return updater.run(force=force)
[docs] class Updater: """Does the thing.""" project: Any def __init__( self, projroot: str, path: str, existing_data: str, sources: list[str], projname: str, ) -> None: # pylint: disable=too-many-positional-arguments if not path.endswith('.xcodeproj'): raise RuntimeError(f"Path does not end in .xcodeproj: '{path}'.") self.projroot = projroot self.path = path self.existing_data = existing_data self.sources = sources self.project = None # Project name variations. self.pnameu = projname self.pnamel = projname.lower() # uuids associated with a given file path # (grp/file obj and possibly build-files) self.old_path_uuids: dict[str, list[str]] = {} self.new_path_uuids: dict[str, list[str]] = {} self.print_test = False
[docs] def run(self, force: bool = False) -> str: """Do the thing.""" # pylint: disable=too-many-locals projpath = os.path.join(self.projroot, self.path, 'project.pbxproj') # Make a hash out of all paths we'd add combined with the incoming # state of this project. If this calced hash matches the current # on-disk hash, we can assume our output would match the input # and just deliver the input. projsrc = self.existing_data fhash = self.hash_inputs( self.sources, include_us=True, project_source=projsrc ) # WARNING - this cache naming convention assumes all project # basenames are unique regardless of dir (currently true but # maybe won't always be). _dirname, basename = os.path.split(self.path) basebasename = os.path.splitext(basename)[0] cachedir = os.path.join(self.projroot, '.cache') os.makedirs(cachedir, exist_ok=True) hashpath = os.path.join(cachedir, f'xcode_src_hash_{basebasename}') if os.path.exists(hashpath): with open(hashpath, encoding='utf-8') as infile: currenthash = infile.read() else: currenthash = None # If hash still matches, return incoming project state. if currenthash == fhash and not force: # with open(projpath, encoding='utf-8') as infile: # existing_file = infile.read() # FIXME: Its weird to be feeding our hash file back out to # the project system; we should probably just manage it # completely internally. return self.existing_data tree = osp.OpenStepDecoder.ParseFromString(self.existing_data) self.project = XcodeProject(tree, projpath) # self.project = XcodeProject.load(projpath) bgrp = self._get_unique_group('ballistica') assert isinstance(bgrp.get_id(), str) # Store uuids for all existing paths under ballistica and then # blow it away. When we're done rebuilding ballistica we'll # restore uuids on any paths that got remade. This keeps changes # to the pbxproj file much more minimal which is good for git, # and is simpler to accomplish than doing more selective # adds/removes would be. self._store_path_uuids(bgrp.get_id(), self.old_path_uuids, '') self.project.remove_group_by_id(bgrp.get_id()) srcgrp = self._get_unique_group(f'{self.pnameu} Shared') self.add_paths(srcgrp) # if self.has_app_delegate_mm: # self.mod_app_delegate_mm() # Groups we made should be sorted already since we sorted while # building them, but let's sort the top level group we placed # our stuff *into*. srcgrp.children.sort( key=lambda c: self.project.objects[c].get_name().lower() ) # Now store uuids for the new stuff we made. bgrp = self._get_unique_group('ballistica') assert isinstance(bgrp.get_id(), str) self._store_path_uuids(bgrp.get_id(), self.new_path_uuids, '') # Now filter the raw project file to replace new uuids with old # ones when possible. self._filter_uuids(projpath) # Now go through and, for every object with a path equal to its # name, kill the name. This seems to match what xcode does so our # project structure stays more similar to theirs. bgrp = self._get_unique_group('ballistica') assert isinstance(bgrp.get_id(), str) self._trim_names(bgrp.get_id(), '') projsrc = repr(self.project) + '\n' # A few hacky last tweaks on the final project source to # get ours matching xcode's 100% when possible. projsrc = projsrc.replace( '/* Build configuration list for PBXProject' f' "{self.pnameu} macOS Legacy" */', '/* Build configuration list for PBXProject' f' "{self.pnamel}-mac" */', ) # The hash we generated above used the project as it exists on disk # for checking purposes, so for the one we return we need to # regenerate it here to use the project source we just created. fhash = self.hash_inputs( self.sources, include_us=True, project_source=projsrc ) # Store the new hash. if fhash != currenthash: with open(hashpath, 'w', encoding='utf-8') as outfile: outfile.write(fhash) return projsrc
def _target_name_for_buildfile(self, buildfile: Any) -> str: for target in self.project.objects.get_targets(): for build_phase_id in target.buildPhases: build_phase = self.project.objects[build_phase_id] if build_phase.isa == 'PBXSourcesBuildPhase': if buildfile.get_id() in build_phase.files: assert isinstance(target.name, str) return target.name raise RuntimeError( f'Could not deduce target name from build file {buildfile}.' ) def _filter_uuids(self, projpath: str) -> None: projtxt = repr(self.project) + '\n' # For any path that used to exist in our project, if we # find a new uuid for it, replace it with the old one. for oldpath, olduuids in self.old_path_uuids.items(): newuuids = self.new_path_uuids.get(oldpath) if newuuids is not None: if len(olduuids) != len(newuuids): print( f'uuids count changed for path {oldpath}; unexpected.' ) else: for olduuid, newuuid in zip(olduuids, newuuids): projtxt = projtxt.replace(newuuid, olduuid) # Now replace our existing project with this filtered one. # This will properly update ordering for the id swaps we just made. tree = osp.OpenStepDecoder.ParseFromString(projtxt) self.project = XcodeProject(tree, projpath) def _trim_names(self, objid: str, parentpath: str) -> None: obj = self.project.objects[objid] assert hasattr(obj, 'name') objpath = os.path.join(parentpath, obj.name) if isinstance(obj, PBXGroup): for childid in obj.children: self._trim_names(childid, objpath) assert hasattr(obj, 'path') if obj.path == obj.name: delattr(obj, 'name') def _store_path_uuids( self, objid: str, uuids: dict[str, list[str]], parentpath: str ) -> None: obj = self.project.objects[objid] # Hmmm - seems sometimes things have name but sometimes just path. # (assuming maybe when they're identical or something?..) if hasattr(obj, 'name'): objpath = os.path.join(parentpath, obj.name) else: assert hasattr(obj, 'path') assert '/' not in obj.path objpath = os.path.join(parentpath, obj.path) # Store this uuid with this path. uuidentry = uuids.setdefault(objpath, []) uuidentry.append(objid) if isinstance(obj, PBXGroup): for childid in obj.children: self._store_path_uuids(childid, uuids, objpath) else: buildfiles = self.project.get_build_files_for_file(objid) # We'll replace the new buildfile ids with our previous ones; # however can come out shuffled (the buildfile for target A # might be given the UUID that was previously assigned to the # buildfile for target B, etc.) We can fix this by sorting # our buildfiles by the target they go with. buildfiles.sort(key=self._target_name_for_buildfile) for buildfile in buildfiles: uuidentry.append(buildfile.get_id()) def _get_unique_group(self, name: str) -> Any: grps = self.project.get_groups_by_name(name) if len(grps) != 1: raise RuntimeError( f'Expected exactly 1 "{name}" group; found {len(grps)}.' ) return grps[0] # (No longer used; just leaving here as reference though)
[docs] def mod_app_delegate_mm(self) -> None: """Set per-file compiler flags.""" files = self.project.get_files_by_name('app_delegate.mm') if len(files) != 1: # Update: no longer expecting to always find this now that # it has been moved to base. if self.pnameu == 'Ballistica' + 'Kit': raise RuntimeError( f'Expected to find exactly 1 app_delegate.mm;' f' found {len(files)}.' ) else: bfiles = self.project.get_build_files_for_file(files[0].get_id()) for bfile in bfiles: bfile.add_compiler_flags('-fobjc-arc')
[docs] def hash_inputs( self, sources: list[str], include_us: bool, project_source: str ) -> str: """Make a simple hash based on inputs to the project.""" if TYPE_CHECKING: # Help Mypy infer the right type for this. hashobj = hashlib.md5() else: hashobj = getattr(hashlib, 'md5')() # If they're not providing project-source, use what's on disk. # Hash our sorted list of sources; when sources are added, removed, # or renamed we'll want to rebuild. for source in sorted(sources): hashobj.update(source.encode()) # Also hash the project-source we were passed. We do this so that # we know to re-run our generation if xcode itself (or whatever else) # modifies the project. We want to do that because our generated # output might be slightly different than what xcode writes and # we want our version to always win out and get stored in git; # otherwise CI project checks would see mismatches and complain # that the project is out of date. Ideally our output will exactly # match xcode's so no rewrites will need to happen, but this way # we'll behave even if they do need to. hashobj.update(project_source.encode()) # Also include the full source of this module so we rebuild # when logic here is updated. if include_us: with open(__file__, 'rb') as infile: hashobj.update(infile.read()) return hashobj.hexdigest()
@staticmethod def _xcodesortkey(val: str) -> str: # Yes this is super nitpicky, but I'd like to have things # show up in xcode such that doing a 'sort by name' doesn't move # anything around. The main funky bit of logic with xcode's # sorting seems to be that foo.cc shows up *after* foobar.cc, # whereas in vanilla Python sorts it comes before. A quick hack # to fix this is to replace the . with something that comes after # the alphabet instead of before. return val.lower().replace('.', '~')
[docs] def add_paths(self, parent_pbxgrp: Any) -> None: """Do the thing.""" # PBXGroups we create for each dir we come across in paths. dir_pbxgrps: dict[str, Any] = {} # For each path, create/fetch its chain of dirs as PBXGroups and # drop the file in the bottom one. for source in self.sources: parts = source.split('/') assert all(p for p in parts) for i in range(len(parts) - 1): thisname = parts[i] thispath = '/'.join(parts[: i + 1]) if thispath not in dir_pbxgrps: if i == 0: # Root; provide full path. pbxgrp = self.project.get_or_create_group( thisname, os.path.abspath( os.path.join(self.projroot, 'src', thispath) ), parent_pbxgrp, make_relative=True, ) else: # Non-root; provide relative path from parent. parentpath = '/'.join(parts[:i]) pbxgrp = self.project.get_or_create_group( thisname, thisname, dir_pbxgrps[parentpath], make_relative=True, ) dir_pbxgrps[thispath] = pbxgrp assert os.path.dirname(source) # All sources should be in a dir. self.project.add_file( os.path.basename(source), dir_pbxgrps[os.path.dirname(source)], force=False, tree=TreeType.GROUP, target_name=self._target_names_for_file( os.path.basename(source) ), )
def _target_names_for_file(self, filename: str) -> list[str] | None: # Cocoa stuff only applies to our macOS targets. if filename.startswith('Cocoa') and filename.endswith('.swift'): return [ f'{self.pnameu} macOS TestBuild', f'{self.pnameu} macOS AppStore', f'{self.pnameu} macOS Steam', ] # A few things only for AppStore bound builds. if filename in {'StoreKitContext.swift', 'GameCenterContext.swift'}: return [ f'{self.pnameu} iOS', f'{self.pnameu} tvOS', f'{self.pnameu} macOS AppStore', ] # UIKit stuff applies to our iOS/tvOS targets. if filename.startswith('UIKit') and filename.endswith('.swift'): return [ f'{self.pnameu} iOS', f'{self.pnameu} tvOS', ] # Everything else applies to everything. return None