Make dry_run a global and add a few comments.
[cdist.git] / cdist
1 #!/usr/bin/python
2
3 #    Copyright (C) 2012-2017 Carl Hetherington <cth@carlh.net>
4 #
5 #    This program is free software; you can redistribute it and/or modify
6 #    it under the terms of the GNU General Public License as published by
7 #    the Free Software Foundation; either version 2 of the License, or
8 #    (at your option) any later version.
9 #
10 #    This program is distributed in the hope that it will be useful,
11 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
12 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 #    GNU General Public License for more details.
14
15 #    You should have received a copy of the GNU General Public License
16 #    along with this program; if not, write to the Free Software
17 #    Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
18
19 from __future__ import print_function
20 import os
21 import sys
22 import shutil
23 import glob
24 import tempfile
25 import argparse
26 import datetime
27 import subprocess
28 import re
29 import copy
30 import inspect
31
32 TEMPORARY_DIRECTORY = '/var/tmp'
33
34 class Error(Exception):
35     def __init__(self, value):
36         self.value = value
37     def __str__(self):
38         return self.value
39     def __repr__(self):
40         return str(self)
41
42 class Trees:
43     """
44     Store for Tree objects which re-uses already-created objects
45     and checks for requests for different versions of the same thing.
46     """
47
48     def __init__(self):
49         self.trees = []
50
51     def get(self, name, specifier, target):
52         for t in self.trees:
53             if t.name == name and t.specifier == specifier and t.target == target:
54                 return t
55             elif t.name == name and t.specifier != specifier:
56                 raise Error('conflicting versions of %s requested (%s and %s)' % (name, specifier, t.specifier))
57
58         nt = Tree(name, specifier, target)
59         self.trees.append(nt)
60         return nt
61
62 class Globals:
63     quiet = False
64     command = None
65     dry_run = False
66     trees = Trees()
67
68 globals = Globals()
69
70
71 #
72 # Configuration
73 #
74
75 class Option(object):
76     def __init__(self, key, default=None):
77         self.key = key
78         self.value = default
79
80     def offer(self, key, value):
81         if key == self.key:
82             self.value = value
83
84 class BoolOption(object):
85     def __init__(self, key):
86         self.key = key
87         self.value = False
88
89     def offer(self, key, value):
90         if key == self.key:
91             self.value = (value == 'yes' or value == '1' or value == 'true')
92
93 class Config:
94     def __init__(self):
95         self.options = [ Option('linux_chroot_prefix'),
96                          Option('windows_environment_prefix'),
97                          Option('mingw_prefix'),
98                          Option('git_prefix'),
99                          Option('osx_build_host'),
100                          Option('osx_environment_prefix'),
101                          Option('osx_sdk_prefix'),
102                          Option('osx_sdk'),
103                          Option('parallel', 4) ]
104
105         try:
106             f = open('%s/.config/cdist' % os.path.expanduser('~'), 'r')
107             while True:
108                 l = f.readline()
109                 if l == '':
110                     break
111
112                 if len(l) > 0 and l[0] == '#':
113                     continue
114
115                 s = l.strip().split()
116                 if len(s) == 2:
117                     for k in self.options:
118                         k.offer(s[0], s[1])
119         except:
120             raise
121
122     def get(self, k):
123         for o in self.options:
124             if o.key == k:
125                 if o.value is None:
126                     raise Error('Required setting %s not found' % k)
127                 return o.value
128
129     def set(self, k, v):
130         for o in self.options:
131             o.offer(k, v)
132
133 config = Config()
134
135 #
136 # Utility bits
137 #
138
139 def log(m):
140     if not globals.quiet:
141         print('\x1b[33m* %s\x1b[0m' % m)
142
143 def scp_escape(n):
144     """Escape a host:filename string for use with an scp command"""
145     s = n.split(':')
146     assert(len(s) == 1 or len(s) == 2)
147     if len(s) == 2:
148         return '%s:"\'%s\'"' % (s[0], s[1])
149     else:
150         return '\"%s\"' % s[0]
151
152 def copytree(a, b):
153     log('copy %s -> %s' % (scp_escape(b), scp_escape(b)))
154     command('scp -r %s %s' % (scp_escape(a), scp_escape(b)))
155
156 def copyfile(a, b):
157     log('copy %s -> %s' % (scp_escape(a), scp_escape(b)))
158     command('scp %s %s' % (scp_escape(a), scp_escape(b)))
159
160 def makedirs(d):
161     """
162     Make directories either locally or on a remote host; remotely if
163     d includes a colon, otherwise locally.
164     """
165     if d.find(':') == -1:
166         os.makedirs(d)
167     else:
168         s = d.split(':')
169         command('ssh %s -- mkdir -p %s' % (s[0], s[1]))
170
171 def rmdir(a):
172     log('remove %s' % a)
173     os.rmdir(a)
174
175 def rmtree(a):
176     log('remove %s' % a)
177     shutil.rmtree(a, ignore_errors=True)
178
179 def command(c):
180     log(c)
181     r = os.system(c)
182     if (r >> 8):
183         raise Error('command %s failed' % c)
184
185 def command_and_read(c):
186     log(c)
187     p = subprocess.Popen(c.split(), stdout=subprocess.PIPE)
188     f = os.fdopen(os.dup(p.stdout.fileno()))
189     return f
190
191 def read_wscript_variable(directory, variable):
192     f = open('%s/wscript' % directory, 'r')
193     while True:
194         l = f.readline()
195         if l == '':
196             break
197
198         s = l.split()
199         if len(s) == 3 and s[0] == variable:
200             f.close()
201             return s[2][1:-1]
202
203     f.close()
204     return None
205
206 def set_version_in_wscript(version):
207     f = open('wscript', 'rw')
208     o = open('wscript.tmp', 'w')
209     while True:
210         l = f.readline()
211         if l == '':
212             break
213
214         s = l.split()
215         if len(s) == 3 and s[0] == "VERSION":
216             print("Writing %s" % version)
217             print("VERSION = '%s'" % version, file=o)
218         else:
219             print(l, file=o, end="")
220     f.close()
221     o.close()
222
223     os.rename('wscript.tmp', 'wscript')
224
225 def append_version_to_changelog(version):
226     try:
227         f = open('ChangeLog', 'r')
228     except:
229         log('Could not open ChangeLog')
230         return
231
232     c = f.read()
233     f.close()
234
235     f = open('ChangeLog', 'w')
236     now = datetime.datetime.now()
237     f.write('%d-%02d-%02d  Carl Hetherington  <cth@carlh.net>\n\n\t* Version %s released.\n\n' % (now.year, now.month, now.day, version))
238     f.write(c)
239
240 def append_version_to_debian_changelog(version):
241     if not os.path.exists('debian'):
242         log('Could not find debian directory')
243         return
244
245     command('dch -b -v %s-1 "New upstream release."' % version)
246
247 def devel_to_git(git_commit, filename):
248     if git_commit is not None:
249         filename = filename.replace('devel', '-%s' % git_commit)
250     return filename
251
252 class TreeDirectory:
253     def __init__(self, tree):
254         self.tree = tree
255     def __enter__(self):
256         self.cwd = os.getcwd()
257         os.chdir('%s/src/%s' % (self.tree.target.directory, self.tree.name))
258     def __exit__(self, type, value, traceback):
259         os.chdir(self.cwd)
260
261 #
262 # Version
263 #
264
265 class Version:
266     def __init__(self, s):
267         self.devel = False
268
269         if s.startswith("'"):
270             s = s[1:]
271         if s.endswith("'"):
272             s = s[0:-1]
273
274         if s.endswith('devel'):
275             s = s[0:-5]
276             self.devel = True
277
278         if s.endswith('pre'):
279             s = s[0:-3]
280
281         p = s.split('.')
282         self.major = int(p[0])
283         self.minor = int(p[1])
284         if len(p) == 3:
285             self.micro = int(p[2])
286         else:
287             self.micro = 0
288
289     def bump_minor(self):
290         self.minor += 1
291         self.micro = 0
292
293     def bump_micro(self):
294         self.micro += 1
295
296     def to_devel(self):
297         self.devel = True
298
299     def to_release(self):
300         self.devel = False
301
302     def __str__(self):
303         s = '%d.%d.%d' % (self.major, self.minor, self.micro)
304         if self.devel:
305             s += 'devel'
306
307         return s
308
309 #
310 # Targets
311 #
312
313 class Target(object):
314     """
315     Class representing the target that we are building for.  This is exposed to cscripts,
316     though not all of it is guaranteed 'API'.  cscripts may expect:
317
318     platform: platform string (e.g. 'windows', 'linux', 'osx')
319     parallel: number of parallel jobs to run
320     directory: directory to work in
321     variables: dict of environment variables
322     debug: True to build a debug version, otherwise False
323     set(a, b): set the value of variable 'a' to 'b'
324     unset(a): unset the value of variable 'a'
325     command(c): run the command 'c' in the build environment
326
327     """
328
329     def __init__(self, platform, directory=None):
330         """
331         platform -- platform string (e.g. 'windows', 'linux', 'osx')
332         directory -- directory to work in; if None we will use a temporary directory
333         Temporary directories will be removed after use; specified directories will not.
334         """
335         self.platform = platform
336         self.parallel = int(config.get('parallel'))
337
338         # self.directory is the working directory
339         if directory is None:
340             self.directory = tempfile.mkdtemp('', 'tmp', TEMPORARY_DIRECTORY)
341             self.rmdir = True
342         else:
343             self.directory = directory
344             self.rmdir = False
345
346         # Environment variables that we will use when we call cscripts
347         self.variables = {}
348         self.debug = False
349
350     def package(self, project, checkout):
351         tree = globals.trees.get(project, checkout, self)
352         tree.build_dependencies()
353         tree.build()
354         return tree.call('package', tree.version), tree.git_commit
355
356     def test(self, tree, test):
357         """test is the test case to run, or None"""
358         tree.build_dependencies()
359         tree.build()
360         return tree.call('test', test)
361
362     def set(self, a, b):
363         self.variables[a] = b
364
365     def unset(self, a):
366         del(self.variables[a])
367
368     def get(self, a):
369         return self.variables[a]
370
371     def append_with_space(self, k, v):
372         if (not k in self.variables) or len(self.variables[k]) == 0:
373             self.variables[k] = '"%s"' % v
374         else:
375             e = self.variables[k]
376             if e[0] == '"' and e[-1] == '"':
377                 self.variables[k] = '"%s %s"' % (e[1:-1], v)
378             else:
379                 self.variables[k] = '"%s %s"' % (e, v)
380
381     def variables_string(self, escaped_quotes=False):
382         e = ''
383         for k, v in self.variables.items():
384             if escaped_quotes:
385                 v = v.replace('"', '\\"')
386             e += '%s=%s ' % (k, v)
387         return e
388
389     def cleanup(self):
390         if self.rmdir:
391             rmtree(self.directory)
392
393
394 class WindowsTarget(Target):
395     """
396     This target exposes the following additional API:
397
398     version: Windows version ('xp' or None)
399     bits: bitness of Windows (32 or 64)
400     environment_prefix: path to Windows environment
401     mingw_path: path to mingw binaries
402     """
403     def __init__(self, version, bits, directory=None):
404         super(WindowsTarget, self).__init__('windows', directory)
405         self.version = version
406         self.bits = bits
407
408         self.environment_prefix = '%s/%d' % (config.get('windows_environment_prefix'), self.bits)
409         if not os.path.exists(self.environment_prefix):
410             raise Error('environment prefix %s does not exist' % self.environment_prefix)
411
412         if self.bits == 32:
413             self.mingw_name = 'i686'
414         else:
415             self.mingw_name = 'x86_64'
416
417         self.mingw_path = '%s/%d/bin' % (config.get('mingw_prefix'), self.bits)
418         self.mingw_prefixes = ['/%s/%d' % (config.get('mingw_prefix'), self.bits), '%s/%d/%s-w64-mingw32' % (config.get('mingw_prefix'), bits, self.mingw_name)]
419
420         self.set('PKG_CONFIG_LIBDIR', '%s/lib/pkgconfig' % self.environment_prefix)
421         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/bin/pkgconfig' % (self.directory, self.directory))
422         self.set('PATH', '%s/bin:%s:%s' % (self.environment_prefix, self.mingw_path, os.environ['PATH']))
423         self.set('CC', '%s-w64-mingw32-gcc' % self.mingw_name)
424         self.set('CXX', '%s-w64-mingw32-g++' % self.mingw_name)
425         self.set('LD', '%s-w64-mingw32-ld' % self.mingw_name)
426         self.set('RANLIB', '%s-w64-mingw32-ranlib' % self.mingw_name)
427         self.set('WINRC', '%s-w64-mingw32-windres' % self.mingw_name)
428         cxx = '-I%s/include -I%s/include' % (self.environment_prefix, self.directory)
429         link = '-L%s/lib -L%s/lib' % (self.environment_prefix, self.directory)
430         for p in self.mingw_prefixes:
431             cxx += ' -I%s/include' % p
432             link += ' -L%s/lib' % p
433         self.set('CXXFLAGS', '"%s"' % cxx)
434         self.set('CPPFLAGS', '')
435         self.set('LINKFLAGS', '"%s"' % link)
436         self.set('LDFLAGS', '"%s"' % link)
437
438         # This is for backwards-compatibility
439         self.windows_prefix = self.environment_prefix
440
441     def command(self, c):
442         log('host -> %s' % c)
443         command('%s %s' % (self.variables_string(), c))
444
445 class LinuxTarget(Target):
446     """
447     Parent for Linux targets.
448     This target exposes the following additional API:
449
450     distro: distribution ('debian', 'ubuntu', 'centos' or 'fedora')
451     version: distribution version (e.g. '12.04', '8', '6.5')
452     bits: bitness of the distribution (32 or 64)
453     """
454
455     def __init__(self, distro, version, bits, directory=None):
456         super(LinuxTarget, self).__init__('linux', directory)
457         self.distro = distro
458         self.version = version
459         self.bits = bits
460
461         self.set('CXXFLAGS', '-I%s/include' % self.directory)
462         self.set('CPPFLAGS', '')
463         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
464         self.set('PKG_CONFIG_PATH',
465                  '%s/lib/pkgconfig:%s/lib64/pkgconfig:/usr/local/lib64/pkgconfig:/usr/local/lib/pkgconfig' % (self.directory, self.directory))
466         self.set('PATH', '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin')
467
468 class ChrootTarget(LinuxTarget):
469     """Build in a chroot"""
470     def __init__(self, distro, version, bits, directory=None):
471         super(ChrootTarget, self).__init__(distro, version, bits, directory)
472         # e.g. ubuntu-14.04-64
473         if self.version is not None and self.bits is not None:
474             self.chroot = '%s-%s-%d' % (self.distro, self.version, self.bits)
475         else:
476             self.chroot = self.distro
477         # e.g. /home/carl/Environments/ubuntu-14.04-64
478         self.chroot_prefix = '%s/%s' % (config.get('linux_chroot_prefix'), self.chroot)
479
480     def command(self, c):
481         command('%s schroot -c %s -p -- %s' % (self.variables_string(), self.chroot, c))
482
483
484 class HostTarget(LinuxTarget):
485     """Build directly on the host"""
486     def __init__(self, distro, version, bits, directory=None):
487         super(HostTarget, self).__init__(distro, version, bits, directory)
488
489     def command(self, c):
490         command('%s %s' % (self.variables_string(), c))
491
492
493 class DockerTarget(Target):
494     """
495     Build a Docker image.
496
497     This target exposes the following additional API:
498
499     deb: path to Debian 8 .deb
500     """
501     def __init__(self, directory=None):
502         super(DockerTarget, self).__init__('docker', directory)
503         self.debian = ChrootTarget('debian', '8', 64, directory)
504
505     def command(self, c):
506         log('host -> %s' % c)
507         command('%s %s' % (self.variables_string(), c))
508
509     def package(self, project, checkout):
510         self.deb = self.debian.package(project, checkout)
511         return globals.trees.get(project, checkout, self).call('package', tree.version), tree.git_commit
512
513
514 class OSXTarget(Target):
515     def __init__(self, directory=None):
516         super(OSXTarget, self).__init__('osx', directory)
517         self.sdk = config.get('osx_sdk')
518         self.sdk_prefix = config.get('osx_sdk_prefix')
519         self.environment_prefix = config.get('osx_environment_prefix')
520
521     def command(self, c):
522         command('%s %s' % (self.variables_string(False), c))
523
524
525 class OSXSingleTarget(OSXTarget):
526     def __init__(self, bits, directory=None):
527         super(OSXSingleTarget, self).__init__(directory)
528         self.bits = bits
529
530         if bits == 32:
531             arch = 'i386'
532         else:
533             arch = 'x86_64'
534
535         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (self.sdk_prefix, self.sdk, arch)
536         enviro = '%s/%d' % (config.get('osx_environment_prefix'), bits)
537
538         # Environment variables
539         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
540         self.set('CPPFLAGS', '')
541         self.set('CXXFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
542         self.set('LDFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
543         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
544         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, enviro))
545         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % enviro)
546         self.set('MACOSX_DEPLOYMENT_TARGET', config.get('osx_sdk'))
547
548     def package(self, project, checkout):
549         raise Error('cannot package non-universal OS X versions')
550
551
552 class OSXUniversalTarget(OSXTarget):
553     def __init__(self, directory=None):
554         super(OSXUniversalTarget, self).__init__(directory)
555
556     def package(self, project, checkout):
557
558         for b in [32, 64]:
559             target = OSXSingleTarget(b, os.path.join(self.directory, '%d' % b))
560             tree = globals.trees.get(project, checkout, target)
561             tree.build_dependencies()
562             tree.build()
563
564         tree = globals.trees.get(project, checkout, self)
565         with TreeDirectory(tree):
566             return tree.call('package', tree.version), tree.git_commit
567
568 class SourceTarget(Target):
569     """Build a source .tar.bz2"""
570     def __init__(self):
571         super(SourceTarget, self).__init__('source')
572
573     def command(self, c):
574         log('host -> %s' % c)
575         command('%s %s' % (self.variables_string(), c))
576
577     def cleanup(self):
578         rmtree(self.directory)
579
580     def package(self, project, checkout):
581         tree = globals.trees.get(project, checkout, self)
582         with TreeDirectory(tree):
583             name = read_wscript_variable(os.getcwd(), 'APPNAME')
584             command('./waf dist')
585             return os.path.abspath('%s-%s.tar.bz2' % (name, tree.version)), tree.git_commit
586
587
588 # @param s Target string:
589 #       windows-{32,64}
590 #    or ubuntu-version-{32,64}
591 #    or debian-version-{32,64}
592 #    or centos-version-{32,64}
593 #    or osx-{32,64}
594 #    or source
595 # @param debug True to build with debugging symbols (where possible)
596 def target_factory(s, debug, work):
597     target = None
598     if s.startswith('windows-'):
599         x = s.split('-')
600         if len(x) == 2:
601             target = WindowsTarget(None, int(x[1]), work)
602         elif len(x) == 3:
603             target = WindowsTarget(x[1], int(x[2]), work)
604         else:
605             raise Error("Bad Windows target name `%s'")
606     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-'):
607         p = s.split('-')
608         if len(p) != 3:
609             raise Error("Bad Linux target name `%s'; must be something like ubuntu-12.04-32 (i.e. distro-version-bits)" % s)
610         target = ChrootTarget(p[0], p[1], int(p[2]), work)
611     elif s.startswith('arch-'):
612         p = s.split('-')
613         if len(p) != 2:
614             raise Error("Bad Arch target name `%s'; must be arch-32 or arch-64")
615         target = ChrootTarget(p[0], None, p[1], work)
616     elif s == 'raspbian':
617         target = ChrootTarget(s, None, None, work)
618     elif s == 'host':
619         if command_and_read('uname -m').read().strip() == 'x86_64':
620             bits = 64
621         else:
622             bits = 32
623         try:
624             f = open('/etc/fedora-release', 'r')
625             l = f.readline().strip().split()
626             target = HostTarget("fedora", l[2], bits, work)
627         except Exception as e:
628             if os.path.exists('/etc/arch-release'):
629                 target = HostTarget("arch", None, bits, work)
630             else:
631                 raise Error("could not identify distribution for `host' target (%s)" % e)
632     elif s.startswith('osx-'):
633         target = OSXSingleTarget(int(s.split('-')[1]), work)
634     elif s == 'osx':
635         if globals.command == 'build':
636             target = OSXSingleTarget(64, work)
637         else:
638             target = OSXUniversalTarget(work)
639     elif s == 'source':
640         target = SourceTarget()
641     elif s == 'docker':
642         target = DockerTarget()
643
644     if target is None:
645         raise Error("Bad target `%s'" % s)
646
647     target.debug = debug
648     return target
649
650
651 #
652 # Tree
653 #
654
655 class Tree(object):
656     """Description of a tree, which is a checkout of a project,
657        possibly built.  This class is never exposed to cscripts.
658        Attributes:
659            name -- name of git repository (without the .git)
660            specifier -- git tag or revision to use
661            target --- target object that we are using
662            version --- version from the wscript (if one is present)
663            git_commit -- git revision that is actually being used
664            built --- true if the tree has been built yet in this run
665     """
666
667     def __init__(self, name, specifier, target):
668         self.name = name
669         self.specifier = specifier
670         self.target = target
671         self.version = None
672         self.git_commit = None
673         self.built = False
674
675         cwd = os.getcwd()
676
677         flags = ''
678         redirect = ''
679         if globals.quiet:
680             flags = '-q'
681             redirect = '>/dev/null'
682         command('git clone %s %s/%s.git %s/src/%s' % (flags, config.get('git_prefix'), self.name, target.directory, self.name))
683         os.chdir('%s/src/%s' % (target.directory, self.name))
684
685         spec = self.specifier
686         if spec is None:
687             spec = 'master'
688
689         command('git checkout %s %s %s' % (flags, spec, redirect))
690         self.git_commit = command_and_read('git rev-parse --short=7 HEAD').readline().strip()
691         command('git submodule init --quiet')
692         command('git submodule update --quiet')
693
694         proj = '%s/src/%s' % (target.directory, self.name)
695
696         self.cscript = {}
697         exec(open('%s/cscript' % proj).read(), self.cscript)
698
699         if os.path.exists('%s/wscript' % proj):
700             v = read_wscript_variable(proj, "VERSION");
701             if v is not None:
702                 self.version = Version(v)
703
704         os.chdir(cwd)
705
706     def call(self, function, *args):
707         with TreeDirectory(self):
708             return self.cscript[function](self.target, *args)
709
710     def build_dependencies(self, options=None):
711
712         if not 'dependencies' in self.cscript:
713             return
714
715         if len(inspect.getargspec(self.cscript['dependencies']).args) == 2:
716             deps = self.call('dependencies', options)
717         else:
718             log("Deprecated cscipt dependencies() method with no parameter")
719             deps = self.call('dependencies')
720
721         for d in deps:
722             dep = globals.trees.get(d[0], d[1], self.target)
723
724             options = dict()
725             # Make the options to pass in from the option_defaults of the thing
726             # we are building and any options specified by the parent.
727             if 'option_defaults' in dep.cscript:
728                 for k, v in dep.cscript['option_defaults']().items():
729                     options[k] = v
730
731             if len(d) > 2:
732                 for k, v in d[2].items():
733                     options[k] = v
734
735             msg = 'Building dependency %s %s of %s' % (d[0], d[1], self.name)
736             if len(options) > 0:
737                 msg += ' with options %s' % options
738             log(msg)
739
740             dep.build_dependencies(options)
741             dep.build(options)
742
743     def build(self, options=None):
744         if self.built:
745             return
746
747         variables = copy.copy(self.target.variables)
748
749         if not globals.dry_run:
750             if len(inspect.getargspec(self.cscript['build']).args) == 2:
751                 self.call('build', options)
752             else:
753                 self.call('build')
754
755         self.target.variables = variables
756         self.built = True
757
758 #
759 # Command-line parser
760 #
761
762 def main():
763
764     commands = {
765         "build": "build project",
766         "package": "package and build project",
767         "release": "release a project using its next version number (changing wscript and tagging)",
768         "pot": "build the project's .pot files",
769         "changelog": "generate a simple HTML changelog",
770         "manual": "build the project's manual",
771         "doxygen": "build the project's Doxygen documentation",
772         "latest": "print out the latest version",
773         "test": "run the project's unit tests",
774         "shell": "build the project then start a shell in its chroot",
775         "checkout": "check out the project",
776         "revision": "print the head git revision number"
777     }
778
779     one_of = "Command is one of:\n"
780     summary = ""
781     for k, v in commands.items():
782         one_of += "\t%s\t%s\n" % (k, v)
783         summary += k + " "
784
785     parser = argparse.ArgumentParser()
786     parser.add_argument('command', help=summary)
787     parser.add_argument('-p', '--project', help='project name')
788     parser.add_argument('--minor', help='minor version number bump', action='store_true')
789     parser.add_argument('--micro', help='micro version number bump', action='store_true')
790     parser.add_argument('--major', help='major version to return with latest', type=int)
791     parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
792     parser.add_argument('-o', '--output', help='output directory', default='.')
793     parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
794     parser.add_argument('-t', '--target', help='target')
795     parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
796     parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
797     parser.add_argument('-w', '--work', help='override default work directory')
798     parser.add_argument('-g', '--git-prefix', help='override configured git prefix')
799     parser.add_argument('--test', help='name of test to run (with `test''), defaults to all')
800     parser.add_argument('-n', '--dry-run', help='run the process without building anything', action='store_true')
801     args = parser.parse_args()
802
803     # Override configured stuff
804     if args.git_prefix is not None:
805         config.set('git_prefix', args.git_prefix)
806
807     if args.output.find(':') == -1:
808         # This isn't of the form host:path so make it absolute
809         args.output = os.path.abspath(args.output) + '/'
810     else:
811         if args.output[-1] != ':' and args.output[-1] != '/':
812             args.output += '/'
813
814     # Now, args.output is 'host:', 'host:path/' or 'path/'
815
816     if args.work is not None:
817         args.work = os.path.abspath(args.work)
818
819     if args.project is None and args.command != 'shell':
820         raise Error('you must specify -p or --project')
821
822     globals.quiet = args.quiet
823     globals.command = args.command
824     globals.dry_run = args.dry_run
825
826     if not globals.command in commands:
827         e = 'command must be one of:\n' + one_of
828         raise Error('command must be one of:\n%s' % one_of)
829
830     if globals.command == 'build':
831         if args.target is None:
832             raise Error('you must specify -t or --target')
833
834         target = target_factory(args.target, args.debug, args.work)
835         tree = globals.trees.get(args.project, args.checkout, target)
836         tree.build_dependencies()
837         tree.build()
838         if not args.keep:
839             target.cleanup()
840
841     elif globals.command == 'package':
842         if args.target is None:
843             raise Error('you must specify -t or --target')
844
845         target = target_factory(args.target, args.debug, args.work)
846         packages, git_commit = target.package(args.project, args.checkout)
847         if hasattr(packages, 'strip') or (not hasattr(packages, '__getitem__') and not hasattr(packages, '__iter__')):
848             packages = [packages]
849
850         if target.platform == 'linux':
851             out = '%s%s-%s-%d' % (args.output, target.distro, target.version, target.bits)
852             try:
853                 makedirs(out)
854             except:
855                 pass
856             for p in packages:
857                 copyfile(p, '%s/%s' % (out, os.path.basename(devel_to_git(git_commit, p))))
858         else:
859             try:
860                 makedirs(args.output)
861             except:
862                 pass
863             for p in packages:
864                 copyfile(p, '%s%s' % (args.output, os.path.basename(devel_to_git(git_commit, p))))
865
866         if not args.keep:
867             target.cleanup()
868
869     elif globals.command == 'release':
870         if args.minor is False and args.micro is False:
871             raise Error('you must specify --minor or --micro')
872
873         target = SourceTarget()
874         tree = globals.trees.get(args.project, args.checkout, target)
875
876         version = tree.version
877         version.to_release()
878         if args.minor:
879             version.bump_minor()
880         else:
881             version.bump_micro()
882
883         set_version_in_wscript(version)
884         append_version_to_changelog(version)
885         append_version_to_debian_changelog(version)
886
887         command('git commit -a -m "Bump version"')
888         command('git tag -m "v%s" v%s' % (version, version))
889
890         version.to_devel()
891         set_version_in_wscript(version)
892         command('git commit -a -m "Bump version"')
893         command('git push')
894         command('git push --tags')
895
896         target.cleanup()
897
898     elif globals.command == 'pot':
899         target = SourceTarget()
900         tree = globals.trees.get(args.project, args.checkout, target)
901
902         pots = tree.call('make_pot')
903         for p in pots:
904             copyfile(p, '%s%s' % (args.output, os.path.basename(p)))
905
906         target.cleanup()
907
908     elif globals.command == 'changelog':
909         target = SourceTarget()
910         tree = globals.trees.get(args.project, args.checkout, target)
911
912         with TreeDirectory(tree):
913             text = open('ChangeLog', 'r')
914
915         html = tempfile.NamedTemporaryFile()
916         versions = 8
917
918         last = None
919         changes = []
920
921         while True:
922             l = text.readline()
923             if l == '':
924                 break
925
926             if len(l) > 0 and l[0] == "\t":
927                 s = l.split()
928                 if len(s) == 4 and s[1] == "Version" and s[3] == "released.":
929                     v = Version(s[2])
930                     if v.micro == 0:
931                         if last is not None and len(changes) > 0:
932                             print("<h2>Changes between version %s and %s</h2>" % (s[2], last), file=html)
933                             print("<ul>", file=html)
934                             for c in changes:
935                                 print("<li>%s" % c, file=html)
936                             print("</ul>", file=html)
937                         last = s[2]
938                         changes = []
939                         versions -= 1
940                         if versions < 0:
941                             break
942                 else:
943                     c = l.strip()
944                     if len(c) > 0:
945                         if c[0] == '*':
946                             changes.append(c[2:])
947                         else:
948                             changes[-1] += " " + c
949
950         copyfile(html.file, '%schangelog.html' % args.output)
951         html.close()
952         target.cleanup()
953
954     elif globals.command == 'manual':
955         target = SourceTarget()
956         tree = globals.trees.get(args.project, args.checkout, target)
957
958         outs = tree.call('make_manual')
959         for o in outs:
960             if os.path.isfile(o):
961                 copyfile(o, '%s%s' % (args.output, os.path.basename(o)))
962             else:
963                 copytree(o, '%s%s' % (args.output, os.path.basename(o)))
964
965         target.cleanup()
966
967     elif globals.command == 'doxygen':
968         target = SourceTarget()
969         tree = globals.trees.get(args.project, args.checkout, target)
970
971         dirs = tree.call('make_doxygen')
972         if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
973             dirs = [dirs]
974
975         for d in dirs:
976             copytree(d, args.output)
977
978         target.cleanup()
979
980     elif globals.command == 'latest':
981         target = SourceTarget()
982         tree = globals.trees.get(args.project, args.checkout, target)
983
984         with TreeDirectory(tree):
985             f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
986             latest = None
987             while latest is None:
988                 t = f.readline()
989                 m = re.compile(".*\((.*)\).*").match(t)
990                 if m:
991                     tags = m.group(1).split(', ')
992                     for t in tags:
993                         s = t.split()
994                         if len(s) > 1:
995                             t = s[1]
996                         if len(t) > 0 and t[0] == 'v':
997                             v = Version(t[1:])
998                             if args.major is None or v.major == args.major:
999                                 latest = v
1000
1001         print(latest)
1002         target.cleanup()
1003
1004     elif globals.command == 'test':
1005         if args.target is None:
1006             raise Error('you must specify -t or --target')
1007
1008         target = None
1009         try:
1010             target = target_factory(args.target, args.debug, args.work)
1011             tree = globals.trees.get(args.project, args.checkout, target)
1012             with TreeDirectory(tree):
1013                 target.test(tree, args.test)
1014         except Error as e:
1015             if target is not None:
1016                 target.cleanup()
1017             raise
1018
1019         if target is not None:
1020             target.cleanup()
1021
1022     elif globals.command == 'shell':
1023         if args.target is None:
1024             raise Error('you must specify -t or --target')
1025
1026         target = target_factory(args.target, args.debug, args.work)
1027         target.command('bash')
1028
1029     elif globals.command == 'revision':
1030
1031         target = SourceTarget()
1032         tree = globals.trees.get(args.project, args.checkout, target)
1033         with TreeDirectory(tree):
1034             print(command_and_read('git rev-parse HEAD').readline().strip()[:7])
1035         target.cleanup()
1036
1037     elif globals.command == 'checkout':
1038
1039         if args.output is None:
1040             raise Error('you must specify -o or --output')
1041
1042         target = SourceTarget()
1043         tree = globals.trees.get(args.project, args.checkout, target)
1044         with TreeDirectory(tree):
1045             shutil.copytree('.', args.output)
1046         target.cleanup()
1047
1048     else:
1049         raise Error('invalid command %s' % globals.command)
1050
1051 try:
1052     main()
1053 except Error as e:
1054     print('cdist: %s' % str(e), file=sys.stderr)
1055     sys.exit(1)