3 """buildpkg.py -- Build OS X packages for Apple's Installer.app. 
   5 This is an experimental command-line tool for building packages to be 
   6 installed with the Mac OS X Installer.app application.  
   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  
  14   http://personalpages.tds.net/~brian_hill/packagemaker.html 
  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: 
  20   http://www.stepwise.com/Articles/Technical/Packages/InstallerWoes.html 
  21   http://www.stepwise.com/Articles/Technical/Packages/InstallerOnX.html 
  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- 
  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  
  35     **************************************************************** 
  38   - test pre-process and post-process scripts (Python ones?) 
  39   - handle multi-volume packages (?) 
  40   - integrate into distutils (?) 
  43 gherman@europemail.com 
  46 !! USE AT YOUR OWN RISK !! 
  50 __license__ 
= "FreeBSD" 
  53 import os
, sys
, glob
, fnmatch
, shutil
, string
, copy
, getopt
 
  54 from os
.path 
import basename
, dirname
, join
, islink
, isdir
, isfile
 
  56 Error 
= "buildpkg.Error" 
  58 PKG_INFO_FIELDS 
= """\ 
  80 ###################################################################### 
  82 ###################################################################### 
  84 # Convenience class, as suggested by /F. 
  86 class GlobDirectoryWalker
: 
  87     "A forward iterator that traverses files in a directory tree." 
  89     def __init__(self
, directory
, pattern
="*"): 
  90         self
.stack 
= [directory
] 
  91         self
.pattern 
= pattern
 
  96     def __getitem__(self
, index
): 
  99                 file = self
.files
[self
.index
] 
 100                 self
.index 
= self
.index 
+ 1 
 102                 # pop next directory from stack 
 103                 self
.directory 
= self
.stack
.pop() 
 104                 self
.files 
= os
.listdir(self
.directory
) 
 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
): 
 115 ###################################################################### 
 117 ###################################################################### 
 120     """A class to generate packages for Mac OS X. 
 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. 
 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. 
 133     E.g. this will create a package folder /my/space/distutils.pkg/: 
 135       pm = PackageMaker("distutils", "1.0.2", "Python distutils.") 
 136       pm.build("/my/space/distutils") 
 139     packageInfoDefaults 
= { 
 143         'DefaultLocation': '/', 
 145         'NeedsAuthorization': 'NO', 
 147         'UseUserMask': 'YES', 
 149         'Relocatable': 'YES', 
 152         'RequiresReboot': 'NO', 
 153         'RootVolumeOnly' : 'NO', 
 155         'LongFilenames': 'YES', 
 156         'LibrarySubdirectory': 'Standard', 
 157         'AllowBackRev': 'YES', 
 158         'OverwritePermissions': 'NO', 
 162     def __init__(self
, title
, version
, desc
): 
 163         "Init. with mandatory title/version/description arguments." 
 165         info 
= {"Title": title, "Version": version, "Description": desc}
 
 166         self
.packageInfo 
= copy
.deepcopy(self
.packageInfoDefaults
) 
 167         self
.packageInfo
.update(info
) 
 169         # variables set later 
 170         self
.packageRootFolder 
= None 
 171         self
.packageResourceFolder 
= None 
 172         self
.sourceFolder 
= None 
 173         self
.resourceFolder 
= None 
 176     def build(self
, root
, resources
=None, **options
): 
 177         """Create a package for some given root folder. 
 179         With no 'resources' argument set it is assumed to be the same  
 180         as the root directory. Option items replace the default ones  
 184         # set folder attributes 
 185         self
.sourceFolder 
= root
 
 186         if resources 
== None: 
 187             self
.resourceFolder 
= root
 
 189             self
.resourceFolder 
= resources
 
 191         # replace default option settings with user ones if provided 
 192         fields 
= self
. packageInfoDefaults
.keys() 
 193         for k
, v 
in options
.items(): 
 195                 self
.packageInfo
[k
] = v
 
 196             elif not k 
in ["OutputDir"]: 
 197                 raise Error
, "Unknown package option: %s" % k
 
 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") 
 204         # do what needs to be done 
 214     def _makeFolders(self
): 
 215         "Create package folder structure." 
 217         # Not sure if the package name should contain the version or not... 
 218         # packageName = "%s-%s" % (self.packageInfo["Title"],  
 219         #                          self.packageInfo["Version"]) # ?? 
 221         contFolder 
= join(self
.PackageRootFolder
, "Contents") 
 222         self
.packageResourceFolder 
= join(contFolder
, "Resources") 
 223         os
.mkdir(self
.PackageRootFolder
) 
 225         os
.mkdir(self
.packageResourceFolder
) 
 228         "Write .info file containing installing options." 
 230         # Not sure if options in PKG_INFO_FIELDS are complete... 
 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
) 
 244         "Write .bom file containing 'Bill of Materials'." 
 246         # Currently ignores if the 'mkbom' tool is not available. 
 249             base 
= self
.packageInfo
["Title"] + ".bom" 
 250             bomPath 
= join(self
.packageResourceFolder
, base
) 
 251             cmd 
= "mkbom %s %s" % (self
.sourceFolder
, bomPath
) 
 257     def _addArchive(self
): 
 258         "Write .pax.gz file, a compressed archive using pax/gzip." 
 260         # Currently ignores if the 'pax' tool is not available. 
 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
, ".") 
 272         cmd 
= "gzip %s" % self
.archPath
 
 277     def _addResources(self
): 
 278         "Add Welcome/ReadMe/License files, .lproj folders and scripts." 
 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... 
 284         if not self
.resourceFolder
: 
 287         # find candidate resource files (txt html rtf rtfd/ or lproj/) 
 289         for pat 
in string
.split("*.txt *.html *.rtf *.rtfd *.lproj", " "): 
 290             pattern 
= join(self
.resourceFolder
, pat
) 
 291             allFiles 
= allFiles 
+ glob
.glob(pattern
) 
 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
) 
 304         # check name patterns 
 307             for s 
in ("Welcome", "License", "ReadMe"): 
 308                 if string
.find(basename(f
), s
) == 0: 
 310             if f
[-6:] == ".lproj": 
 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"]: 
 316             elif f
[-8:] == "_upgrade": 
 318             elif f
[-8:] == "_install": 
 322         for src
, dst 
in files
: 
 325             f 
= join(self
.resourceFolder
, src
) 
 327                 shutil
.copy(f
, os
.path
.join(self
.packageResourceFolder
, dst
)) 
 329                 # special case for .rtfd and .lproj folders... 
 330                 d 
= join(self
.packageResourceFolder
, dst
) 
 332                 files 
= GlobDirectoryWalker(f
) 
 338         "Write .sizes file with info about number and size of files." 
 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... 
 349         files 
= GlobDirectoryWalker(self
.sourceFolder
) 
 351             numFiles 
= numFiles 
+ 1 
 352             installedSize 
= installedSize 
+ os
.lstat(f
)[6] 
 355             zippedSize 
= os
.stat(self
.archPath
+ ".gz")[6] 
 356         except OSError: # ignore error  
 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
)) 
 365         base 
= self
.packageInfo
["Title"] + ".loc" 
 366         f 
= open(join(self
.packageResourceFolder
, base
), "w") 
 369 # Shortcut function interface 
 371 def buildPackage(*args
, **options
): 
 372     "A Shortcut function for building a package." 
 375     title
, version
, desc 
= o
["Title"], o
["Version"], o
["Description"] 
 376     pm 
= PackageMaker(title
, version
, desc
) 
 377     apply(pm
.build
, list(args
), options
) 
 380 ###################################################################### 
 382 ###################################################################### 
 385     "Vanilla test for the distutils distribution." 
 387     pm 
= PackageMaker("distutils2", "1.0.2", "Python distutils package.") 
 388     pm
.build("/Users/dinu/Desktop/distutils2") 
 392     "Test for the reportlab distribution with modified options." 
 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", 
 401     "Shortcut test for the reportlab distribution with modified options." 
 404         "/Users/dinu/Desktop/reportlab",  
 407         Description
="ReportLab's Open Source PDF toolkit.", 
 408         DefaultLocation
="/Applications/ReportLab", 
 412 ###################################################################### 
 413 # Command-line interface 
 414 ###################################################################### 
 417     "Print usage message." 
 419     format 
= "Usage: %s <opts1> [<opts2>] <root> [<resources>]" 
 420     print format 
% basename(sys
.argv
[0]) 
 422     print "       with arguments:" 
 423     print "           (mandatory) root:         the package root folder" 
 424     print "           (optional)  resources:    the package resources folder" 
 426     print "       and options:" 
 427     print "           (mandatory) opts1:" 
 428     mandatoryKeys 
= string
.split("Title Version Description", " ") 
 429     for k 
in mandatoryKeys
: 
 431     print "           (optional) opts2: (with default values)" 
 433     pmDefaults 
= PackageMaker
.packageInfoDefaults
 
 434     optionalKeys 
= pmDefaults
.keys() 
 435     for k 
in mandatoryKeys
: 
 436         optionalKeys
.remove(k
) 
 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
])) 
 446     "Command-line interface." 
 449     keys 
= PackageMaker
.packageInfoDefaults
.keys() 
 450     longOpts 
= map(lambda k
: k
+"=", keys
) 
 453         opts
, args 
= getopt
.getopt(sys
.argv
[1:], shortOpts
, longOpts
) 
 454     except getopt
.GetoptError
, details
: 
 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!" 
 471         apply(buildPackage
, args
, optsDict
) 
 477     # buildpkg.py --Title=distutils \ 
 479     #             --Description="Python distutils package." \ 
 480     #             /Users/dinu/Desktop/distutils 
 483 if __name__ 
== "__main__":