1#!/usr/bin/env python 2 3"""buildpkg.py -- Build OS X packages for Apple's Installer.app. 4 5This is an experimental command-line tool for building packages to be 6installed with the Mac OS X Installer.app application. 7 8It is much inspired by Apple's GUI tool called PackageMaker.app, that 9seems to be part of the OS X developer tools installed in the folder 10/Developer/Applications. But apparently there are other free tools to 11do the same thing which are also named PackageMaker like Brian Hill's 12one: 13 14 http://personalpages.tds.net/~brian_hill/packagemaker.html 15 16Beware of the multi-package features of Installer.app (which are not 17yet supported here) that can potentially screw-up your installation 18and 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 23Beside using the PackageMaker class directly, by importing it inside 24another module, say, there are additional ways of using this module: 25the top-level buildPackage() function provides a shortcut to the same 26feature and is also called when using this module from the command- 27line. 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 37TODO: 38 - test pre-process and post-process scripts (Python ones?) 39 - handle multi-volume packages (?) 40 - integrate into distutils (?) 41 42Dinu C. Gherman, 43[email protected] 44November 2001 45 46!! USE AT YOUR OWN RISK !! 47""" 48 49__version__ = 0.2 50__license__ = "FreeBSD" 51 52 53import os, sys, glob, fnmatch, shutil, string, copy, getopt 54from os.path import basename, dirname, join, islink, isdir, isfile 55 56Error = "buildpkg.Error" 57 58PKG_INFO_FIELDS = """\ 59Title 60Version 61Description 62DefaultLocation 63DeleteWarning 64NeedsAuthorization 65DisableStop 66UseUserMask 67Application 68Relocatable 69Required 70InstallOnly 71RequiresReboot 72RootVolumeOnly 73LongFilenames 74LibrarySubdirectory 75AllowBackRev 76OverwritePermissions 77InstallFat\ 78""" 79 80###################################################################### 81# Helpers 82###################################################################### 83 84# Convenience class, as suggested by /F. 85 86class 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 119class 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 371def 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 384def 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 391def 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 400def 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 416def 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 445def 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 483if __name__ == "__main__": 484 main() 485