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 
= """\ 
  77 ###################################################################### 
  79 ###################################################################### 
  81 # Convenience class, as suggested by /F. 
  83 class GlobDirectoryWalker
: 
  84     "A forward iterator that traverses files in a directory tree." 
  86     def __init__(self
, directory
, pattern
="*"): 
  87         self
.stack 
= [directory
] 
  88         self
.pattern 
= pattern
 
  93     def __getitem__(self
, index
): 
  96                 file = self
.files
[self
.index
] 
  97                 self
.index 
= self
.index 
+ 1 
  99                 # pop next directory from stack 
 100                 self
.directory 
= self
.stack
.pop() 
 101                 self
.files 
= os
.listdir(self
.directory
) 
 105                 fullname 
= join(self
.directory
, file) 
 106                 if isdir(fullname
) and not islink(fullname
): 
 107                     self
.stack
.append(fullname
) 
 108                 if fnmatch
.fnmatch(file, self
.pattern
): 
 112 ###################################################################### 
 114 ###################################################################### 
 117     """A class to generate packages for Mac OS X. 
 119     This is intended to create OS X packages (with extension .pkg) 
 120     containing archives of arbitrary files that the Installer.app 
 121     will be able to handle. 
 123     As of now, PackageMaker instances need to be created with the 
 124     title, version and description of the package to be built. 
 125     The package is built after calling the instance method 
 126     build(root, **options). It has the same name as the constructor's 
 127     title argument plus a '.pkg' extension and is located in the same 
 128     parent folder that contains the root folder. 
 130     E.g. this will create a package folder /my/space/distutils.pkg/: 
 132       pm = PackageMaker("distutils", "1.0.2", "Python distutils.") 
 133       pm.build("/my/space/distutils") 
 136     packageInfoDefaults 
= { 
 140         'DefaultLocation': '/', 
 141         'Diskname': '(null)', 
 143         'NeedsAuthorization': 'NO', 
 145         'UseUserMask': 'YES', 
 147         'Relocatable': 'YES', 
 150         'RequiresReboot': 'NO', 
 151         'RootVolumeOnly' : 'NO', 
 155     def __init__(self
, title
, version
, desc
): 
 156         "Init. with mandatory title/version/description arguments." 
 158         info 
= {"Title": title, "Version": version, "Description": desc}
 
 159         self
.packageInfo 
= copy
.deepcopy(self
.packageInfoDefaults
) 
 160         self
.packageInfo
.update(info
) 
 162         # variables set later 
 163         self
.packageRootFolder 
= None 
 164         self
.packageResourceFolder 
= None 
 165         self
.sourceFolder 
= None 
 166         self
.resourceFolder 
= None 
 169     def build(self
, root
, resources
=None, **options
): 
 170         """Create a package for some given root folder. 
 172         With no 'resources' argument set it is assumed to be the same 
 173         as the root directory. Option items replace the default ones 
 177         # set folder attributes 
 178         self
.sourceFolder 
= root
 
 179         if resources 
== None: 
 180             self
.resourceFolder 
= root
 
 182             self
.resourceFolder 
= resources
 
 184         # replace default option settings with user ones if provided 
 185         fields 
= self
. packageInfoDefaults
.keys() 
 186         for k
, v 
in options
.items(): 
 188                 self
.packageInfo
[k
] = v
 
 189             elif not k 
in ["OutputDir"]: 
 190                 raise Error
, "Unknown package option: %s" % k
 
 192         # Check where we should leave the output. Default is current directory 
 193         outputdir 
= options
.get("OutputDir", os
.getcwd()) 
 194         packageName 
= self
.packageInfo
["Title"] 
 195         self
.PackageRootFolder 
= os
.path
.join(outputdir
, packageName 
+ ".pkg") 
 197         # do what needs to be done 
 206     def _makeFolders(self
): 
 207         "Create package folder structure." 
 209         # Not sure if the package name should contain the version or not... 
 210         # packageName = "%s-%s" % (self.packageInfo["Title"], 
 211         #                          self.packageInfo["Version"]) # ?? 
 213         contFolder 
= join(self
.PackageRootFolder
, "Contents") 
 214         self
.packageResourceFolder 
= join(contFolder
, "Resources") 
 215         os
.mkdir(self
.PackageRootFolder
) 
 217         os
.mkdir(self
.packageResourceFolder
) 
 220         "Write .info file containing installing options." 
 222         # Not sure if options in PKG_INFO_FIELDS are complete... 
 225         for f 
in string
.split(PKG_INFO_FIELDS
, "\n"): 
 226             info 
= info 
+ "%s %%(%s)s\n" % (f
, f
) 
 227         info 
= info 
% self
.packageInfo
 
 228         base 
= self
.packageInfo
["Title"] + ".info" 
 229         path 
= join(self
.packageResourceFolder
, base
) 
 235         "Write .bom file containing 'Bill of Materials'." 
 237         # Currently ignores if the 'mkbom' tool is not available. 
 240             base 
= self
.packageInfo
["Title"] + ".bom" 
 241             bomPath 
= join(self
.packageResourceFolder
, base
) 
 242             cmd 
= "mkbom %s %s" % (self
.sourceFolder
, bomPath
) 
 248     def _addArchive(self
): 
 249         "Write .pax.gz file, a compressed archive using pax/gzip." 
 251         # Currently ignores if the 'pax' tool is not available. 
 256         os
.chdir(self
.sourceFolder
) 
 257         base 
= basename(self
.packageInfo
["Title"]) + ".pax" 
 258         self
.archPath 
= join(self
.packageResourceFolder
, base
) 
 259         cmd 
= "pax -w -f %s %s" % (self
.archPath
, ".") 
 263         cmd 
= "gzip %s" % self
.archPath
 
 268     def _addResources(self
): 
 269         "Add Welcome/ReadMe/License files, .lproj folders and scripts." 
 271         # Currently we just copy everything that matches the allowed 
 272         # filenames. So, it's left to Installer.app to deal with the 
 273         # same file available in multiple formats... 
 275         if not self
.resourceFolder
: 
 278         # find candidate resource files (txt html rtf rtfd/ or lproj/) 
 280         for pat 
in string
.split("*.txt *.html *.rtf *.rtfd *.lproj", " "): 
 281             pattern 
= join(self
.resourceFolder
, pat
) 
 282             allFiles 
= allFiles 
+ glob
.glob(pattern
) 
 284         # find pre-process and post-process scripts 
 285         # naming convention: packageName.{pre,post}_{upgrade,install} 
 286         # Alternatively the filenames can be {pre,post}_{upgrade,install} 
 287         # in which case we prepend the package name 
 288         packageName 
= self
.packageInfo
["Title"] 
 289         for pat 
in ("*upgrade", "*install", "*flight"): 
 290             pattern 
= join(self
.resourceFolder
, packageName 
+ pat
) 
 291             pattern2 
= join(self
.resourceFolder
, pat
) 
 292             allFiles 
= allFiles 
+ glob
.glob(pattern
) 
 293             allFiles 
= allFiles 
+ glob
.glob(pattern2
) 
 295         # check name patterns 
 298             for s 
in ("Welcome", "License", "ReadMe"): 
 299                 if string
.find(basename(f
), s
) == 0: 
 301             if f
[-6:] == ".lproj": 
 303             elif basename(f
) in ["pre_upgrade", "pre_install", "post_upgrade", "post_install"]: 
 304                 files
.append((f
, packageName
+"."+basename(f
))) 
 305             elif basename(f
) in ["preflight", "postflight"]: 
 307             elif f
[-8:] == "_upgrade": 
 309             elif f
[-8:] == "_install": 
 313         for src
, dst 
in files
: 
 316             f 
= join(self
.resourceFolder
, src
) 
 318                 shutil
.copy(f
, os
.path
.join(self
.packageResourceFolder
, dst
)) 
 320                 # special case for .rtfd and .lproj folders... 
 321                 d 
= join(self
.packageResourceFolder
, dst
) 
 323                 files 
= GlobDirectoryWalker(f
) 
 329         "Write .sizes file with info about number and size of files." 
 331         # Not sure if this is correct, but 'installedSize' and 
 332         # 'zippedSize' are now in Bytes. Maybe blocks are needed? 
 333         # Well, Installer.app doesn't seem to care anyway, saying 
 334         # the installation needs 100+ MB... 
 340         files 
= GlobDirectoryWalker(self
.sourceFolder
) 
 342             numFiles 
= numFiles 
+ 1 
 343             installedSize 
= installedSize 
+ os
.lstat(f
)[6] 
 346             zippedSize 
= os
.stat(self
.archPath
+ ".gz")[6] 
 347         except OSError: # ignore error 
 349         base 
= self
.packageInfo
["Title"] + ".sizes" 
 350         f 
= open(join(self
.packageResourceFolder
, base
), "w") 
 351         format 
= "NumFiles %d\nInstalledSize %d\nCompressedSize %d\n" 
 352         f
.write(format 
% (numFiles
, installedSize
, zippedSize
)) 
 355 # Shortcut function interface 
 357 def buildPackage(*args
, **options
): 
 358     "A Shortcut function for building a package." 
 361     title
, version
, desc 
= o
["Title"], o
["Version"], o
["Description"] 
 362     pm 
= PackageMaker(title
, version
, desc
) 
 363     apply(pm
.build
, list(args
), options
) 
 366 ###################################################################### 
 368 ###################################################################### 
 371     "Vanilla test for the distutils distribution." 
 373     pm 
= PackageMaker("distutils2", "1.0.2", "Python distutils package.") 
 374     pm
.build("/Users/dinu/Desktop/distutils2") 
 378     "Test for the reportlab distribution with modified options." 
 380     pm 
= PackageMaker("reportlab", "1.10", 
 381                       "ReportLab's Open Source PDF toolkit.") 
 382     pm
.build(root
="/Users/dinu/Desktop/reportlab", 
 383              DefaultLocation
="/Applications/ReportLab", 
 387     "Shortcut test for the reportlab distribution with modified options." 
 390         "/Users/dinu/Desktop/reportlab", 
 393         Description
="ReportLab's Open Source PDF toolkit.", 
 394         DefaultLocation
="/Applications/ReportLab", 
 398 ###################################################################### 
 399 # Command-line interface 
 400 ###################################################################### 
 403     "Print usage message." 
 405     format 
= "Usage: %s <opts1> [<opts2>] <root> [<resources>]" 
 406     print format 
% basename(sys
.argv
[0]) 
 408     print "       with arguments:" 
 409     print "           (mandatory) root:         the package root folder" 
 410     print "           (optional)  resources:    the package resources folder" 
 412     print "       and options:" 
 413     print "           (mandatory) opts1:" 
 414     mandatoryKeys 
= string
.split("Title Version Description", " ") 
 415     for k 
in mandatoryKeys
: 
 417     print "           (optional) opts2: (with default values)" 
 419     pmDefaults 
= PackageMaker
.packageInfoDefaults
 
 420     optionalKeys 
= pmDefaults
.keys() 
 421     for k 
in mandatoryKeys
: 
 422         optionalKeys
.remove(k
) 
 424     maxKeyLen 
= max(map(len, optionalKeys
)) 
 425     for k 
in optionalKeys
: 
 426         format 
= "               --%%s:%s %%s" 
 427         format 
= format 
% (" " * (maxKeyLen
-len(k
))) 
 428         print format 
% (k
, repr(pmDefaults
[k
])) 
 432     "Command-line interface." 
 435     keys 
= PackageMaker
.packageInfoDefaults
.keys() 
 436     longOpts 
= map(lambda k
: k
+"=", keys
) 
 439         opts
, args 
= getopt
.getopt(sys
.argv
[1:], shortOpts
, longOpts
) 
 440     except getopt
.GetoptError
, details
: 
 450     if not (1 <= len(args
) <= 2): 
 451         print "No argument given!" 
 452     elif not ("Title" in ok 
and \
 
 453               "Version" in ok 
and \
 
 454               "Description" in ok
): 
 455         print "Missing mandatory option!" 
 457         apply(buildPackage
, args
, optsDict
) 
 463     # buildpkg.py --Title=distutils \ 
 465     #             --Description="Python distutils package." \ 
 466     #             /Users/dinu/Desktop/distutils 
 469 if __name__ 
== "__main__":