|
1 #!/usr/bin/env python |
|
2 |
|
3 """buildpkg.py -- Build OS X packages for Apple's Installer.app. |
|
4 |
|
5 This is an experimental command-line tool for building packages to be |
|
6 installed with the Mac OS X Installer.app application. |
|
7 |
|
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 |
|
12 one: |
|
13 |
|
14 http://personalpages.tds.net/~brian_hill/packagemaker.html |
|
15 |
|
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: |
|
19 |
|
20 http://www.stepwise.com/Articles/Technical/Packages/InstallerWoes.html |
|
21 http://www.stepwise.com/Articles/Technical/Packages/InstallerOnX.html |
|
22 |
|
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- |
|
27 line. |
|
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 |
|
37 TODO: |
|
38 - test pre-process and post-process scripts (Python ones?) |
|
39 - handle multi-volume packages (?) |
|
40 - integrate into distutils (?) |
|
41 |
|
42 Dinu C. Gherman, |
|
43 gherman@europemail.com |
|
44 November 2001 |
|
45 |
|
46 !! USE AT YOUR OWN RISK !! |
|
47 """ |
|
48 |
|
49 __version__ = 0.2 |
|
50 __license__ = "FreeBSD" |
|
51 |
|
52 |
|
53 import os, sys, glob, fnmatch, shutil, string, copy, getopt |
|
54 from os.path import basename, dirname, join, islink, isdir, isfile |
|
55 |
|
56 Error = "buildpkg.Error" |
|
57 |
|
58 PKG_INFO_FIELDS = """\ |
|
59 Title |
|
60 Version |
|
61 Description |
|
62 DefaultLocation |
|
63 DeleteWarning |
|
64 NeedsAuthorization |
|
65 DisableStop |
|
66 UseUserMask |
|
67 Application |
|
68 Relocatable |
|
69 Required |
|
70 InstallOnly |
|
71 RequiresReboot |
|
72 RootVolumeOnly |
|
73 LongFilenames |
|
74 LibrarySubdirectory |
|
75 AllowBackRev |
|
76 OverwritePermissions |
|
77 InstallFat\ |
|
78 """ |
|
79 |
|
80 ###################################################################### |
|
81 # Helpers |
|
82 ###################################################################### |
|
83 |
|
84 # Convenience class, as suggested by /F. |
|
85 |
|
86 class 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 |
|
119 class 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 |
|
371 def 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 |
|
384 def 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 |
|
391 def 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 |
|
400 def 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 |
|
416 def 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 |
|
445 def 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 |
|
483 if __name__ == "__main__": |
|
484 main() |