diff --git a/installinstallmacos.py b/installinstallmacos.py index 2c9be28..d9ffe5f 100755 --- a/installinstallmacos.py +++ b/installinstallmacos.py @@ -23,6 +23,9 @@ A tool to download the parts for an Install macOS app from Apple's softwareupdate servers and install a functioning Install macOS app onto an empty disk image''' +# Python 3 compatibility shims +from __future__ import ( + absolute_import, division, print_function, unicode_literals) import argparse import gzip @@ -30,8 +33,13 @@ import os import plistlib import subprocess import sys -import urlparse import xattr +try: + # python 2 + from urllib.parse import urlsplit +except ImportError: + # python 3 + from urlparse import urlsplit from xml.dom import minidom from xml.parsers.expat import ExpatError from distutils.version import LooseVersion @@ -109,10 +117,38 @@ def get_current_build_info(): return build_info +def get_input(prompt=None): + '''Python 2 and 3 wrapper for raw_input/input''' + try: + return raw_input(prompt) + except NameError: + # raw_input doesn't exist in Python 3 + return input(prompt) + + +def read_plist(filepath): + '''Wrapper for the differences between Python 2 and Python 3's plistlib''' + try: + with open(filepath, "rb") as fileobj: + return plistlib.load(fileobj) + except AttributeError: + # plistlib module doesn't have a load function (as in Python 2) + return plistlib.readPlist(filepath) + + +def read_plist_from_string(bytestring): + '''Wrapper for the differences between Python 2 and Python 3's plistlib''' + try: + return plistlib.loads(bytestring) + except AttributeError: + # plistlib module doesn't have a load function (as in Python 2) + return plistlib.readPlistFromString(bytestring) + + def get_seeding_program(sucatalog_url): '''Returns a seeding program name based on the sucatalog_url''' try: - seed_catalogs = plistlib.readPlist(SEED_CATALOGS_PLIST) + seed_catalogs = read_plist(SEED_CATALOGS_PLIST) for key, value in seed_catalogs.items(): if sucatalog_url == value: return key @@ -124,7 +160,7 @@ def get_seeding_program(sucatalog_url): def get_seed_catalog(seedname='DeveloperSeed'): '''Returns the developer seed sucatalog''' try: - seed_catalogs = plistlib.readPlist(SEED_CATALOGS_PLIST) + seed_catalogs = read_plist(SEED_CATALOGS_PLIST) return seed_catalogs.get(seedname) except (OSError, ExpatError, AttributeError, KeyError): return '' @@ -133,8 +169,8 @@ def get_seed_catalog(seedname='DeveloperSeed'): def get_seeding_programs(): '''Returns the list of seeding program names''' try: - seed_catalogs = plistlib.readPlist(SEED_CATALOGS_PLIST) - return seed_catalogs.keys() + seed_catalogs = read_plist(SEED_CATALOGS_PLIST) + return list(seed_catalogs.keys()) except (OSError, ExpatError, AttributeError, KeyError): return '' @@ -151,17 +187,17 @@ def make_sparse_image(volume_name, output_path): '-volname', volume_name, '-type', 'SPARSE', '-plist', output_path] try: output = subprocess.check_output(cmd) - except subprocess.CalledProcessError, err: - print >> sys.stderr, err + except subprocess.CalledProcessError as err: + print(err, file=sys.stderr) exit(-1) try: - return plistlib.readPlistFromString(output)[0] - except IndexError, err: - print >> sys.stderr, 'Unexpected output from hdiutil: %s' % output + return read_plist_from_string(output)[0] + except IndexError as err: + print('Unexpected output from hdiutil: %s' % output, file=sys.stderr) exit(-1) - except ExpatError, err: - print >> sys.stderr, 'Malformed output from hdiutil: %s' % output - print >> sys.stderr, err + except ExpatError as err: + print('Malformed output from hdiutil: %s' % output, file=sys.stderr) + print(err, file=sys.stderr) exit(-1) @@ -169,16 +205,16 @@ def make_compressed_dmg(app_path, diskimagepath, volume_name): """Returns path to newly-created compressed r/o disk image containing Install macOS.app""" - print ('Making read-only compressed disk image containing %s...' - % os.path.basename(app_path)) - cmd = ['/usr/bin/hdiutil', 'create', '-volname', volume_name, '-fs', 'HFS+', + print('Making read-only compressed disk image containing %s...' + % os.path.basename(app_path)) + cmd = ['/usr/bin/hdiutil', 'create', '-fs', 'HFS+', '-srcfolder', app_path, diskimagepath] try: subprocess.check_call(cmd) - except subprocess.CalledProcessError, err: - print >> sys.stderr, err + except subprocess.CalledProcessError as err: + print(err, file=sys.stderr) else: - print 'Disk image created at: %s' % diskimagepath + print('Disk image created at: %s' % diskimagepath) def mountdmg(dmgpath): @@ -194,10 +230,11 @@ def mountdmg(dmgpath): stdout=subprocess.PIPE, stderr=subprocess.PIPE) (pliststr, err) = proc.communicate() if proc.returncode: - print >> sys.stderr, 'Error: "%s" while mounting %s.' % (err, dmgname) + print('Error: "%s" while mounting %s.' % (err, dmgname), + file=sys.stderr) return None if pliststr: - plist = plistlib.readPlistFromString(pliststr) + plist = read_plist_from_string(pliststr) for entity in plist['system-entities']: if 'mount-point' in entity: mountpoints.append(entity['mount-point']) @@ -214,13 +251,13 @@ def unmountdmg(mountpoint): stderr=subprocess.PIPE) (dummy_output, err) = proc.communicate() if proc.returncode: - print >> sys.stderr, 'Polite unmount failed: %s' % err - print >> sys.stderr, 'Attempting to force unmount %s' % mountpoint + print('Polite unmount failed: %s' % err, file=sys.stderr) + print('Attempting to force unmount %s' % mountpoint, file=sys.stderr) # try forcing the unmount retcode = subprocess.call(['/usr/bin/hdiutil', 'detach', mountpoint, '-force']) if retcode: - print >> sys.stderr, 'Failed to unmount %s' % mountpoint + print('Failed to unmount %s' % mountpoint, file=sys.stderr) def install_product(dist_path, target_vol): @@ -230,8 +267,8 @@ def install_product(dist_path, target_vol): try: subprocess.check_call(cmd) return True - except subprocess.CalledProcessError, err: - print >> sys.stderr, err + except subprocess.CalledProcessError as err: + print(err, file=sys.stderr) return False @@ -248,7 +285,7 @@ def replicate_url(full_url, '''Downloads a URL and stores it in the same relative path on our filesystem. Returns a path to the replicated file.''' - path = urlparse.urlsplit(full_url)[2] + path = urlsplit(full_url)[2] relative_url = path.lstrip('/') relative_url = os.path.normpath(relative_url) local_file_path = os.path.join(root_dir, relative_url) @@ -263,10 +300,10 @@ def replicate_url(full_url, if attempt_resume: curl_cmd.extend(['-C', '-']) curl_cmd.append(full_url) - # print "Downloading %s..." % full_url + # print("Downloading %s..." % full_url) try: subprocess.check_call(curl_cmd) - except subprocess.CalledProcessError, err: + except subprocess.CalledProcessError as err: raise ReplicationError(err) return local_file_path @@ -278,9 +315,9 @@ def parse_server_metadata(filename): title = '' vers = '' try: - md_plist = plistlib.readPlist(filename) - except (OSError, IOError, ExpatError), err: - print >> sys.stderr, 'Error reading %s: %s' % (filename, err) + md_plist = read_plist(filename) + except (OSError, IOError, ExpatError) as err: + print('Error reading %s: %s' % (filename, err), file=sys.stderr) return {} vers = md_plist.get('CFBundleShortVersionString', '') localization = md_plist.get('localization', {}) @@ -303,12 +340,11 @@ def get_server_metadata(catalog, product_key, workdir, ignore_cache=False): smd_path = replicate_url( url, root_dir=workdir, ignore_cache=ignore_cache) return smd_path - except ReplicationError, err: - print >> sys.stderr, ( - 'Could not replicate %s: %s' % (url, err)) + except ReplicationError as err: + print('Could not replicate %s: %s' % (url, err), file=sys.stderr) return None except KeyError: - print >> sys.stderr, 'Malformed catalog.' + print('Malformed catalog.', file=sys.stderr) return None @@ -319,10 +355,10 @@ def parse_dist(filename): try: dom = minidom.parse(filename) except ExpatError: - print >> sys.stderr, 'Invalid XML in %s' % filename + print('Invalid XML in %s' % filename, file=sys.stderr) return dist_info - except IOError, err: - print >> sys.stderr, 'Error reading %s: %s' % (filename, err) + except IOError as err: + print('Error reading %s: %s' % (filename, err), file=sys.stderr) return dist_info auxinfos = dom.getElementsByTagName('auxinfo') @@ -380,26 +416,26 @@ def download_and_parse_sucatalog(sucatalog, workdir, ignore_cache=False): try: localcatalogpath = replicate_url( sucatalog, root_dir=workdir, ignore_cache=ignore_cache) - except ReplicationError, err: - print >> sys.stderr, 'Could not replicate %s: %s' % (sucatalog, err) + except ReplicationError as err: + print('Could not replicate %s: %s' % (sucatalog, err), file=sys.stderr) exit(-1) if os.path.splitext(localcatalogpath)[1] == '.gz': with gzip.open(localcatalogpath) as the_file: content = the_file.read() try: - catalog = plistlib.readPlistFromString(content) + catalog = read_plist_from_string(content) return catalog - except ExpatError, err: - print >> sys.stderr, ( - 'Error reading %s: %s' % (localcatalogpath, err)) + except ExpatError as err: + print('Error reading %s: %s' % (localcatalogpath, err), + file=sys.stderr) exit(-1) else: try: - catalog = plistlib.readPlist(localcatalogpath) + catalog = read_plist(localcatalogpath) return catalog - except (OSError, IOError, ExpatError), err: - print >> sys.stderr, ( - 'Error reading %s: %s' % (localcatalogpath, err)) + except (OSError, IOError, ExpatError) as err: + print('Error reading %s: %s' % (localcatalogpath, err), + file=sys.stderr) exit(-1) @@ -408,8 +444,7 @@ def find_mac_os_installers(catalog): installers''' mac_os_installer_products = [] if 'Products' in catalog: - product_keys = list(catalog['Products'].keys()) - for product_key in product_keys: + for product_key in catalog['Products'].keys(): product = catalog['Products'][product_key] try: if product['ExtendedMetaInfo'][ @@ -436,16 +471,17 @@ def os_installer_product_info(catalog, workdir, ignore_cache=False): try: dist_path = replicate_url( dist_url, root_dir=workdir, ignore_cache=ignore_cache) - except ReplicationError, err: - print >> sys.stderr, 'Could not replicate %s: %s' % (dist_url, err) - dist_info = parse_dist(dist_path) - product_info[product_key]['DistributionPath'] = dist_path - unsupported_models = get_unsupported_models(dist_path) - product_info[product_key]['UnsupportedModels'] = unsupported_models - board_ids = get_board_ids(dist_path) - product_info[product_key]['BoardIDs'] = board_ids - product_info[product_key].update(dist_info) - + except ReplicationError as err: + print('Could not replicate %s: %s' % (dist_url, err), + file=sys.stderr) + else: + dist_info = parse_dist(dist_path) + product_info[product_key]['DistributionPath'] = dist_path + unsupported_models = get_unsupported_models(dist_path) + product_info[product_key]['UnsupportedModels'] = unsupported_models + board_ids = get_board_ids(dist_path) + product_info[product_key]['BoardIDs'] = board_ids + product_info[product_key].update(dist_info) return product_info @@ -470,18 +506,17 @@ def replicate_product(catalog, product_id, workdir, ignore_cache=False): package['URL'], root_dir=workdir, show_progress=True, ignore_cache=ignore_cache, attempt_resume=(not ignore_cache)) - except ReplicationError, err: - print >> sys.stderr, ( - 'Could not replicate %s: %s' % (package['URL'], err)) + except ReplicationError as err: + print('Could not replicate %s: %s' % (package['URL'], err), + file=sys.stderr) exit(-1) if 'MetadataURL' in package: try: replicate_url(package['MetadataURL'], root_dir=workdir, ignore_cache=ignore_cache) - except ReplicationError, err: - print >> sys.stderr, ( - 'Could not replicate %s: %s' - % (package['MetadataURL'], err)) + except ReplicationError as err: + print('Could not replicate %s: %s' + % (package['MetadataURL'], err), file=sys.stderr) exit(-1) @@ -561,31 +596,29 @@ def main(): build_info = get_current_build_info() is_vm = is_a_vm() - print "This Mac:" + print('This Mac:') if is_vm == True: - print "Identified as a Virtual Machine" - print "%-17s: %s" % ('Model Identifier', hw_model) - print "%-17s: %s" % ('Board ID', board_id) - print "%-17s: %s" % ('OS Version', build_info[0]) - print "%-17s: %s\n" % ('Build ID', build_info[1]) + print('Identified as a Virtual Machine') + print('%-17s: %s' % ('Model Identifier', hw_model)) + print('%-17s: %s' % ('Board ID', board_id)) + print('%-17s: %s' % ('OS Version', build_info[0])) + print('%-17s: %s\n' % ('Build ID', build_info[1])) if args.catalogurl: su_catalog_url = args.catalogurl elif args.seedprogram: su_catalog_url = get_seed_catalog(args.seedprogram) if not su_catalog_url: - print >> sys.stderr, ( - 'Could not find a catalog url for seed program %s' - % args.seedprogram) - print >> sys.stderr, ( - 'Valid seeding programs are: %s' - % ', '.join(get_seeding_programs())) + print('Could not find a catalog url for seed program %s' + % args.seedprogram, file=sys.stderr) + print('Valid seeding programs are: %s' + % ', '.join(get_seeding_programs()), file=sys.stderr) exit(-1) else: su_catalog_url = get_default_catalog() if not su_catalog_url: - print >> sys.stderr, ( - 'Could not find a default catalog url for this OS version.') + print('Could not find a default catalog url for this OS version.', + file=sys.stderr) exit(-1) # download sucatalog and look for products that are for macOS installers @@ -595,8 +628,8 @@ def main(): catalog, args.workdir, ignore_cache=args.ignore_cache) if not product_info: - print >> sys.stderr, ( - 'No macOS installer products found in the sucatalog.') + print('No macOS installer products found in the sucatalog.', + file=sys.stderr) exit(-1) output_plist = "%s/softwareupdate.plist" % args.workdir @@ -606,8 +639,8 @@ def main(): valid_build_found = False # display a menu of choices (some seed catalogs have multiple installers) - print '%2s %-15s %-10s %-8s %-11s %-30s %s' % ('#', 'ProductID', 'Version', - 'Build', 'Post Date', 'Title', 'Notes') + print('%2s %-15s %-10s %-8s %-11s %-30s %s' % ('#', 'ProductID', 'Version', + 'Build', 'Post Date', 'Title', 'Notes')) for index, product_id in enumerate(product_info): not_valid = '' if hw_model in product_info[product_id]['UnsupportedModels'] and is_vm == False: @@ -619,7 +652,7 @@ def main(): else: valid_build_found = True - print '%2s %-15s %-10s %-8s %-11s %-30s %s' % ( + print('%2s %-15s %-10s %-8s %-11s %-30s %s' % ( index + 1, product_id, product_info[product_id]['version'], @@ -627,7 +660,7 @@ def main(): product_info[product_id]['PostDate'].strftime('%Y-%m-%d'), product_info[product_id]['title'], not_valid - ) + )) # go through various options for automatically determining the answer: @@ -648,7 +681,7 @@ def main(): continue # determine the lowest valid build ID and select this - # when using auto and version options + # when using auto and version options if (args.auto or args.version or args.os) and 'Beta' not in product_info[product_id]['title']: try: lowest_valid_build @@ -685,13 +718,13 @@ def main(): # Stop here if no valid builds found if valid_build_found == False: - print 'No valid build found for this hardware' + print('No valid build found for this hardware') exit(0) # Output a plist of available updates and quit if list option chosen if args.list: plistlib.writePlist(pl, output_plist) - print ('\n' + print('\n' 'Valid seeding programs are: %s' % ', '.join(get_seeding_programs())) exit(0) @@ -701,35 +734,41 @@ def main(): try: answer except NameError: - print ('\n' + print('\n' 'Build %s is not available. ' 'Run again without --build argument ' 'to select a valid build to download.\n' % args.build) exit(0) else: - print '\nBuild %s available. Downloading #%s...\n' % (args.build, answer) + print('\n' + 'Build %s available. Downloading #%s...\n' + % (args.build, answer)) elif args.current: try: answer except NameError: - print ('\n' + print('\n' 'Build %s is not available. ' 'Run again without --current argument ' 'to select a valid build to download.\n' % build_info[0]) exit(0) else: - print '\nBuild %s available. Downloading #%s...\n' % (build_info[0], answer) + print('\n' + 'Build %s available. Downloading #%s...\n' + % (build_info[0], answer)) elif args.version: try: answer except NameError: - print ('\n' + print('\n' 'Item # %s is not available. ' 'Run again without --version argument ' 'to select a valid build to download.\n' % args.version) exit(0) else: - print '\nBuild %s selected. Downloading #%s...\n' % (lowest_valid_build, answer) + print('\n' + 'Build %s selected. Downloading #%s...\n' + % (lowest_valid_build, answer)) elif args.os: try: answer @@ -740,30 +779,34 @@ def main(): 'to select a valid build to download.\n' % args.os) exit(0) else: - print '\nBuild %s selected. Downloading #%s...\n' % (lowest_valid_build, answer) + print('\n' + 'Build %s selected. Downloading #%s...\n' + % (lowest_valid_build, answer)) elif args.auto: try: answer except NameError: - print ('\n' + print('\n' 'No valid version available. ' 'Run again without --auto argument ' 'to select a valid build to download.\n') exit(0) else: - print '\nBuild %s selected. Downloading #%s...\n' % (lowest_valid_build, answer) + print('\n' + 'Build %s selected. Downloading #%s...\n' + % (lowest_valid_build, answer)) else: - # default option to interactively offer selection - answer = raw_input( + # default option to interactively offer selection + answer = get_input( '\nChoose a product to download (1-%s): ' % len(product_info)) try: index = int(answer) - 1 if index < 0: raise ValueError - product_id = product_info.keys()[index] + product_id = list(product_info.keys())[index] except (ValueError, IndexError): - print 'Exiting.' + print('Exiting.') exit(0) # download all the packages for the selected product @@ -779,7 +822,7 @@ def main(): os.unlink(sparse_diskimage_path) # make an empty sparseimage and mount it - print 'Making empty sparseimage...' + print('Making empty sparseimage...') sparse_diskimage_path = make_sparse_image(volname, sparse_diskimage_path) mountpoint = mountdmg(sparse_diskimage_path) if mountpoint: @@ -788,16 +831,18 @@ def main(): product_info[product_id]['DistributionPath'], mountpoint) if not success: - print >> sys.stderr, 'Product installation failed.' + print('Product installation failed.', file=sys.stderr) unmountdmg(mountpoint) exit(-1) # add the seeding program xattr to the app if applicable - seeding_program = get_seeding_program(args.catalogurl) + seeding_program = get_seeding_program(su_catalog_url) if seeding_program: installer_app = find_installer_app(mountpoint) if installer_app: + print("Adding seeding program %s extended attribute to app" + % seeding_program) xattr.setxattr(installer_app, 'SeedProgram', seeding_program) - print 'Product downloaded and installed to %s' % sparse_diskimage_path + print('Product downloaded and installed to %s' % sparse_diskimage_path) if args.raw: unmountdmg(mountpoint) else: