blob: 280f8aec406aa978a53c931507f95b42d5235e93 [file] [log] [blame]
Jack Jansena6db44f2002-09-06 19:47:49 +00001#!/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,
43gherman@europemail.com
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
Jack Jansen997429a2002-09-06 21:55:13 +000056Error = "buildpkg.Error"
Jack Jansena6db44f2002-09-06 19:47:49 +000057
58PKG_INFO_FIELDS = """\
59Title
60Version
61Description
62DefaultLocation
63Diskname
64DeleteWarning
65NeedsAuthorization
66DisableStop
67UseUserMask
68Application
69Relocatable
70Required
71InstallOnly
72RequiresReboot
Just van Rossum3bd8d0f2003-02-01 10:07:28 +000073RootVolumeOnly
Jack Jansena6db44f2002-09-06 19:47:49 +000074InstallFat\
75"""
76
77######################################################################
78# Helpers
79######################################################################
80
81# Convenience class, as suggested by /F.
82
83class GlobDirectoryWalker:
84 "A forward iterator that traverses files in a directory tree."
85
86 def __init__(self, directory, pattern="*"):
87 self.stack = [directory]
88 self.pattern = pattern
89 self.files = []
90 self.index = 0
91
92
93 def __getitem__(self, index):
94 while 1:
95 try:
96 file = self.files[self.index]
97 self.index = self.index + 1
98 except IndexError:
99 # pop next directory from stack
100 self.directory = self.stack.pop()
101 self.files = os.listdir(self.directory)
102 self.index = 0
103 else:
104 # got a filename
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):
109 return fullname
110
111
112######################################################################
113# The real thing
114######################################################################
115
116class PackageMaker:
117 """A class to generate packages for Mac OS X.
118
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.
122
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.
129
130 E.g. this will create a package folder /my/space/distutils.pkg/:
131
132 pm = PackageMaker("distutils", "1.0.2", "Python distutils.")
133 pm.build("/my/space/distutils")
134 """
135
136 packageInfoDefaults = {
137 'Title': None,
138 'Version': None,
139 'Description': '',
140 'DefaultLocation': '/',
141 'Diskname': '(null)',
142 'DeleteWarning': '',
143 'NeedsAuthorization': 'NO',
144 'DisableStop': 'NO',
145 'UseUserMask': 'YES',
146 'Application': 'NO',
147 'Relocatable': 'YES',
148 'Required': 'NO',
149 'InstallOnly': 'NO',
150 'RequiresReboot': 'NO',
Just van Rossum3bd8d0f2003-02-01 10:07:28 +0000151 'RootVolumeOnly' : 'NO',
Jack Jansena6db44f2002-09-06 19:47:49 +0000152 'InstallFat': 'NO'}
153
154
155 def __init__(self, title, version, desc):
156 "Init. with mandatory title/version/description arguments."
157
158 info = {"Title": title, "Version": version, "Description": desc}
159 self.packageInfo = copy.deepcopy(self.packageInfoDefaults)
160 self.packageInfo.update(info)
161
162 # variables set later
163 self.packageRootFolder = None
164 self.packageResourceFolder = None
Jack Jansen997429a2002-09-06 21:55:13 +0000165 self.sourceFolder = None
Jack Jansena6db44f2002-09-06 19:47:49 +0000166 self.resourceFolder = None
167
168
169 def build(self, root, resources=None, **options):
170 """Create a package for some given root folder.
171
172 With no 'resources' argument set it is assumed to be the same
173 as the root directory. Option items replace the default ones
174 in the package info.
175 """
176
177 # set folder attributes
Jack Jansen997429a2002-09-06 21:55:13 +0000178 self.sourceFolder = root
Jack Jansena6db44f2002-09-06 19:47:49 +0000179 if resources == None:
Jack Jansen997429a2002-09-06 21:55:13 +0000180 self.resourceFolder = root
181 else:
182 self.resourceFolder = resources
Jack Jansena6db44f2002-09-06 19:47:49 +0000183
184 # replace default option settings with user ones if provided
185 fields = self. packageInfoDefaults.keys()
186 for k, v in options.items():
187 if k in fields:
188 self.packageInfo[k] = v
Jack Jansen997429a2002-09-06 21:55:13 +0000189 elif not k in ["OutputDir"]:
190 raise Error, "Unknown package option: %s" % k
191
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")
196
Jack Jansena6db44f2002-09-06 19:47:49 +0000197 # do what needs to be done
198 self._makeFolders()
199 self._addInfo()
200 self._addBom()
201 self._addArchive()
202 self._addResources()
203 self._addSizes()
204
205
206 def _makeFolders(self):
207 "Create package folder structure."
208
209 # Not sure if the package name should contain the version or not...
210 # packageName = "%s-%s" % (self.packageInfo["Title"],
211 # self.packageInfo["Version"]) # ??
212
Jack Jansen997429a2002-09-06 21:55:13 +0000213 contFolder = join(self.PackageRootFolder, "Contents")
214 self.packageResourceFolder = join(contFolder, "Resources")
215 os.mkdir(self.PackageRootFolder)
Jack Jansena6db44f2002-09-06 19:47:49 +0000216 os.mkdir(contFolder)
Jack Jansen997429a2002-09-06 21:55:13 +0000217 os.mkdir(self.packageResourceFolder)
Jack Jansena6db44f2002-09-06 19:47:49 +0000218
219 def _addInfo(self):
220 "Write .info file containing installing options."
221
222 # Not sure if options in PKG_INFO_FIELDS are complete...
223
224 info = ""
225 for f in string.split(PKG_INFO_FIELDS, "\n"):
226 info = info + "%s %%(%s)s\n" % (f, f)
227 info = info % self.packageInfo
Jack Jansen997429a2002-09-06 21:55:13 +0000228 base = self.packageInfo["Title"] + ".info"
229 path = join(self.packageResourceFolder, base)
Jack Jansena6db44f2002-09-06 19:47:49 +0000230 f = open(path, "w")
231 f.write(info)
232
233
234 def _addBom(self):
235 "Write .bom file containing 'Bill of Materials'."
236
237 # Currently ignores if the 'mkbom' tool is not available.
238
239 try:
Jack Jansen997429a2002-09-06 21:55:13 +0000240 base = self.packageInfo["Title"] + ".bom"
241 bomPath = join(self.packageResourceFolder, base)
242 cmd = "mkbom %s %s" % (self.sourceFolder, bomPath)
Jack Jansena6db44f2002-09-06 19:47:49 +0000243 res = os.system(cmd)
244 except:
245 pass
246
247
248 def _addArchive(self):
249 "Write .pax.gz file, a compressed archive using pax/gzip."
250
251 # Currently ignores if the 'pax' tool is not available.
252
253 cwd = os.getcwd()
254
Jack Jansen997429a2002-09-06 21:55:13 +0000255 # create archive
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, ".")
260 res = os.system(cmd)
261
262 # compress archive
263 cmd = "gzip %s" % self.archPath
264 res = os.system(cmd)
Jack Jansena6db44f2002-09-06 19:47:49 +0000265 os.chdir(cwd)
266
267
268 def _addResources(self):
269 "Add Welcome/ReadMe/License files, .lproj folders and scripts."
270
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...
274
Jack Jansen997429a2002-09-06 21:55:13 +0000275 if not self.resourceFolder:
Jack Jansena6db44f2002-09-06 19:47:49 +0000276 return
277
278 # find candidate resource files (txt html rtf rtfd/ or lproj/)
279 allFiles = []
280 for pat in string.split("*.txt *.html *.rtf *.rtfd *.lproj", " "):
Jack Jansen997429a2002-09-06 21:55:13 +0000281 pattern = join(self.resourceFolder, pat)
Jack Jansena6db44f2002-09-06 19:47:49 +0000282 allFiles = allFiles + glob.glob(pattern)
283
284 # find pre-process and post-process scripts
Just van Rossum3bd8d0f2003-02-01 10:07:28 +0000285 # naming convention: packageName.{pre,post}_{upgrade,install}
286 # Alternatively the filenames can be {pre,post}_{upgrade,install}
Jack Jansen997429a2002-09-06 21:55:13 +0000287 # in which case we prepend the package name
Jack Jansena6db44f2002-09-06 19:47:49 +0000288 packageName = self.packageInfo["Title"]
Just van Rossum3bd8d0f2003-02-01 10:07:28 +0000289 for pat in ("*upgrade", "*install", "*flight"):
Jack Jansen997429a2002-09-06 21:55:13 +0000290 pattern = join(self.resourceFolder, packageName + pat)
Just van Rossum3bd8d0f2003-02-01 10:07:28 +0000291 pattern2 = join(self.resourceFolder, pat)
Jack Jansena6db44f2002-09-06 19:47:49 +0000292 allFiles = allFiles + glob.glob(pattern)
Just van Rossum3bd8d0f2003-02-01 10:07:28 +0000293 allFiles = allFiles + glob.glob(pattern2)
Jack Jansena6db44f2002-09-06 19:47:49 +0000294
295 # check name patterns
296 files = []
297 for f in allFiles:
298 for s in ("Welcome", "License", "ReadMe"):
299 if string.find(basename(f), s) == 0:
Jack Jansen997429a2002-09-06 21:55:13 +0000300 files.append((f, f))
Jack Jansena6db44f2002-09-06 19:47:49 +0000301 if f[-6:] == ".lproj":
Jack Jansen997429a2002-09-06 21:55:13 +0000302 files.append((f, f))
Just van Rossum3bd8d0f2003-02-01 10:07:28 +0000303 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"]:
306 files.append((f, f))
307 elif f[-8:] == "_upgrade":
Jack Jansen997429a2002-09-06 21:55:13 +0000308 files.append((f,f))
Just van Rossum3bd8d0f2003-02-01 10:07:28 +0000309 elif f[-8:] == "_install":
Jack Jansen997429a2002-09-06 21:55:13 +0000310 files.append((f,f))
Jack Jansena6db44f2002-09-06 19:47:49 +0000311
312 # copy files
Jack Jansen997429a2002-09-06 21:55:13 +0000313 for src, dst in files:
Just van Rossum3bd8d0f2003-02-01 10:07:28 +0000314 src = basename(src)
315 dst = basename(dst)
Jack Jansen997429a2002-09-06 21:55:13 +0000316 f = join(self.resourceFolder, src)
Jack Jansena6db44f2002-09-06 19:47:49 +0000317 if isfile(f):
Jack Jansen997429a2002-09-06 21:55:13 +0000318 shutil.copy(f, os.path.join(self.packageResourceFolder, dst))
Jack Jansena6db44f2002-09-06 19:47:49 +0000319 elif isdir(f):
320 # special case for .rtfd and .lproj folders...
Jack Jansen997429a2002-09-06 21:55:13 +0000321 d = join(self.packageResourceFolder, dst)
Jack Jansena6db44f2002-09-06 19:47:49 +0000322 os.mkdir(d)
323 files = GlobDirectoryWalker(f)
324 for file in files:
325 shutil.copy(file, d)
326
327
328 def _addSizes(self):
329 "Write .sizes file with info about number and size of files."
330
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...
335
336 numFiles = 0
337 installedSize = 0
338 zippedSize = 0
339
Jack Jansen997429a2002-09-06 21:55:13 +0000340 files = GlobDirectoryWalker(self.sourceFolder)
Jack Jansena6db44f2002-09-06 19:47:49 +0000341 for f in files:
342 numFiles = numFiles + 1
Jack Jansen997429a2002-09-06 21:55:13 +0000343 installedSize = installedSize + os.lstat(f)[6]
Jack Jansena6db44f2002-09-06 19:47:49 +0000344
Jack Jansena6db44f2002-09-06 19:47:49 +0000345 try:
Jack Jansen997429a2002-09-06 21:55:13 +0000346 zippedSize = os.stat(self.archPath+ ".gz")[6]
Jack Jansena6db44f2002-09-06 19:47:49 +0000347 except OSError: # ignore error
348 pass
Jack Jansen997429a2002-09-06 21:55:13 +0000349 base = self.packageInfo["Title"] + ".sizes"
350 f = open(join(self.packageResourceFolder, base), "w")
351 format = "NumFiles %d\nInstalledSize %d\nCompressedSize %d\n"
Jack Jansena6db44f2002-09-06 19:47:49 +0000352 f.write(format % (numFiles, installedSize, zippedSize))
353
354
355# Shortcut function interface
356
357def buildPackage(*args, **options):
358 "A Shortcut function for building a package."
359
360 o = options
361 title, version, desc = o["Title"], o["Version"], o["Description"]
362 pm = PackageMaker(title, version, desc)
363 apply(pm.build, list(args), options)
364
365
366######################################################################
367# Tests
368######################################################################
369
370def test0():
371 "Vanilla test for the distutils distribution."
372
373 pm = PackageMaker("distutils2", "1.0.2", "Python distutils package.")
374 pm.build("/Users/dinu/Desktop/distutils2")
375
376
377def test1():
378 "Test for the reportlab distribution with modified options."
379
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",
384 Relocatable="YES")
385
386def test2():
387 "Shortcut test for the reportlab distribution with modified options."
388
389 buildPackage(
390 "/Users/dinu/Desktop/reportlab",
391 Title="reportlab",
392 Version="1.10",
393 Description="ReportLab's Open Source PDF toolkit.",
394 DefaultLocation="/Applications/ReportLab",
395 Relocatable="YES")
396
397
398######################################################################
399# Command-line interface
400######################################################################
401
402def printUsage():
403 "Print usage message."
404
405 format = "Usage: %s <opts1> [<opts2>] <root> [<resources>]"
406 print format % basename(sys.argv[0])
407 print
408 print " with arguments:"
409 print " (mandatory) root: the package root folder"
410 print " (optional) resources: the package resources folder"
411 print
412 print " and options:"
413 print " (mandatory) opts1:"
414 mandatoryKeys = string.split("Title Version Description", " ")
415 for k in mandatoryKeys:
416 print " --%s" % k
417 print " (optional) opts2: (with default values)"
418
419 pmDefaults = PackageMaker.packageInfoDefaults
420 optionalKeys = pmDefaults.keys()
421 for k in mandatoryKeys:
422 optionalKeys.remove(k)
423 optionalKeys.sort()
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]))
429
430
431def main():
432 "Command-line interface."
433
434 shortOpts = ""
435 keys = PackageMaker.packageInfoDefaults.keys()
436 longOpts = map(lambda k: k+"=", keys)
437
438 try:
439 opts, args = getopt.getopt(sys.argv[1:], shortOpts, longOpts)
440 except getopt.GetoptError, details:
441 print details
442 printUsage()
443 return
444
445 optsDict = {}
446 for k, v in opts:
447 optsDict[k[2:]] = v
448
449 ok = optsDict.keys()
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!"
456 else:
457 apply(buildPackage, args, optsDict)
458 return
459
460 printUsage()
461
462 # sample use:
463 # buildpkg.py --Title=distutils \
464 # --Version=1.0.2 \
465 # --Description="Python distutils package." \
466 # /Users/dinu/Desktop/distutils
467
468
469if __name__ == "__main__":
470 main()