286d7259f9139fd441c346dbd6b6ae1ed6835581
[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('windows_environment_prefix'),
96                          Option('mingw_prefix'),
97                          Option('git_prefix'),
98                          Option('osx_build_host'),
99                          Option('osx_environment_prefix'),
100                          Option('osx_sdk_prefix'),
101                          Option('osx_sdk'),
102                          Option('parallel', 4) ]
103
104         try:
105             f = open('%s/.config/cdist' % os.path.expanduser('~'), 'r')
106             while True:
107                 l = f.readline()
108                 if l == '':
109                     break
110
111                 if len(l) > 0 and l[0] == '#':
112                     continue
113
114                 s = l.strip().split()
115                 if len(s) == 2:
116                     for k in self.options:
117                         k.offer(s[0], s[1])
118         except:
119             raise
120
121     def get(self, k):
122         for o in self.options:
123             if o.key == k:
124                 if o.value is None:
125                     raise Error('Required setting %s not found' % k)
126                 return o.value
127
128     def set(self, k, v):
129         for o in self.options:
130             o.offer(k, v)
131
132 config = Config()
133
134 #
135 # Utility bits
136 #
137
138 def log(m):
139     if not globals.quiet:
140         print('\x1b[33m* %s\x1b[0m' % m)
141
142 def scp_escape(n):
143     """Escape a host:filename string for use with an scp command"""
144     s = n.split(':')
145     assert(len(s) == 1 or len(s) == 2)
146     if len(s) == 2:
147         return '%s:"\'%s\'"' % (s[0], s[1])
148     else:
149         return '\"%s\"' % s[0]
150
151 def copytree(a, b):
152     log('copy %s -> %s' % (scp_escape(b), scp_escape(b)))
153     command('scp -r %s %s' % (scp_escape(a), scp_escape(b)))
154
155 def copyfile(a, b):
156     log('copy %s -> %s' % (scp_escape(a), scp_escape(b)))
157     command('scp %s %s' % (scp_escape(a), scp_escape(b)))
158
159 def makedirs(d):
160     """
161     Make directories either locally or on a remote host; remotely if
162     d includes a colon, otherwise locally.
163     """
164     if d.find(':') == -1:
165         os.makedirs(d)
166     else:
167         s = d.split(':')
168         command('ssh %s -- mkdir -p %s' % (s[0], s[1]))
169
170 def rmdir(a):
171     log('remove %s' % a)
172     os.rmdir(a)
173
174 def rmtree(a):
175     log('remove %s' % a)
176     shutil.rmtree(a, ignore_errors=True)
177
178 def command(c):
179     log(c)
180     r = os.system(c)
181     if (r >> 8):
182         raise Error('command %s failed' % c)
183
184 def command_and_read(c):
185     log(c)
186     p = subprocess.Popen(c.split(), stdout=subprocess.PIPE)
187     f = os.fdopen(os.dup(p.stdout.fileno()))
188     return f
189
190 def read_wscript_variable(directory, variable):
191     f = open('%s/wscript' % directory, 'r')
192     while True:
193         l = f.readline()
194         if l == '':
195             break
196
197         s = l.split()
198         if len(s) == 3 and s[0] == variable:
199             f.close()
200             return s[2][1:-1]
201
202     f.close()
203     return None
204
205 def set_version_in_wscript(version):
206     f = open('wscript', 'rw')
207     o = open('wscript.tmp', 'w')
208     while True:
209         l = f.readline()
210         if l == '':
211             break
212
213         s = l.split()
214         if len(s) == 3 and s[0] == "VERSION":
215             print("Writing %s" % version)
216             print("VERSION = '%s'" % version, file=o)
217         else:
218             print(l, file=o, end="")
219     f.close()
220     o.close()
221
222     os.rename('wscript.tmp', 'wscript')
223
224 def append_version_to_changelog(version):
225     try:
226         f = open('ChangeLog', 'r')
227     except:
228         log('Could not open ChangeLog')
229         return
230
231     c = f.read()
232     f.close()
233
234     f = open('ChangeLog', 'w')
235     now = datetime.datetime.now()
236     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))
237     f.write(c)
238
239 def append_version_to_debian_changelog(version):
240     if not os.path.exists('debian'):
241         log('Could not find debian directory')
242         return
243
244     command('dch -b -v %s-1 "New upstream release."' % version)
245
246 def devel_to_git(git_commit, filename):
247     if git_commit is not None:
248         filename = filename.replace('devel', '-%s' % git_commit)
249     return filename
250
251 class TreeDirectory:
252     def __init__(self, tree):
253         self.tree = tree
254     def __enter__(self):
255         self.cwd = os.getcwd()
256         os.chdir('%s/src/%s' % (self.tree.target.directory, self.tree.name))
257     def __exit__(self, type, value, traceback):
258         os.chdir(self.cwd)
259
260 #
261 # Version
262 #
263
264 class Version:
265     def __init__(self, s):
266         self.devel = False
267
268         if s.startswith("'"):
269             s = s[1:]
270         if s.endswith("'"):
271             s = s[0:-1]
272
273         if s.endswith('devel'):
274             s = s[0:-5]
275             self.devel = True
276
277         if s.endswith('pre'):
278             s = s[0:-3]
279
280         p = s.split('.')
281         self.major = int(p[0])
282         self.minor = int(p[1])
283         if len(p) == 3:
284             self.micro = int(p[2])
285         else:
286             self.micro = 0
287
288     def bump_minor(self):
289         self.minor += 1
290         self.micro = 0
291
292     def bump_micro(self):
293         self.micro += 1
294
295     def to_devel(self):
296         self.devel = True
297
298     def to_release(self):
299         self.devel = False
300
301     def __str__(self):
302         s = '%d.%d.%d' % (self.major, self.minor, self.micro)
303         if self.devel:
304             s += 'devel'
305
306         return s
307
308 #
309 # Targets
310 #
311
312 class Target(object):
313     """
314     Class representing the target that we are building for.  This is exposed to cscripts,
315     though not all of it is guaranteed 'API'.  cscripts may expect:
316
317     platform: platform string (e.g. 'windows', 'linux', 'osx')
318     parallel: number of parallel jobs to run
319     directory: directory to work in
320     variables: dict of environment variables
321     debug: True to build a debug version, otherwise False
322     set(a, b): set the value of variable 'a' to 'b'
323     unset(a): unset the value of variable 'a'
324     command(c): run the command 'c' in the build environment
325
326     """
327
328     def __init__(self, platform, directory=None):
329         """
330         platform -- platform string (e.g. 'windows', 'linux', 'osx')
331         directory -- directory to work in; if None we will use a temporary directory
332         Temporary directories will be removed after use; specified directories will not.
333         """
334         self.platform = platform
335         self.parallel = int(config.get('parallel'))
336
337         # self.directory is the working directory
338         if directory is None:
339             self.directory = tempfile.mkdtemp('', 'tmp', TEMPORARY_DIRECTORY)
340             self.rmdir = True
341         else:
342             self.directory = directory
343             self.rmdir = False
344
345         # Environment variables that we will use when we call cscripts
346         self.variables = {}
347         self.debug = False
348
349     def package(self, project, checkout, output_dir):
350         tree = globals.trees.get(project, checkout, self)
351         tree.build_dependencies()
352         tree.build()
353         for p in tree.call('package', tree.version):
354             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
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     Build for Linux in a docker container.
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 DockerTarget(LinuxTarget):
469     """Build in a docker container"""
470     def __init__(self, distro, version, bits, directory=None):
471         super(DockerTarget, self).__init__(distro, version, bits, directory)
472
473     def package(self, project, checkout, output_dir):
474         ch = ''
475         if checkout is not None:
476             ch = '-c %s' % checkout
477         target = '%s-%s-%s' % (self.distro, self.version, self.bits)
478         container = command_and_read('docker run -itd %s /bin/bash' % target).read().strip()
479         command('docker exec -t %s /bin/bash -c "cdist -p %s -t %s -d %s package -w /cdist"' % (container, project, target, ch))
480         # I can't get wildcards to work with docker cp
481         debs = command_and_read('docker exec -t %s ls -1 /%s' % (container, target)).read().split('\n')
482         makedirs(output_dir)
483         for d in debs:
484             d = d.strip()
485             if len(d) > 0:
486                 command('docker cp %s:/%s/%s %s' % (container, target, d, output_dir))
487         command('docker kill %s' % container)
488
489 class DirectTarget(LinuxTarget):
490     """Build directly in the current environment"""
491     def __init__(self, distro, version, bits, directory=None):
492         super(DirectTarget, self).__init__(distro, version, bits, directory)
493
494     def command(self, c):
495         command('%s %s' % (self.variables_string(), c))
496
497
498 class OSXTarget(Target):
499     def __init__(self, directory=None):
500         super(OSXTarget, self).__init__('osx', directory)
501         self.sdk = config.get('osx_sdk')
502         self.sdk_prefix = config.get('osx_sdk_prefix')
503         self.environment_prefix = config.get('osx_environment_prefix')
504
505     def command(self, c):
506         command('%s %s' % (self.variables_string(False), c))
507
508
509 class OSXSingleTarget(OSXTarget):
510     def __init__(self, bits, directory=None):
511         super(OSXSingleTarget, self).__init__(directory)
512         self.bits = bits
513
514         if bits == 32:
515             arch = 'i386'
516         else:
517             arch = 'x86_64'
518
519         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (self.sdk_prefix, self.sdk, arch)
520         enviro = '%s/%d' % (config.get('osx_environment_prefix'), bits)
521
522         # Environment variables
523         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
524         self.set('CPPFLAGS', '')
525         self.set('CXXFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
526         self.set('LDFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
527         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
528         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, enviro))
529         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % enviro)
530         self.set('MACOSX_DEPLOYMENT_TARGET', config.get('osx_sdk'))
531
532     def package(self, project, checkout, output_dir):
533         raise Error('cannot package non-universal OS X versions')
534
535
536 class OSXUniversalTarget(OSXTarget):
537     def __init__(self, directory=None):
538         super(OSXUniversalTarget, self).__init__(directory)
539
540     def package(self, project, checkout, output_dir):
541
542         for b in [32, 64]:
543             target = OSXSingleTarget(b, os.path.join(self.directory, '%d' % b))
544             tree = globals.trees.get(project, checkout, target)
545             tree.build_dependencies()
546             tree.build()
547
548         tree = globals.trees.get(project, checkout, self)
549         with TreeDirectory(tree):
550             for p in tree.call('package', tree.version):
551                 copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
552
553 class SourceTarget(Target):
554     """Build a source .tar.bz2"""
555     def __init__(self):
556         super(SourceTarget, self).__init__('source')
557
558     def command(self, c):
559         log('host -> %s' % c)
560         command('%s %s' % (self.variables_string(), c))
561
562     def cleanup(self):
563         rmtree(self.directory)
564
565     def package(self, project, checkout, output_dir):
566         tree = globals.trees.get(project, checkout, self)
567         with TreeDirectory(tree):
568             name = read_wscript_variable(os.getcwd(), 'APPNAME')
569             command('./waf dist')
570             p = os.path.abspath('%s-%s.tar.bz2' % (name, tree.version))
571             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
572
573 # @param s Target string:
574 #       windows-{32,64}
575 #    or ubuntu-version-{32,64}
576 #    or debian-version-{32,64}
577 #    or centos-version-{32,64}
578 #    or fedora-version-{32,64}
579 #    or osx-{32,64}
580 #    or source
581 # @param debug True to build with debugging symbols (where possible)
582 def target_factory(s, direct, debug, work):
583     target = None
584     if s.startswith('windows-'):
585         x = s.split('-')
586         if len(x) == 2:
587             target = WindowsTarget(None, int(x[1]), work)
588         elif len(x) == 3:
589             target = WindowsTarget(x[1], int(x[2]), work)
590         else:
591             raise Error("Bad Windows target name `%s'")
592     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-') or s.startswith('fedora'):
593         p = s.split('-')
594         if len(p) != 3:
595             raise Error("Bad Linux target name `%s'; must be something like ubuntu-12.04-32 (i.e. distro-version-bits)" % s)
596         if direct:
597             target = DirectTarget(p[0], p[1], int(p[2]), work)
598         else:
599             target = DockerTarget(p[0], p[1], int(p[2]), work)
600     elif s.startswith('arch-'):
601         p = s.split('-')
602         if len(p) != 2:
603             raise Error("Bad Arch target name `%s'; must be arch-32 or arch-64")
604         target = DockerTarget(p[0], None, p[1], work)
605     elif s == 'raspbian':
606         target = DockerTarget(s, None, None, work)
607     elif s.startswith('osx-'):
608         target = OSXSingleTarget(int(s.split('-')[1]), work)
609     elif s == 'osx':
610         if globals.command == 'build':
611             target = OSXSingleTarget(64, work)
612         else:
613             target = OSXUniversalTarget(work)
614     elif s == 'source':
615         target = SourceTarget()
616     elif s == 'docker':
617         target = DockerTarget()
618
619     if target is None:
620         raise Error("Bad target `%s'" % s)
621
622     target.debug = debug
623     return target
624
625
626 #
627 # Tree
628 #
629
630 class Tree(object):
631     """Description of a tree, which is a checkout of a project,
632        possibly built.  This class is never exposed to cscripts.
633        Attributes:
634            name -- name of git repository (without the .git)
635            specifier -- git tag or revision to use
636            target --- target object that we are using
637            version --- version from the wscript (if one is present)
638            git_commit -- git revision that is actually being used
639            built --- true if the tree has been built yet in this run
640     """
641
642     def __init__(self, name, specifier, target):
643         self.name = name
644         self.specifier = specifier
645         self.target = target
646         self.version = None
647         self.git_commit = None
648         self.built = False
649
650         cwd = os.getcwd()
651
652         flags = ''
653         redirect = ''
654         if globals.quiet:
655             flags = '-q'
656             redirect = '>/dev/null'
657         command('git clone %s %s/%s.git %s/src/%s' % (flags, config.get('git_prefix'), self.name, target.directory, self.name))
658         os.chdir('%s/src/%s' % (target.directory, self.name))
659
660         spec = self.specifier
661         if spec is None:
662             spec = 'master'
663
664         command('git checkout %s %s %s' % (flags, spec, redirect))
665         self.git_commit = command_and_read('git rev-parse --short=7 HEAD').readline().strip()
666         command('git submodule init --quiet')
667         command('git submodule update --quiet')
668
669         proj = '%s/src/%s' % (target.directory, self.name)
670
671         self.cscript = {}
672         exec(open('%s/cscript' % proj).read(), self.cscript)
673
674         if os.path.exists('%s/wscript' % proj):
675             v = read_wscript_variable(proj, "VERSION");
676             if v is not None:
677                 self.version = Version(v)
678
679         os.chdir(cwd)
680
681     def call(self, function, *args):
682         with TreeDirectory(self):
683             return self.cscript[function](self.target, *args)
684
685     def build_dependencies(self, options=None):
686
687         if not 'dependencies' in self.cscript:
688             return
689
690         if len(inspect.getargspec(self.cscript['dependencies']).args) == 2:
691             deps = self.call('dependencies', options)
692         else:
693             log("Deprecated cscipt dependencies() method with no parameter")
694             deps = self.call('dependencies')
695
696         for d in deps:
697             dep = globals.trees.get(d[0], d[1], self.target)
698
699             options = dict()
700             # Make the options to pass in from the option_defaults of the thing
701             # we are building and any options specified by the parent.
702             if 'option_defaults' in dep.cscript:
703                 for k, v in dep.cscript['option_defaults']().items():
704                     options[k] = v
705
706             if len(d) > 2:
707                 for k, v in d[2].items():
708                     options[k] = v
709
710             msg = 'Building dependency %s %s of %s' % (d[0], d[1], self.name)
711             if len(options) > 0:
712                 msg += ' with options %s' % options
713             log(msg)
714
715             dep.build_dependencies(options)
716             dep.build(options)
717
718     def build(self, options=None):
719         if self.built:
720             return
721
722         variables = copy.copy(self.target.variables)
723
724         if not globals.dry_run:
725             if len(inspect.getargspec(self.cscript['build']).args) == 2:
726                 self.call('build', options)
727             else:
728                 self.call('build')
729
730         self.target.variables = variables
731         self.built = True
732
733 #
734 # Command-line parser
735 #
736
737 def main():
738
739     commands = {
740         "build": "build project",
741         "package": "package and build project",
742         "release": "release a project using its next version number (changing wscript and tagging)",
743         "pot": "build the project's .pot files",
744         "changelog": "generate a simple HTML changelog",
745         "manual": "build the project's manual",
746         "doxygen": "build the project's Doxygen documentation",
747         "latest": "print out the latest version",
748         "test": "run the project's unit tests",
749         "shell": "build the project then start a shell",
750         "checkout": "check out the project",
751         "revision": "print the head git revision number"
752     }
753
754     one_of = "Command is one of:\n"
755     summary = ""
756     for k, v in commands.items():
757         one_of += "\t%s\t%s\n" % (k, v)
758         summary += k + " "
759
760     parser = argparse.ArgumentParser()
761     parser.add_argument('command', help=summary)
762     parser.add_argument('-p', '--project', help='project name')
763     parser.add_argument('--minor', help='minor version number bump', action='store_true')
764     parser.add_argument('--micro', help='micro version number bump', action='store_true')
765     parser.add_argument('--major', help='major version to return with latest', type=int)
766     parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
767     parser.add_argument('-o', '--output', help='output directory', default='.')
768     parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
769     parser.add_argument('-t', '--target', help='target')
770     parser.add_argument('-d', '--direct', help='build in the current environment', action='store_true')
771     parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
772     parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
773     parser.add_argument('-w', '--work', help='override default work directory')
774     parser.add_argument('-g', '--git-prefix', help='override configured git prefix')
775     parser.add_argument('--test', help='name of test to run (with `test''), defaults to all')
776     parser.add_argument('-n', '--dry-run', help='run the process without building anything', action='store_true')
777     args = parser.parse_args()
778
779     # Override configured stuff
780     if args.git_prefix is not None:
781         config.set('git_prefix', args.git_prefix)
782
783     if args.output.find(':') == -1:
784         # This isn't of the form host:path so make it absolute
785         args.output = os.path.abspath(args.output) + '/'
786     else:
787         if args.output[-1] != ':' and args.output[-1] != '/':
788             args.output += '/'
789
790     # Now, args.output is 'host:', 'host:path/' or 'path/'
791
792     if args.work is not None:
793         args.work = os.path.abspath(args.work)
794
795     if args.project is None and args.command != 'shell':
796         raise Error('you must specify -p or --project')
797
798     globals.quiet = args.quiet
799     globals.command = args.command
800     globals.dry_run = args.dry_run
801
802     if not globals.command in commands:
803         e = 'command must be one of:\n' + one_of
804         raise Error('command must be one of:\n%s' % one_of)
805
806     if globals.command == 'build':
807         if args.target is None:
808             raise Error('you must specify -t or --target')
809
810         target = target_factory(args.target, args.direct, args.debug, args.work)
811         tree = globals.trees.get(args.project, args.checkout, target)
812         tree.build_dependencies()
813         tree.build()
814         if not args.keep:
815             target.cleanup()
816
817     elif globals.command == 'package':
818         if args.target is None:
819             raise Error('you must specify -t or --target')
820
821         target = target_factory(args.target, args.direct, args.debug, args.work)
822
823         if target.platform == 'linux':
824             output_dir = os.path.join(args.output, '%s-%s-%d' % (target.distro, target.version, target.bits))
825         else:
826             output_dir = args.output
827
828         target.package(args.project, args.checkout, output_dir)
829
830         if not args.keep:
831             target.cleanup()
832
833     elif globals.command == 'release':
834         if args.minor is False and args.micro is False:
835             raise Error('you must specify --minor or --micro')
836
837         target = SourceTarget()
838         tree = globals.trees.get(args.project, args.checkout, target)
839
840         version = tree.version
841         version.to_release()
842         if args.minor:
843             version.bump_minor()
844         else:
845             version.bump_micro()
846
847         set_version_in_wscript(version)
848         append_version_to_changelog(version)
849         append_version_to_debian_changelog(version)
850
851         command('git commit -a -m "Bump version"')
852         command('git tag -m "v%s" v%s' % (version, version))
853
854         version.to_devel()
855         set_version_in_wscript(version)
856         command('git commit -a -m "Bump version"')
857         command('git push')
858         command('git push --tags')
859
860         target.cleanup()
861
862     elif globals.command == 'pot':
863         target = SourceTarget()
864         tree = globals.trees.get(args.project, args.checkout, target)
865
866         pots = tree.call('make_pot')
867         for p in pots:
868             copyfile(p, '%s%s' % (args.output, os.path.basename(p)))
869
870         target.cleanup()
871
872     elif globals.command == 'changelog':
873         target = SourceTarget()
874         tree = globals.trees.get(args.project, args.checkout, target)
875
876         with TreeDirectory(tree):
877             text = open('ChangeLog', 'r')
878
879         html = tempfile.NamedTemporaryFile()
880         versions = 8
881
882         last = None
883         changes = []
884
885         while True:
886             l = text.readline()
887             if l == '':
888                 break
889
890             if len(l) > 0 and l[0] == "\t":
891                 s = l.split()
892                 if len(s) == 4 and s[1] == "Version" and s[3] == "released.":
893                     v = Version(s[2])
894                     if v.micro == 0:
895                         if last is not None and len(changes) > 0:
896                             print("<h2>Changes between version %s and %s</h2>" % (s[2], last), file=html)
897                             print("<ul>", file=html)
898                             for c in changes:
899                                 print("<li>%s" % c, file=html)
900                             print("</ul>", file=html)
901                         last = s[2]
902                         changes = []
903                         versions -= 1
904                         if versions < 0:
905                             break
906                 else:
907                     c = l.strip()
908                     if len(c) > 0:
909                         if c[0] == '*':
910                             changes.append(c[2:])
911                         else:
912                             changes[-1] += " " + c
913
914         copyfile(html.file, '%schangelog.html' % args.output)
915         html.close()
916         target.cleanup()
917
918     elif globals.command == 'manual':
919         target = SourceTarget()
920         tree = globals.trees.get(args.project, args.checkout, target)
921
922         outs = tree.call('make_manual')
923         for o in outs:
924             if os.path.isfile(o):
925                 copyfile(o, '%s%s' % (args.output, os.path.basename(o)))
926             else:
927                 copytree(o, '%s%s' % (args.output, os.path.basename(o)))
928
929         target.cleanup()
930
931     elif globals.command == 'doxygen':
932         target = SourceTarget()
933         tree = globals.trees.get(args.project, args.checkout, target)
934
935         dirs = tree.call('make_doxygen')
936         if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
937             dirs = [dirs]
938
939         for d in dirs:
940             copytree(d, args.output)
941
942         target.cleanup()
943
944     elif globals.command == 'latest':
945         target = SourceTarget()
946         tree = globals.trees.get(args.project, args.checkout, target)
947
948         with TreeDirectory(tree):
949             f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
950             latest = None
951             while latest is None:
952                 t = f.readline()
953                 m = re.compile(".*\((.*)\).*").match(t)
954                 if m:
955                     tags = m.group(1).split(', ')
956                     for t in tags:
957                         s = t.split()
958                         if len(s) > 1:
959                             t = s[1]
960                         if len(t) > 0 and t[0] == 'v':
961                             v = Version(t[1:])
962                             if args.major is None or v.major == args.major:
963                                 latest = v
964
965         print(latest)
966         target.cleanup()
967
968     elif globals.command == 'test':
969         if args.target is None:
970             raise Error('you must specify -t or --target')
971
972         target = None
973         try:
974             target = target_factory(args.target, args.direct, args.debug, args.work)
975             tree = globals.trees.get(args.project, args.checkout, target)
976             with TreeDirectory(tree):
977                 target.test(tree, args.test)
978         except Error as e:
979             if target is not None:
980                 target.cleanup()
981             raise
982
983         if target is not None:
984             target.cleanup()
985
986     elif globals.command == 'shell':
987         if args.target is None:
988             raise Error('you must specify -t or --target')
989
990         target = target_factory(args.target, args.direct, args.debug, args.work)
991         target.command('bash')
992
993     elif globals.command == 'revision':
994
995         target = SourceTarget()
996         tree = globals.trees.get(args.project, args.checkout, target)
997         with TreeDirectory(tree):
998             print(command_and_read('git rev-parse HEAD').readline().strip()[:7])
999         target.cleanup()
1000
1001     elif globals.command == 'checkout':
1002
1003         if args.output is None:
1004             raise Error('you must specify -o or --output')
1005
1006         target = SourceTarget()
1007         tree = globals.trees.get(args.project, args.checkout, target)
1008         with TreeDirectory(tree):
1009             shutil.copytree('.', args.output)
1010         target.cleanup()
1011
1012     else:
1013         raise Error('invalid command %s' % globals.command)
1014
1015 try:
1016     main()
1017 except Error as e:
1018     print('cdist: %s' % str(e), file=sys.stderr)
1019     sys.exit(1)