]>
Commit | Line | Data |
---|---|---|
1e4a197e RD |
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 | Diskname | |
64 | DeleteWarning | |
65 | NeedsAuthorization | |
66 | DisableStop | |
67 | UseUserMask | |
68 | Application | |
69 | Relocatable | |
70 | Required | |
71 | InstallOnly | |
72 | RequiresReboot | |
73 | RootVolumeOnly | |
74 | InstallFat\ | |
75 | """ | |
76 | ||
77 | ###################################################################### | |
78 | # Helpers | |
79 | ###################################################################### | |
80 | ||
81 | # Convenience class, as suggested by /F. | |
82 | ||
83 | class 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 | ||
116 | class 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', | |
151 | 'RootVolumeOnly' : 'NO', | |
152 | '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 | |
165 | self.sourceFolder = None | |
166 | 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 | |
178 | self.sourceFolder = root | |
179 | if resources == None: | |
180 | self.resourceFolder = root | |
181 | else: | |
182 | self.resourceFolder = resources | |
183 | ||
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 | |
189 | 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 | ||
197 | # 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 | ||
213 | contFolder = join(self.PackageRootFolder, "Contents") | |
214 | self.packageResourceFolder = join(contFolder, "Resources") | |
215 | os.mkdir(self.PackageRootFolder) | |
216 | os.mkdir(contFolder) | |
217 | os.mkdir(self.packageResourceFolder) | |
218 | ||
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 | |
228 | base = self.packageInfo["Title"] + ".info" | |
229 | path = join(self.packageResourceFolder, base) | |
230 | 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: | |
240 | base = self.packageInfo["Title"] + ".bom" | |
241 | bomPath = join(self.packageResourceFolder, base) | |
242 | cmd = "mkbom %s %s" % (self.sourceFolder, bomPath) | |
243 | 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 | ||
255 | # 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) | |
265 | 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 | ||
275 | if not self.resourceFolder: | |
276 | 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", " "): | |
281 | pattern = join(self.resourceFolder, pat) | |
282 | allFiles = allFiles + glob.glob(pattern) | |
283 | ||
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) | |
294 | ||
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: | |
300 | files.append((f, f)) | |
301 | if f[-6:] == ".lproj": | |
302 | files.append((f, f)) | |
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"]: | |
306 | files.append((f, f)) | |
307 | elif f[-8:] == "_upgrade": | |
308 | files.append((f,f)) | |
309 | elif f[-8:] == "_install": | |
310 | files.append((f,f)) | |
311 | ||
312 | # copy files | |
313 | for src, dst in files: | |
314 | src = basename(src) | |
315 | dst = basename(dst) | |
316 | f = join(self.resourceFolder, src) | |
317 | if isfile(f): | |
318 | shutil.copy(f, os.path.join(self.packageResourceFolder, dst)) | |
319 | elif isdir(f): | |
320 | # special case for .rtfd and .lproj folders... | |
321 | d = join(self.packageResourceFolder, dst) | |
322 | 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 | ||
340 | files = GlobDirectoryWalker(self.sourceFolder) | |
341 | for f in files: | |
342 | numFiles = numFiles + 1 | |
343 | installedSize = installedSize + os.lstat(f)[6] | |
344 | ||
345 | try: | |
346 | zippedSize = os.stat(self.archPath+ ".gz")[6] | |
347 | except OSError: # ignore error | |
348 | pass | |
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)) | |
353 | ||
354 | ||
355 | # Shortcut function interface | |
356 | ||
357 | def 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 | ||
370 | def 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 | ||
377 | def 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 | ||
386 | def 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 | ||
402 | def printUsage(): | |
403 | "Print usage message." | |
404 | ||
405 | format = "Usage: %s <opts1> [<opts2>] <root> [<resources>]" | |
406 | print format % basename(sys.argv[0]) | |
407 | ||
408 | print " with arguments:" | |
409 | print " (mandatory) root: the package root folder" | |
410 | print " (optional) resources: the package resources folder" | |
411 | ||
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 | ||
431 | def 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 | ||
469 | if __name__ == "__main__": | |
470 | main() |