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