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
# Docs-generation hack; import some stuff that we likely only forward-declared # in our actual source code so that docs tools can find it. from typing import (Coroutine, Any, Literal, Callable, Generator, Awaitable, Sequence, Self) import asyncio from concurrent.futures import Future from pathlib import Path from enum import Enum