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__":