| #!/usr/bin/env python | 
 |  | 
 | """buildpkg.py -- Build OS X packages for Apple's Installer.app. | 
 |  | 
 | This is an experimental command-line tool for building packages to be | 
 | installed with the Mac OS X Installer.app application. | 
 |  | 
 | It is much inspired by Apple's GUI tool called PackageMaker.app, that | 
 | seems to be part of the OS X developer tools installed in the folder | 
 | /Developer/Applications. But apparently there are other free tools to | 
 | do the same thing which are also named PackageMaker like Brian Hill's | 
 | one: | 
 |  | 
 |   http://personalpages.tds.net/~brian_hill/packagemaker.html | 
 |  | 
 | Beware of the multi-package features of Installer.app (which are not | 
 | yet supported here) that can potentially screw-up your installation | 
 | and are discussed in these articles on Stepwise: | 
 |  | 
 |   http://www.stepwise.com/Articles/Technical/Packages/InstallerWoes.html | 
 |   http://www.stepwise.com/Articles/Technical/Packages/InstallerOnX.html | 
 |  | 
 | Beside using the PackageMaker class directly, by importing it inside | 
 | another module, say, there are additional ways of using this module: | 
 | the top-level buildPackage() function provides a shortcut to the same | 
 | feature and is also called when using this module from the command- | 
 | line. | 
 |  | 
 |     **************************************************************** | 
 |     NOTE: For now you should be able to run this even on a non-OS X | 
 |           system and get something similar to a package, but without | 
 |           the real archive (needs pax) and bom files (needs mkbom) | 
 |           inside! This is only for providing a chance for testing to | 
 |           folks without OS X. | 
 |     **************************************************************** | 
 |  | 
 | TODO: | 
 |   - test pre-process and post-process scripts (Python ones?) | 
 |   - handle multi-volume packages (?) | 
 |   - integrate into distutils (?) | 
 |  | 
 | Dinu C. Gherman, | 
 | gherman@europemail.com | 
 | November 2001 | 
 |  | 
 | !! USE AT YOUR OWN RISK !! | 
 | """ | 
 |  | 
 | __version__ = 0.2 | 
 | __license__ = "FreeBSD" | 
 |  | 
 |  | 
 | import os, sys, glob, fnmatch, shutil, string, copy, getopt | 
 | from os.path import basename, dirname, join, islink, isdir, isfile | 
 |  | 
 | Error = "buildpkg.Error" | 
 |  | 
 | PKG_INFO_FIELDS = """\ | 
 | Title | 
 | Version | 
 | Description | 
 | DefaultLocation | 
 | DeleteWarning | 
 | NeedsAuthorization | 
 | DisableStop | 
 | UseUserMask | 
 | Application | 
 | Relocatable | 
 | Required | 
 | InstallOnly | 
 | RequiresReboot | 
 | RootVolumeOnly | 
 | LongFilenames | 
 | LibrarySubdirectory | 
 | AllowBackRev | 
 | OverwritePermissions | 
 | InstallFat\ | 
 | """ | 
 |  | 
 | ###################################################################### | 
 | # Helpers | 
 | ###################################################################### | 
 |  | 
 | # Convenience class, as suggested by /F. | 
 |  | 
 | class GlobDirectoryWalker: | 
 |     "A forward iterator that traverses files in a directory tree." | 
 |  | 
 |     def __init__(self, directory, pattern="*"): | 
 |         self.stack = [directory] | 
 |         self.pattern = pattern | 
 |         self.files = [] | 
 |         self.index = 0 | 
 |  | 
 |  | 
 |     def __getitem__(self, index): | 
 |         while 1: | 
 |             try: | 
 |                 file = self.files[self.index] | 
 |                 self.index = self.index + 1 | 
 |             except IndexError: | 
 |                 # pop next directory from stack | 
 |                 self.directory = self.stack.pop() | 
 |                 self.files = os.listdir(self.directory) | 
 |                 self.index = 0 | 
 |             else: | 
 |                 # got a filename | 
 |                 fullname = join(self.directory, file) | 
 |                 if isdir(fullname) and not islink(fullname): | 
 |                     self.stack.append(fullname) | 
 |                 if fnmatch.fnmatch(file, self.pattern): | 
 |                     return fullname | 
 |  | 
 |  | 
 | ###################################################################### | 
 | # The real thing | 
 | ###################################################################### | 
 |  | 
 | class PackageMaker: | 
 |     """A class to generate packages for Mac OS X. | 
 |  | 
 |     This is intended to create OS X packages (with extension .pkg) | 
 |     containing archives of arbitrary files that the Installer.app | 
 |     will be able to handle. | 
 |  | 
 |     As of now, PackageMaker instances need to be created with the | 
 |     title, version and description of the package to be built. | 
 |     The package is built after calling the instance method | 
 |     build(root, **options). It has the same name as the constructor's | 
 |     title argument plus a '.pkg' extension and is located in the same | 
 |     parent folder that contains the root folder. | 
 |  | 
 |     E.g. this will create a package folder /my/space/distutils.pkg/: | 
 |  | 
 |       pm = PackageMaker("distutils", "1.0.2", "Python distutils.") | 
 |       pm.build("/my/space/distutils") | 
 |     """ | 
 |  | 
 |     packageInfoDefaults = { | 
 |         'Title': None, | 
 |         'Version': None, | 
 |         'Description': '', | 
 |         'DefaultLocation': '/', | 
 |         'DeleteWarning': '', | 
 |         'NeedsAuthorization': 'NO', | 
 |         'DisableStop': 'NO', | 
 |         'UseUserMask': 'YES', | 
 |         'Application': 'NO', | 
 |         'Relocatable': 'YES', | 
 |         'Required': 'NO', | 
 |         'InstallOnly': 'NO', | 
 |         'RequiresReboot': 'NO', | 
 |         'RootVolumeOnly' : 'NO', | 
 |         'InstallFat': 'NO', | 
 |         'LongFilenames': 'YES', | 
 |         'LibrarySubdirectory': 'Standard', | 
 |         'AllowBackRev': 'YES', | 
 |         'OverwritePermissions': 'NO', | 
 |         } | 
 |  | 
 |  | 
 |     def __init__(self, title, version, desc): | 
 |         "Init. with mandatory title/version/description arguments." | 
 |  | 
 |         info = {"Title": title, "Version": version, "Description": desc} | 
 |         self.packageInfo = copy.deepcopy(self.packageInfoDefaults) | 
 |         self.packageInfo.update(info) | 
 |  | 
 |         # variables set later | 
 |         self.packageRootFolder = None | 
 |         self.packageResourceFolder = None | 
 |         self.sourceFolder = None | 
 |         self.resourceFolder = None | 
 |  | 
 |  | 
 |     def build(self, root, resources=None, **options): | 
 |         """Create a package for some given root folder. | 
 |  | 
 |         With no 'resources' argument set it is assumed to be the same | 
 |         as the root directory. Option items replace the default ones | 
 |         in the package info. | 
 |         """ | 
 |  | 
 |         # set folder attributes | 
 |         self.sourceFolder = root | 
 |         if resources is None: | 
 |             self.resourceFolder = root | 
 |         else: | 
 |             self.resourceFolder = resources | 
 |  | 
 |         # replace default option settings with user ones if provided | 
 |         fields = self. packageInfoDefaults.keys() | 
 |         for k, v in options.items(): | 
 |             if k in fields: | 
 |                 self.packageInfo[k] = v | 
 |             elif not k in ["OutputDir"]: | 
 |                 raise Error, "Unknown package option: %s" % k | 
 |  | 
 |         # Check where we should leave the output. Default is current directory | 
 |         outputdir = options.get("OutputDir", os.getcwd()) | 
 |         packageName = self.packageInfo["Title"] | 
 |         self.PackageRootFolder = os.path.join(outputdir, packageName + ".pkg") | 
 |  | 
 |         # do what needs to be done | 
 |         self._makeFolders() | 
 |         self._addInfo() | 
 |         self._addBom() | 
 |         self._addArchive() | 
 |         self._addResources() | 
 |         self._addSizes() | 
 |         self._addLoc() | 
 |  | 
 |  | 
 |     def _makeFolders(self): | 
 |         "Create package folder structure." | 
 |  | 
 |         # Not sure if the package name should contain the version or not... | 
 |         # packageName = "%s-%s" % (self.packageInfo["Title"], | 
 |         #                          self.packageInfo["Version"]) # ?? | 
 |  | 
 |         contFolder = join(self.PackageRootFolder, "Contents") | 
 |         self.packageResourceFolder = join(contFolder, "Resources") | 
 |         os.mkdir(self.PackageRootFolder) | 
 |         os.mkdir(contFolder) | 
 |         os.mkdir(self.packageResourceFolder) | 
 |  | 
 |     def _addInfo(self): | 
 |         "Write .info file containing installing options." | 
 |  | 
 |         # Not sure if options in PKG_INFO_FIELDS are complete... | 
 |  | 
 |         info = "" | 
 |         for f in string.split(PKG_INFO_FIELDS, "\n"): | 
 |             if self.packageInfo.has_key(f): | 
 |                 info = info + "%s %%(%s)s\n" % (f, f) | 
 |         info = info % self.packageInfo | 
 |         base = self.packageInfo["Title"] + ".info" | 
 |         path = join(self.packageResourceFolder, base) | 
 |         f = open(path, "w") | 
 |         f.write(info) | 
 |  | 
 |  | 
 |     def _addBom(self): | 
 |         "Write .bom file containing 'Bill of Materials'." | 
 |  | 
 |         # Currently ignores if the 'mkbom' tool is not available. | 
 |  | 
 |         try: | 
 |             base = self.packageInfo["Title"] + ".bom" | 
 |             bomPath = join(self.packageResourceFolder, base) | 
 |             cmd = "mkbom %s %s" % (self.sourceFolder, bomPath) | 
 |             res = os.system(cmd) | 
 |         except: | 
 |             pass | 
 |  | 
 |  | 
 |     def _addArchive(self): | 
 |         "Write .pax.gz file, a compressed archive using pax/gzip." | 
 |  | 
 |         # Currently ignores if the 'pax' tool is not available. | 
 |  | 
 |         cwd = os.getcwd() | 
 |  | 
 |         # create archive | 
 |         os.chdir(self.sourceFolder) | 
 |         base = basename(self.packageInfo["Title"]) + ".pax" | 
 |         self.archPath = join(self.packageResourceFolder, base) | 
 |         cmd = "pax -w -f %s %s" % (self.archPath, ".") | 
 |         res = os.system(cmd) | 
 |  | 
 |         # compress archive | 
 |         cmd = "gzip %s" % self.archPath | 
 |         res = os.system(cmd) | 
 |         os.chdir(cwd) | 
 |  | 
 |  | 
 |     def _addResources(self): | 
 |         "Add Welcome/ReadMe/License files, .lproj folders and scripts." | 
 |  | 
 |         # Currently we just copy everything that matches the allowed | 
 |         # filenames. So, it's left to Installer.app to deal with the | 
 |         # same file available in multiple formats... | 
 |  | 
 |         if not self.resourceFolder: | 
 |             return | 
 |  | 
 |         # find candidate resource files (txt html rtf rtfd/ or lproj/) | 
 |         allFiles = [] | 
 |         for pat in string.split("*.txt *.html *.rtf *.rtfd *.lproj", " "): | 
 |             pattern = join(self.resourceFolder, pat) | 
 |             allFiles = allFiles + glob.glob(pattern) | 
 |  | 
 |         # find pre-process and post-process scripts | 
 |         # naming convention: packageName.{pre,post}_{upgrade,install} | 
 |         # Alternatively the filenames can be {pre,post}_{upgrade,install} | 
 |         # in which case we prepend the package name | 
 |         packageName = self.packageInfo["Title"] | 
 |         for pat in ("*upgrade", "*install", "*flight"): | 
 |             pattern = join(self.resourceFolder, packageName + pat) | 
 |             pattern2 = join(self.resourceFolder, pat) | 
 |             allFiles = allFiles + glob.glob(pattern) | 
 |             allFiles = allFiles + glob.glob(pattern2) | 
 |  | 
 |         # check name patterns | 
 |         files = [] | 
 |         for f in allFiles: | 
 |             for s in ("Welcome", "License", "ReadMe"): | 
 |                 if string.find(basename(f), s) == 0: | 
 |                     files.append((f, f)) | 
 |             if f[-6:] == ".lproj": | 
 |                 files.append((f, f)) | 
 |             elif basename(f) in ["pre_upgrade", "pre_install", "post_upgrade", "post_install"]: | 
 |                 files.append((f, packageName+"."+basename(f))) | 
 |             elif basename(f) in ["preflight", "postflight"]: | 
 |                 files.append((f, f)) | 
 |             elif f[-8:] == "_upgrade": | 
 |                 files.append((f,f)) | 
 |             elif f[-8:] == "_install": | 
 |                 files.append((f,f)) | 
 |  | 
 |         # copy files | 
 |         for src, dst in files: | 
 |             src = basename(src) | 
 |             dst = basename(dst) | 
 |             f = join(self.resourceFolder, src) | 
 |             if isfile(f): | 
 |                 shutil.copy(f, os.path.join(self.packageResourceFolder, dst)) | 
 |             elif isdir(f): | 
 |                 # special case for .rtfd and .lproj folders... | 
 |                 d = join(self.packageResourceFolder, dst) | 
 |                 os.mkdir(d) | 
 |                 files = GlobDirectoryWalker(f) | 
 |                 for file in files: | 
 |                     shutil.copy(file, d) | 
 |  | 
 |  | 
 |     def _addSizes(self): | 
 |         "Write .sizes file with info about number and size of files." | 
 |  | 
 |         # Not sure if this is correct, but 'installedSize' and | 
 |         # 'zippedSize' are now in Bytes. Maybe blocks are needed? | 
 |         # Well, Installer.app doesn't seem to care anyway, saying | 
 |         # the installation needs 100+ MB... | 
 |  | 
 |         numFiles = 0 | 
 |         installedSize = 0 | 
 |         zippedSize = 0 | 
 |  | 
 |         files = GlobDirectoryWalker(self.sourceFolder) | 
 |         for f in files: | 
 |             numFiles = numFiles + 1 | 
 |             installedSize = installedSize + os.lstat(f)[6] | 
 |  | 
 |         try: | 
 |             zippedSize = os.stat(self.archPath+ ".gz")[6] | 
 |         except OSError: # ignore error | 
 |             pass | 
 |         base = self.packageInfo["Title"] + ".sizes" | 
 |         f = open(join(self.packageResourceFolder, base), "w") | 
 |         format = "NumFiles %d\nInstalledSize %d\nCompressedSize %d\n" | 
 |         f.write(format % (numFiles, installedSize, zippedSize)) | 
 |  | 
 |     def _addLoc(self): | 
 |         "Write .loc file." | 
 |         base = self.packageInfo["Title"] + ".loc" | 
 |         f = open(join(self.packageResourceFolder, base), "w") | 
 |         f.write('/') | 
 |  | 
 | # Shortcut function interface | 
 |  | 
 | def buildPackage(*args, **options): | 
 |     "A Shortcut function for building a package." | 
 |  | 
 |     o = options | 
 |     title, version, desc = o["Title"], o["Version"], o["Description"] | 
 |     pm = PackageMaker(title, version, desc) | 
 |     apply(pm.build, list(args), options) | 
 |  | 
 |  | 
 | ###################################################################### | 
 | # Tests | 
 | ###################################################################### | 
 |  | 
 | def test0(): | 
 |     "Vanilla test for the distutils distribution." | 
 |  | 
 |     pm = PackageMaker("distutils2", "1.0.2", "Python distutils package.") | 
 |     pm.build("/Users/dinu/Desktop/distutils2") | 
 |  | 
 |  | 
 | def test1(): | 
 |     "Test for the reportlab distribution with modified options." | 
 |  | 
 |     pm = PackageMaker("reportlab", "1.10", | 
 |                       "ReportLab's Open Source PDF toolkit.") | 
 |     pm.build(root="/Users/dinu/Desktop/reportlab", | 
 |              DefaultLocation="/Applications/ReportLab", | 
 |              Relocatable="YES") | 
 |  | 
 | def test2(): | 
 |     "Shortcut test for the reportlab distribution with modified options." | 
 |  | 
 |     buildPackage( | 
 |         "/Users/dinu/Desktop/reportlab", | 
 |         Title="reportlab", | 
 |         Version="1.10", | 
 |         Description="ReportLab's Open Source PDF toolkit.", | 
 |         DefaultLocation="/Applications/ReportLab", | 
 |         Relocatable="YES") | 
 |  | 
 |  | 
 | ###################################################################### | 
 | # Command-line interface | 
 | ###################################################################### | 
 |  | 
 | def printUsage(): | 
 |     "Print usage message." | 
 |  | 
 |     format = "Usage: %s <opts1> [<opts2>] <root> [<resources>]" | 
 |     print format % basename(sys.argv[0]) | 
 |     print | 
 |     print "       with arguments:" | 
 |     print "           (mandatory) root:         the package root folder" | 
 |     print "           (optional)  resources:    the package resources folder" | 
 |     print | 
 |     print "       and options:" | 
 |     print "           (mandatory) opts1:" | 
 |     mandatoryKeys = string.split("Title Version Description", " ") | 
 |     for k in mandatoryKeys: | 
 |         print "               --%s" % k | 
 |     print "           (optional) opts2: (with default values)" | 
 |  | 
 |     pmDefaults = PackageMaker.packageInfoDefaults | 
 |     optionalKeys = pmDefaults.keys() | 
 |     for k in mandatoryKeys: | 
 |         optionalKeys.remove(k) | 
 |     optionalKeys.sort() | 
 |     maxKeyLen = max(map(len, optionalKeys)) | 
 |     for k in optionalKeys: | 
 |         format = "               --%%s:%s %%s" | 
 |         format = format % (" " * (maxKeyLen-len(k))) | 
 |         print format % (k, repr(pmDefaults[k])) | 
 |  | 
 |  | 
 | def main(): | 
 |     "Command-line interface." | 
 |  | 
 |     shortOpts = "" | 
 |     keys = PackageMaker.packageInfoDefaults.keys() | 
 |     longOpts = map(lambda k: k+"=", keys) | 
 |  | 
 |     try: | 
 |         opts, args = getopt.getopt(sys.argv[1:], shortOpts, longOpts) | 
 |     except getopt.GetoptError, details: | 
 |         print details | 
 |         printUsage() | 
 |         return | 
 |  | 
 |     optsDict = {} | 
 |     for k, v in opts: | 
 |         optsDict[k[2:]] = v | 
 |  | 
 |     ok = optsDict.keys() | 
 |     if not (1 <= len(args) <= 2): | 
 |         print "No argument given!" | 
 |     elif not ("Title" in ok and \ | 
 |               "Version" in ok and \ | 
 |               "Description" in ok): | 
 |         print "Missing mandatory option!" | 
 |     else: | 
 |         apply(buildPackage, args, optsDict) | 
 |         return | 
 |  | 
 |     printUsage() | 
 |  | 
 |     # sample use: | 
 |     # buildpkg.py --Title=distutils \ | 
 |     #             --Version=1.0.2 \ | 
 |     #             --Description="Python distutils package." \ | 
 |     #             /Users/dinu/Desktop/distutils | 
 |  | 
 |  | 
 | if __name__ == "__main__": | 
 |     main() |