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