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