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