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