1301cf6d23229749f7a17cfeb83e98c7cd7a1253
[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         self.set('CC', '%s-gcc' % self.name)
439         self.set('CXX', '%s-g++' % self.name)
440         self.set('LD', '%s-ld' % self.name)
441         self.set('RANLIB', '%s-ranlib' % self.name)
442         self.set('WINRC', '%s-windres' % self.name)
443         cxx = '-I%s/include -I%s/include' % (self.library_prefix, self.directory)
444         link = '-L%s/lib -L%s/lib' % (self.library_prefix, self.directory)
445         self.set('CXXFLAGS', '"%s"' % cxx)
446         self.set('CPPFLAGS', '')
447         self.set('LINKFLAGS', '"%s"' % link)
448         self.set('LDFLAGS', '"%s"' % link)
449
450     @property
451     def windows_prefix(self):
452         log('Deprecated property windows_prefix')
453         return self.library_prefix
454
455     @property
456     def mingw_prefixes(self):
457         log('Deprecated property mingw_prefixes')
458         return [self.library_prefix]
459
460     @property
461     def mingw_path(self):
462         log('Deprecated property mingw_path')
463         return self.tool_path
464
465     @property
466     def mingw_name(self):
467         log('Deprecated property mingw_name')
468         return self.name
469
470     def command(self, c):
471         log('host -> %s' % c)
472         command('%s %s' % (self.variables_string(), c))
473
474 class LinuxTarget(Target):
475     """
476     Build for Linux in a docker container.
477     This target exposes the following additional API:
478
479     distro: distribution ('debian', 'ubuntu', 'centos' or 'fedora')
480     version: distribution version (e.g. '12.04', '8', '6.5')
481     bits: bitness of the distribution (32 or 64)
482     """
483
484     def __init__(self, distro, version, bits, directory=None):
485         super(LinuxTarget, self).__init__('linux', directory)
486         self.distro = distro
487         self.version = version
488         self.bits = bits
489
490         self.set('CXXFLAGS', '-I%s/include' % self.directory)
491         self.set('CPPFLAGS', '')
492         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
493         self.set('PKG_CONFIG_PATH',
494                  '%s/lib/pkgconfig:%s/lib64/pkgconfig:/usr/local/lib64/pkgconfig:/usr/local/lib/pkgconfig' % (self.directory, self.directory))
495         self.set('PATH', '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin')
496
497 class DockerTarget(LinuxTarget):
498     """Build in a docker container"""
499     def __init__(self, distro, version, bits, directory=None):
500         super(DockerTarget, self).__init__(distro, version, bits, directory)
501
502     def build(self, project, checkout):
503         ch = ''
504         if checkout is not None:
505             ch = '-c %s' % checkout
506         target = '%s-%s-%s' % (self.distro, self.version, self.bits)
507         container = command_and_read('%s run -itd %s /bin/bash' % (config.docker(), target)).read().strip()
508         command('%s exec -t %s /bin/bash -c "cdist -p %s -t %s -d %s build -w /cdist"' % (config.docker(), container, project, target, ch))
509         command('%s kill %s' % (config.docker(), container))
510
511     def package(self, project, checkout, output_dir):
512         ch = ''
513         if checkout is not None:
514             ch = '-c %s' % checkout
515         target = '%s-%s-%s' % (self.distro, self.version, self.bits)
516         container = command_and_read('%s run -itd %s /bin/bash' % (config.docker(), target)).read().strip()
517         command('%s exec -t %s /bin/bash -c "cdist -p %s -t %s -d %s package -w /cdist"' % (config.docker(), container, project, target, ch))
518         # I can't get wildcards to work with docker cp
519         debs = command_and_read('%s exec -t %s ls -1 /%s' % (config.docker(), container, target)).read().split('\n')
520         for d in debs:
521             d = d.strip()
522             if len(d) > 0:
523                 command('%s cp %s:/%s/%s %s' % (config.docker(), container, target, d, self.directory))
524                 copyfile('%s/%s' % (self.directory, d), output_dir)
525         command('%s kill %s' % (config.docker(), container))
526
527 class DirectTarget(LinuxTarget):
528     """Build directly in the current environment"""
529     def __init__(self, distro, version, bits, directory=None):
530         super(DirectTarget, self).__init__(distro, version, bits, directory)
531
532     def command(self, c):
533         command('%s %s' % (self.variables_string(), c))
534
535
536 class OSXTarget(Target):
537     def __init__(self, directory=None):
538         super(OSXTarget, self).__init__('osx', directory)
539         self.sdk = config.get('osx_sdk')
540         self.sdk_prefix = config.get('osx_sdk_prefix')
541         self.environment_prefix = config.get('osx_environment_prefix')
542
543     def command(self, c):
544         command('%s %s' % (self.variables_string(False), c))
545
546
547 class OSXSingleTarget(OSXTarget):
548     def __init__(self, bits, directory=None):
549         super(OSXSingleTarget, self).__init__(directory)
550         self.bits = bits
551
552         if bits == 32:
553             arch = 'i386'
554         else:
555             arch = 'x86_64'
556
557         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (self.sdk_prefix, self.sdk, arch)
558         enviro = '%s/%d' % (config.get('osx_environment_prefix'), bits)
559
560         # Environment variables
561         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
562         self.set('CPPFLAGS', '')
563         self.set('CXXFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
564         self.set('LDFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
565         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
566         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, enviro))
567         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % enviro)
568         self.set('MACOSX_DEPLOYMENT_TARGET', config.get('osx_sdk'))
569
570     def package(self, project, checkout, output_dir):
571         raise Error('cannot package non-universal OS X versions')
572
573
574 class OSXUniversalTarget(OSXTarget):
575     def __init__(self, directory=None):
576         super(OSXUniversalTarget, self).__init__(directory)
577
578     def package(self, project, checkout, output_dir):
579
580         for b in [32, 64]:
581             target = OSXSingleTarget(b, os.path.join(self.directory, '%d' % b))
582             tree = globals.trees.get(project, checkout, target)
583             tree.build_dependencies()
584             tree.build()
585
586         tree = globals.trees.get(project, checkout, self)
587         with TreeDirectory(tree):
588             for p in tree.call('package', tree.version):
589                 copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
590
591 class SourceTarget(Target):
592     """Build a source .tar.bz2"""
593     def __init__(self):
594         super(SourceTarget, self).__init__('source')
595
596     def command(self, c):
597         log('host -> %s' % c)
598         command('%s %s' % (self.variables_string(), c))
599
600     def cleanup(self):
601         rmtree(self.directory)
602
603     def package(self, project, checkout, output_dir):
604         tree = globals.trees.get(project, checkout, self)
605         with TreeDirectory(tree):
606             name = read_wscript_variable(os.getcwd(), 'APPNAME')
607             command('./waf dist')
608             p = os.path.abspath('%s-%s.tar.bz2' % (name, tree.version))
609             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
610
611 # @param s Target string:
612 #       windows-{32,64}
613 #    or ubuntu-version-{32,64}
614 #    or debian-version-{32,64}
615 #    or centos-version-{32,64}
616 #    or fedora-version-{32,64}
617 #    or osx-{32,64}
618 #    or source
619 # @param debug True to build with debugging symbols (where possible)
620 def target_factory(s, direct, debug, work):
621     target = None
622     if s.startswith('windows-'):
623         x = s.split('-')
624         if len(x) == 2:
625             target = WindowsTarget(None, int(x[1]), work)
626         elif len(x) == 3:
627             target = WindowsTarget(x[1], int(x[2]), work)
628         else:
629             raise Error("Bad Windows target name `%s'")
630     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-') or s.startswith('fedora'):
631         p = s.split('-')
632         if len(p) != 3:
633             raise Error("Bad Linux target name `%s'; must be something like ubuntu-12.04-32 (i.e. distro-version-bits)" % s)
634         if direct:
635             target = DirectTarget(p[0], p[1], int(p[2]), work)
636         else:
637             target = DockerTarget(p[0], p[1], int(p[2]), work)
638     elif s.startswith('arch-'):
639         p = s.split('-')
640         if len(p) != 2:
641             raise Error("Bad Arch target name `%s'; must be arch-32 or arch-64")
642         target = DockerTarget(p[0], None, p[1], work)
643     elif s == 'raspbian':
644         target = DockerTarget(s, None, None, work)
645     elif s.startswith('osx-'):
646         target = OSXSingleTarget(int(s.split('-')[1]), work)
647     elif s == 'osx':
648         if globals.command == 'build':
649             target = OSXSingleTarget(64, work)
650         else:
651             target = OSXUniversalTarget(work)
652     elif s == 'source':
653         target = SourceTarget()
654     elif s == 'docker':
655         target = DockerTarget()
656
657     if target is None:
658         raise Error("Bad target `%s'" % s)
659
660     target.debug = debug
661     return target
662
663
664 #
665 # Tree
666 #
667
668 class Tree(object):
669     """Description of a tree, which is a checkout of a project,
670        possibly built.  This class is never exposed to cscripts.
671        Attributes:
672            name -- name of git repository (without the .git)
673            specifier -- git tag or revision to use
674            target --- target object that we are using
675            version --- version from the wscript (if one is present)
676            git_commit -- git revision that is actually being used
677            built --- true if the tree has been built yet in this run
678     """
679
680     def __init__(self, name, specifier, target):
681         self.name = name
682         self.specifier = specifier
683         self.target = target
684         self.version = None
685         self.git_commit = None
686         self.built = False
687
688         cwd = os.getcwd()
689
690         flags = ''
691         redirect = ''
692         if globals.quiet:
693             flags = '-q'
694             redirect = '>/dev/null'
695         command('git clone %s %s/%s.git %s/src/%s' % (flags, config.get('git_prefix'), self.name, target.directory, self.name))
696         os.chdir('%s/src/%s' % (target.directory, self.name))
697
698         spec = self.specifier
699         if spec is None:
700             spec = 'master'
701
702         command('git checkout %s %s %s' % (flags, spec, redirect))
703         self.git_commit = command_and_read('git rev-parse --short=7 HEAD').readline().strip()
704         command('git submodule init --quiet')
705         command('git submodule update --quiet')
706
707         proj = '%s/src/%s' % (target.directory, self.name)
708
709         self.cscript = {}
710         exec(open('%s/cscript' % proj).read(), self.cscript)
711
712         if os.path.exists('%s/wscript' % proj):
713             v = read_wscript_variable(proj, "VERSION");
714             if v is not None:
715                 self.version = Version(v)
716
717         os.chdir(cwd)
718
719     def call(self, function, *args):
720         with TreeDirectory(self):
721             return self.cscript[function](self.target, *args)
722
723     def build_dependencies(self, options=None):
724
725         if not 'dependencies' in self.cscript:
726             return
727
728         if len(inspect.getargspec(self.cscript['dependencies']).args) == 2:
729             deps = self.call('dependencies', options)
730         else:
731             log("Deprecated cscipt dependencies() method with no parameter")
732             deps = self.call('dependencies')
733
734         for d in deps:
735             dep = globals.trees.get(d[0], d[1], self.target)
736
737             options = dict()
738             # Make the options to pass in from the option_defaults of the thing
739             # we are building and any options specified by the parent.
740             if 'option_defaults' in dep.cscript:
741                 for k, v in dep.cscript['option_defaults']().items():
742                     options[k] = v
743
744             if len(d) > 2:
745                 for k, v in d[2].items():
746                     options[k] = v
747
748             msg = 'Building dependency %s %s of %s' % (d[0], d[1], self.name)
749             if len(options) > 0:
750                 msg += ' with options %s' % options
751             log(msg)
752
753             dep.build_dependencies(options)
754             dep.build(options)
755
756     def build(self, options=None):
757         if self.built:
758             return
759
760         variables = copy.copy(self.target.variables)
761
762         if not globals.dry_run:
763             if len(inspect.getargspec(self.cscript['build']).args) == 2:
764                 self.call('build', options)
765             else:
766                 self.call('build')
767
768         self.target.variables = variables
769         self.built = True
770
771 #
772 # Command-line parser
773 #
774
775 def main():
776
777     commands = {
778         "build": "build project",
779         "package": "package and build project",
780         "release": "release a project using its next version number (changing wscript and tagging)",
781         "pot": "build the project's .pot files",
782         "changelog": "generate a simple HTML changelog",
783         "manual": "build the project's manual",
784         "doxygen": "build the project's Doxygen documentation",
785         "latest": "print out the latest version",
786         "test": "run the project's unit tests",
787         "shell": "build the project then start a shell",
788         "checkout": "check out the project",
789         "revision": "print the head git revision number"
790     }
791
792     one_of = "Command is one of:\n"
793     summary = ""
794     for k, v in commands.items():
795         one_of += "\t%s\t%s\n" % (k, v)
796         summary += k + " "
797
798     parser = argparse.ArgumentParser()
799     parser.add_argument('command', help=summary)
800     parser.add_argument('-p', '--project', help='project name')
801     parser.add_argument('--minor', help='minor version number bump', action='store_true')
802     parser.add_argument('--micro', help='micro version number bump', action='store_true')
803     parser.add_argument('--major', help='major version to return with latest', type=int)
804     parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
805     parser.add_argument('-o', '--output', help='output directory', default='.')
806     parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
807     parser.add_argument('-t', '--target', help='target')
808     parser.add_argument('-d', '--direct', help='build in the current environment', action='store_true')
809     parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
810     parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
811     parser.add_argument('-w', '--work', help='override default work directory')
812     parser.add_argument('-g', '--git-prefix', help='override configured git prefix')
813     parser.add_argument('--test', help='name of test to run (with `test''), defaults to all')
814     parser.add_argument('-n', '--dry-run', help='run the process without building anything', action='store_true')
815     args = parser.parse_args()
816
817     # Override configured stuff
818     if args.git_prefix is not None:
819         config.set('git_prefix', args.git_prefix)
820
821     if args.output.find(':') == -1:
822         # This isn't of the form host:path so make it absolute
823         args.output = os.path.abspath(args.output) + '/'
824     else:
825         if args.output[-1] != ':' and args.output[-1] != '/':
826             args.output += '/'
827
828     # Now, args.output is 'host:', 'host:path/' or 'path/'
829
830     if args.work is not None:
831         args.work = os.path.abspath(args.work)
832
833     if args.project is None and args.command != 'shell':
834         raise Error('you must specify -p or --project')
835
836     globals.quiet = args.quiet
837     globals.command = args.command
838     globals.dry_run = args.dry_run
839
840     if not globals.command in commands:
841         e = 'command must be one of:\n' + one_of
842         raise Error('command must be one of:\n%s' % one_of)
843
844     if globals.command == 'build':
845         if args.target is None:
846             raise Error('you must specify -t or --target')
847
848         target = target_factory(args.target, args.direct, args.debug, args.work)
849         target.build(args.project, args.checkout)
850         if not args.keep:
851             target.cleanup()
852
853     elif globals.command == 'package':
854         if args.target is None:
855             raise Error('you must specify -t or --target')
856
857         target = target_factory(args.target, args.direct, args.debug, args.work)
858
859         if target.platform == 'linux':
860             output_dir = os.path.join(args.output, '%s-%s-%d' % (target.distro, target.version, target.bits))
861         else:
862             output_dir = args.output
863
864         makedirs(output_dir)
865         target.package(args.project, args.checkout, output_dir)
866
867         if not args.keep:
868             target.cleanup()
869
870     elif globals.command == 'release':
871         if args.minor is False and args.micro is False:
872             raise Error('you must specify --minor or --micro')
873
874         target = SourceTarget()
875         tree = globals.trees.get(args.project, args.checkout, target)
876
877         version = tree.version
878         version.to_release()
879         if args.minor:
880             version.bump_minor()
881         else:
882             version.bump_micro()
883
884         with TreeDirectory(tree):
885             set_version_in_wscript(version)
886             append_version_to_changelog(version)
887             append_version_to_debian_changelog(version)
888
889             command('git commit -a -m "Bump version"')
890             command('git tag -m "v%s" v%s' % (version, version))
891
892             version.to_devel()
893             set_version_in_wscript(version)
894             command('git commit -a -m "Bump version"')
895             command('git push')
896             command('git push --tags')
897
898         target.cleanup()
899
900     elif globals.command == 'pot':
901         target = SourceTarget()
902         tree = globals.trees.get(args.project, args.checkout, target)
903
904         pots = tree.call('make_pot')
905         for p in pots:
906             copyfile(p, '%s%s' % (args.output, os.path.basename(p)))
907
908         target.cleanup()
909
910     elif globals.command == 'changelog':
911         target = SourceTarget()
912         tree = globals.trees.get(args.project, args.checkout, target)
913
914         with TreeDirectory(tree):
915             text = open('ChangeLog', 'r')
916
917         html = tempfile.NamedTemporaryFile()
918         versions = 8
919
920         last = None
921         changes = []
922
923         while True:
924             l = text.readline()
925             if l == '':
926                 break
927
928             if len(l) > 0 and l[0] == "\t":
929                 s = l.split()
930                 if len(s) == 4 and s[1] == "Version" and s[3] == "released.":
931                     v = Version(s[2])
932                     if v.micro == 0:
933                         if last is not None and len(changes) > 0:
934                             print("<h2>Changes between version %s and %s</h2>" % (s[2], last), file=html)
935                             print("<ul>", file=html)
936                             for c in changes:
937                                 print("<li>%s" % c, file=html)
938                             print("</ul>", file=html)
939                         last = s[2]
940                         changes = []
941                         versions -= 1
942                         if versions < 0:
943                             break
944                 else:
945                     c = l.strip()
946                     if len(c) > 0:
947                         if c[0] == '*':
948                             changes.append(c[2:])
949                         else:
950                             changes[-1] += " " + c
951
952         copyfile(html.file, '%schangelog.html' % args.output)
953         html.close()
954         target.cleanup()
955
956     elif globals.command == 'manual':
957         target = SourceTarget()
958         tree = globals.trees.get(args.project, args.checkout, target)
959
960         outs = tree.call('make_manual')
961         for o in outs:
962             if os.path.isfile(o):
963                 copyfile(o, '%s%s' % (args.output, os.path.basename(o)))
964             else:
965                 copytree(o, '%s%s' % (args.output, os.path.basename(o)))
966
967         target.cleanup()
968
969     elif globals.command == 'doxygen':
970         target = SourceTarget()
971         tree = globals.trees.get(args.project, args.checkout, target)
972
973         dirs = tree.call('make_doxygen')
974         if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
975             dirs = [dirs]
976
977         for d in dirs:
978             copytree(d, args.output)
979
980         target.cleanup()
981
982     elif globals.command == 'latest':
983         target = SourceTarget()
984         tree = globals.trees.get(args.project, args.checkout, target)
985
986         with TreeDirectory(tree):
987             f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
988             latest = None
989             while latest is None:
990                 t = f.readline()
991                 m = re.compile(".*\((.*)\).*").match(t)
992                 if m:
993                     tags = m.group(1).split(', ')
994                     for t in tags:
995                         s = t.split()
996                         if len(s) > 1:
997                             t = s[1]
998                         if len(t) > 0 and t[0] == 'v':
999                             v = Version(t[1:])
1000                             if args.major is None or v.major == args.major:
1001                                 latest = v
1002
1003         print(latest)
1004         target.cleanup()
1005
1006     elif globals.command == 'test':
1007         if args.target is None:
1008             raise Error('you must specify -t or --target')
1009
1010         target = None
1011         try:
1012             target = target_factory(args.target, args.direct, args.debug, args.work)
1013             tree = globals.trees.get(args.project, args.checkout, target)
1014             with TreeDirectory(tree):
1015                 target.test(tree, args.test)
1016         except Error as e:
1017             if target is not None:
1018                 target.cleanup()
1019             raise
1020
1021         if target is not None:
1022             target.cleanup()
1023
1024     elif globals.command == 'shell':
1025         if args.target is None:
1026             raise Error('you must specify -t or --target')
1027
1028         target = target_factory(args.target, args.direct, args.debug, args.work)
1029         target.command('bash')
1030
1031     elif globals.command == 'revision':
1032
1033         target = SourceTarget()
1034         tree = globals.trees.get(args.project, args.checkout, target)
1035         with TreeDirectory(tree):
1036             print(command_and_read('git rev-parse HEAD').readline().strip()[:7])
1037         target.cleanup()
1038
1039     elif globals.command == 'checkout':
1040
1041         if args.output is None:
1042             raise Error('you must specify -o or --output')
1043
1044         target = SourceTarget()
1045         tree = globals.trees.get(args.project, args.checkout, target)
1046         with TreeDirectory(tree):
1047             shutil.copytree('.', args.output)
1048         target.cleanup()
1049
1050     else:
1051         raise Error('invalid command %s' % globals.command)
1052
1053 try:
1054     main()
1055 except Error as e:
1056     print('cdist: %s' % str(e), file=sys.stderr)
1057     sys.exit(1)