]>
Commit | Line | Data |
---|---|---|
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 == 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 | ||
422 | print " with arguments:" | |
423 | print " (mandatory) root: the package root folder" | |
424 | print " (optional) resources: the package resources folder" | |
425 | ||
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() |