Home | History | Annotate | Download | only in scripts
      1 #!/usr/bin/env python
      2 
      3 """buildpkg.py -- Build OS X packages for Apple's Installer.app.
      4 
      5 This is an experimental command-line tool for building packages to be
      6 installed with the Mac OS X Installer.app application.
      7 
      8 It is much inspired by Apple's GUI tool called PackageMaker.app, that
      9 seems to be part of the OS X developer tools installed in the folder
     10 /Developer/Applications. But apparently there are other free tools to
     11 do the same thing which are also named PackageMaker like Brian Hill's
     12 one:
     13 
     14   http://personalpages.tds.net/~brian_hill/packagemaker.html
     15 
     16 Beware of the multi-package features of Installer.app (which are not
     17 yet supported here) that can potentially screw-up your installation
     18 and are discussed in these articles on Stepwise:
     19 
     20   http://www.stepwise.com/Articles/Technical/Packages/InstallerWoes.html
     21   http://www.stepwise.com/Articles/Technical/Packages/InstallerOnX.html
     22 
     23 Beside using the PackageMaker class directly, by importing it inside
     24 another module, say, there are additional ways of using this module:
     25 the top-level buildPackage() function provides a shortcut to the same
     26 feature and is also called when using this module from the command-
     27 line.
     28 
     29     ****************************************************************
     30     NOTE: For now you should be able to run this even on a non-OS X
     31           system and get something similar to a package, but without
     32           the real archive (needs pax) and bom files (needs mkbom)
     33           inside! This is only for providing a chance for testing to
     34           folks without OS X.
     35     ****************************************************************
     36 
     37 TODO:
     38   - test pre-process and post-process scripts (Python ones?)
     39   - handle multi-volume packages (?)
     40   - integrate into distutils (?)
     41 
     42 Dinu C. Gherman,
     43 gherman (at] europemail.com
     44 November 2001
     45 
     46 !! USE AT YOUR OWN RISK !!
     47 """
     48 
     49 __version__ = 0.2
     50 __license__ = "FreeBSD"
     51 
     52 
     53 import os, sys, glob, fnmatch, shutil, string, copy, getopt
     54 from os.path import basename, dirname, join, islink, isdir, isfile
     55 
     56 Error = "buildpkg.Error"
     57 
     58 PKG_INFO_FIELDS = """\
     59 Title
     60 Version
     61 Description
     62 DefaultLocation
     63 DeleteWarning
     64 NeedsAuthorization
     65 DisableStop
     66 UseUserMask
     67 Application
     68 Relocatable
     69 Required
     70 InstallOnly
     71 RequiresReboot
     72 RootVolumeOnly
     73 LongFilenames
     74 LibrarySubdirectory
     75 AllowBackRev
     76 OverwritePermissions
     77 InstallFat\
     78 """
     79 
     80 ######################################################################
     81 # Helpers
     82 ######################################################################
     83 
     84 # Convenience class, as suggested by /F.
     85 
     86 class GlobDirectoryWalker:
     87     "A forward iterator that traverses files in a directory tree."
     88 
     89     def __init__(self, directory, pattern="*"):
     90         self.stack = [directory]
     91         self.pattern = pattern
     92         self.files = []
     93         self.index = 0
     94 
     95 
     96     def __getitem__(self, index):
     97         while 1:
     98             try:
     99                 file = self.files[self.index]
    100                 self.index = self.index + 1
    101             except IndexError:
    102                 # pop next directory from stack
    103                 self.directory = self.stack.pop()
    104                 self.files = os.listdir(self.directory)
    105                 self.index = 0
    106             else:
    107                 # got a filename
    108                 fullname = join(self.directory, file)
    109                 if isdir(fullname) and not islink(fullname):
    110                     self.stack.append(fullname)
    111                 if fnmatch.fnmatch(file, self.pattern):
    112                     return fullname
    113 
    114 
    115 ######################################################################
    116 # The real thing
    117 ######################################################################
    118 
    119 class PackageMaker:
    120     """A class to generate packages for Mac OS X.
    121 
    122     This is intended to create OS X packages (with extension .pkg)
    123     containing archives of arbitrary files that the Installer.app
    124     will be able to handle.
    125 
    126     As of now, PackageMaker instances need to be created with the
    127     title, version and description of the package to be built.
    128     The package is built after calling the instance method
    129     build(root, **options). It has the same name as the constructor's
    130     title argument plus a '.pkg' extension and is located in the same
    131     parent folder that contains the root folder.
    132 
    133     E.g. this will create a package folder /my/space/distutils.pkg/:
    134 
    135       pm = PackageMaker("distutils", "1.0.2", "Python distutils.")
    136       pm.build("/my/space/distutils")
    137     """
    138 
    139     packageInfoDefaults = {
    140         'Title': None,
    141         'Version': None,
    142         'Description': '',
    143         'DefaultLocation': '/',
    144         'DeleteWarning': '',
    145         'NeedsAuthorization': 'NO',
    146         'DisableStop': 'NO',
    147         'UseUserMask': 'YES',
    148         'Application': 'NO',
    149         'Relocatable': 'YES',
    150         'Required': 'NO',
    151         'InstallOnly': 'NO',
    152         'RequiresReboot': 'NO',
    153         'RootVolumeOnly' : 'NO',
    154         'InstallFat': 'NO',
    155         'LongFilenames': 'YES',
    156         'LibrarySubdirectory': 'Standard',
    157         'AllowBackRev': 'YES',
    158         'OverwritePermissions': 'NO',
    159         }
    160 
    161 
    162     def __init__(self, title, version, desc):
    163         "Init. with mandatory title/version/description arguments."
    164 
    165         info = {"Title": title, "Version": version, "Description": desc}
    166         self.packageInfo = copy.deepcopy(self.packageInfoDefaults)
    167         self.packageInfo.update(info)
    168 
    169         # variables set later
    170         self.packageRootFolder = None
    171         self.packageResourceFolder = None
    172         self.sourceFolder = None
    173         self.resourceFolder = None
    174 
    175 
    176     def build(self, root, resources=None, **options):
    177         """Create a package for some given root folder.
    178 
    179         With no 'resources' argument set it is assumed to be the same
    180         as the root directory. Option items replace the default ones
    181         in the package info.
    182         """
    183 
    184         # set folder attributes
    185         self.sourceFolder = root
    186         if resources is None:
    187             self.resourceFolder = root
    188         else:
    189             self.resourceFolder = resources
    190 
    191         # replace default option settings with user ones if provided
    192         fields = self. packageInfoDefaults.keys()
    193         for k, v in options.items():
    194             if k in fields:
    195                 self.packageInfo[k] = v
    196             elif not k in ["OutputDir"]:
    197                 raise Error, "Unknown package option: %s" % k
    198 
    199         # Check where we should leave the output. Default is current directory
    200         outputdir = options.get("OutputDir", os.getcwd())
    201         packageName = self.packageInfo["Title"]
    202         self.PackageRootFolder = os.path.join(outputdir, packageName + ".pkg")
    203 
    204         # do what needs to be done
    205         self._makeFolders()
    206         self._addInfo()
    207         self._addBom()
    208         self._addArchive()
    209         self._addResources()
    210         self._addSizes()
    211         self._addLoc()
    212 
    213 
    214     def _makeFolders(self):
    215         "Create package folder structure."
    216 
    217         # Not sure if the package name should contain the version or not...
    218         # packageName = "%s-%s" % (self.packageInfo["Title"],
    219         #                          self.packageInfo["Version"]) # ??
    220 
    221         contFolder = join(self.PackageRootFolder, "Contents")
    222         self.packageResourceFolder = join(contFolder, "Resources")
    223         os.mkdir(self.PackageRootFolder)
    224         os.mkdir(contFolder)
    225         os.mkdir(self.packageResourceFolder)
    226 
    227     def _addInfo(self):
    228         "Write .info file containing installing options."
    229 
    230         # Not sure if options in PKG_INFO_FIELDS are complete...
    231 
    232         info = ""
    233         for f in string.split(PKG_INFO_FIELDS, "\n"):
    234             if self.packageInfo.has_key(f):
    235                 info = info + "%s %%(%s)s\n" % (f, f)
    236         info = info % self.packageInfo
    237         base = self.packageInfo["Title"] + ".info"
    238         path = join(self.packageResourceFolder, base)
    239         f = open(path, "w")
    240         f.write(info)
    241 
    242 
    243     def _addBom(self):
    244         "Write .bom file containing 'Bill of Materials'."
    245 
    246         # Currently ignores if the 'mkbom' tool is not available.
    247 
    248         try:
    249             base = self.packageInfo["Title"] + ".bom"
    250             bomPath = join(self.packageResourceFolder, base)
    251             cmd = "mkbom %s %s" % (self.sourceFolder, bomPath)
    252             res = os.system(cmd)
    253         except:
    254             pass
    255 
    256 
    257     def _addArchive(self):
    258         "Write .pax.gz file, a compressed archive using pax/gzip."
    259 
    260         # Currently ignores if the 'pax' tool is not available.
    261 
    262         cwd = os.getcwd()
    263 
    264         # create archive
    265         os.chdir(self.sourceFolder)
    266         base = basename(self.packageInfo["Title"]) + ".pax"
    267         self.archPath = join(self.packageResourceFolder, base)
    268         cmd = "pax -w -f %s %s" % (self.archPath, ".")
    269         res = os.system(cmd)
    270 
    271         # compress archive
    272         cmd = "gzip %s" % self.archPath
    273         res = os.system(cmd)
    274         os.chdir(cwd)
    275 
    276 
    277     def _addResources(self):
    278         "Add Welcome/ReadMe/License files, .lproj folders and scripts."
    279 
    280         # Currently we just copy everything that matches the allowed
    281         # filenames. So, it's left to Installer.app to deal with the
    282         # same file available in multiple formats...
    283 
    284         if not self.resourceFolder:
    285             return
    286 
    287         # find candidate resource files (txt html rtf rtfd/ or lproj/)
    288         allFiles = []
    289         for pat in string.split("*.txt *.html *.rtf *.rtfd *.lproj", " "):
    290             pattern = join(self.resourceFolder, pat)
    291             allFiles = allFiles + glob.glob(pattern)
    292 
    293         # find pre-process and post-process scripts
    294         # naming convention: packageName.{pre,post}_{upgrade,install}
    295         # Alternatively the filenames can be {pre,post}_{upgrade,install}
    296         # in which case we prepend the package name
    297         packageName = self.packageInfo["Title"]
    298         for pat in ("*upgrade", "*install", "*flight"):
    299             pattern = join(self.resourceFolder, packageName + pat)
    300             pattern2 = join(self.resourceFolder, pat)
    301             allFiles = allFiles + glob.glob(pattern)
    302             allFiles = allFiles + glob.glob(pattern2)
    303 
    304         # check name patterns
    305         files = []
    306         for f in allFiles:
    307             for s in ("Welcome", "License", "ReadMe"):
    308                 if string.find(basename(f), s) == 0:
    309                     files.append((f, f))
    310             if f[-6:] == ".lproj":
    311                 files.append((f, f))
    312             elif basename(f) in ["pre_upgrade", "pre_install", "post_upgrade", "post_install"]:
    313                 files.append((f, packageName+"."+basename(f)))
    314             elif basename(f) in ["preflight", "postflight"]:
    315                 files.append((f, f))
    316             elif f[-8:] == "_upgrade":
    317                 files.append((f,f))
    318             elif f[-8:] == "_install":
    319                 files.append((f,f))
    320 
    321         # copy files
    322         for src, dst in files:
    323             src = basename(src)
    324             dst = basename(dst)
    325             f = join(self.resourceFolder, src)
    326             if isfile(f):
    327                 shutil.copy(f, os.path.join(self.packageResourceFolder, dst))
    328             elif isdir(f):
    329                 # special case for .rtfd and .lproj folders...
    330                 d = join(self.packageResourceFolder, dst)
    331                 os.mkdir(d)
    332                 files = GlobDirectoryWalker(f)
    333                 for file in files:
    334                     shutil.copy(file, d)
    335 
    336 
    337     def _addSizes(self):
    338         "Write .sizes file with info about number and size of files."
    339 
    340         # Not sure if this is correct, but 'installedSize' and
    341         # 'zippedSize' are now in Bytes. Maybe blocks are needed?
    342         # Well, Installer.app doesn't seem to care anyway, saying
    343         # the installation needs 100+ MB...
    344 
    345         numFiles = 0
    346         installedSize = 0
    347         zippedSize = 0
    348 
    349         files = GlobDirectoryWalker(self.sourceFolder)
    350         for f in files:
    351             numFiles = numFiles + 1
    352             installedSize = installedSize + os.lstat(f)[6]
    353 
    354         try:
    355             zippedSize = os.stat(self.archPath+ ".gz")[6]
    356         except OSError: # ignore error
    357             pass
    358         base = self.packageInfo["Title"] + ".sizes"
    359         f = open(join(self.packageResourceFolder, base), "w")
    360         format = "NumFiles %d\nInstalledSize %d\nCompressedSize %d\n"
    361         f.write(format % (numFiles, installedSize, zippedSize))
    362 
    363     def _addLoc(self):
    364         "Write .loc file."
    365         base = self.packageInfo["Title"] + ".loc"
    366         f = open(join(self.packageResourceFolder, base), "w")
    367         f.write('/')
    368 
    369 # Shortcut function interface
    370 
    371 def buildPackage(*args, **options):
    372     "A Shortcut function for building a package."
    373 
    374     o = options
    375     title, version, desc = o["Title"], o["Version"], o["Description"]
    376     pm = PackageMaker(title, version, desc)
    377     apply(pm.build, list(args), options)
    378 
    379 
    380 ######################################################################
    381 # Tests
    382 ######################################################################
    383 
    384 def test0():
    385     "Vanilla test for the distutils distribution."
    386 
    387     pm = PackageMaker("distutils2", "1.0.2", "Python distutils package.")
    388     pm.build("/Users/dinu/Desktop/distutils2")
    389 
    390 
    391 def test1():
    392     "Test for the reportlab distribution with modified options."
    393 
    394     pm = PackageMaker("reportlab", "1.10",
    395                       "ReportLab's Open Source PDF toolkit.")
    396     pm.build(root="/Users/dinu/Desktop/reportlab",
    397              DefaultLocation="/Applications/ReportLab",
    398              Relocatable="YES")
    399 
    400 def test2():
    401     "Shortcut test for the reportlab distribution with modified options."
    402 
    403     buildPackage(
    404         "/Users/dinu/Desktop/reportlab",
    405         Title="reportlab",
    406         Version="1.10",
    407         Description="ReportLab's Open Source PDF toolkit.",
    408         DefaultLocation="/Applications/ReportLab",
    409         Relocatable="YES")
    410 
    411 
    412 ######################################################################
    413 # Command-line interface
    414 ######################################################################
    415 
    416 def printUsage():
    417     "Print usage message."
    418 
    419     format = "Usage: %s <opts1> [<opts2>] <root> [<resources>]"
    420     print format % basename(sys.argv[0])
    421     print
    422     print "       with arguments:"
    423     print "           (mandatory) root:         the package root folder"
    424     print "           (optional)  resources:    the package resources folder"
    425     print
    426     print "       and options:"
    427     print "           (mandatory) opts1:"
    428     mandatoryKeys = string.split("Title Version Description", " ")
    429     for k in mandatoryKeys:
    430         print "               --%s" % k
    431     print "           (optional) opts2: (with default values)"
    432 
    433     pmDefaults = PackageMaker.packageInfoDefaults
    434     optionalKeys = pmDefaults.keys()
    435     for k in mandatoryKeys:
    436         optionalKeys.remove(k)
    437     optionalKeys.sort()
    438     maxKeyLen = max(map(len, optionalKeys))
    439     for k in optionalKeys:
    440         format = "               --%%s:%s %%s"
    441         format = format % (" " * (maxKeyLen-len(k)))
    442         print format % (k, repr(pmDefaults[k]))
    443 
    444 
    445 def main():
    446     "Command-line interface."
    447 
    448     shortOpts = ""
    449     keys = PackageMaker.packageInfoDefaults.keys()
    450     longOpts = map(lambda k: k+"=", keys)
    451 
    452     try:
    453         opts, args = getopt.getopt(sys.argv[1:], shortOpts, longOpts)
    454     except getopt.GetoptError, details:
    455         print details
    456         printUsage()
    457         return
    458 
    459     optsDict = {}
    460     for k, v in opts:
    461         optsDict[k[2:]] = v
    462 
    463     ok = optsDict.keys()
    464     if not (1 <= len(args) <= 2):
    465         print "No argument given!"
    466     elif not ("Title" in ok and \
    467               "Version" in ok and \
    468               "Description" in ok):
    469         print "Missing mandatory option!"
    470     else:
    471         apply(buildPackage, args, optsDict)
    472         return
    473 
    474     printUsage()
    475 
    476     # sample use:
    477     # buildpkg.py --Title=distutils \
    478     #             --Version=1.0.2 \
    479     #             --Description="Python distutils package." \
    480     #             /Users/dinu/Desktop/distutils
    481 
    482 
    483 if __name__ == "__main__":
    484     main()
    485