• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/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
56Error = "buildpkg.Error"
57
58PKG_INFO_FIELDS = """\
59Title
60Version
61Description
62DefaultLocation
63DeleteWarning
64NeedsAuthorization
65DisableStop
66UseUserMask
67Application
68Relocatable
69Required
70InstallOnly
71RequiresReboot
72RootVolumeOnly
73LongFilenames
74LibrarySubdirectory
75AllowBackRev
76OverwritePermissions
77InstallFat\
78"""
79
80######################################################################
81# Helpers
82######################################################################
83
84# Convenience class, as suggested by /F.
85
86class 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
119class 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
371def 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
384def 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
391def 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
400def 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
416def 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
445def 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
483if __name__ == "__main__":
484    main()
485