/Mac/scripts/buildpkg.py
http://unladen-swallow.googlecode.com/ · Python · 484 lines · 394 code · 52 blank · 38 comment · 43 complexity · d124a224689fce11825055261a055080 MD5 · raw file
- #!/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()