aaebbfffb13c23aace5a06b19a12c534ccc5bdd3
[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             if len(inspect.getargspec(tree.cscript['package']).args) == 3:
646                 packages = tree.call('package', tree.version, options)
647             else:
648                 log("Deprecated cscript package() method with no options parameter")
649                 packages = tree.call('package', tree.version)
650             for p in packages:
651                 copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
652
653 class SourceTarget(Target):
654     """Build a source .tar.bz2"""
655     def __init__(self):
656         super(SourceTarget, self).__init__('source')
657
658     def command(self, c):
659         log('host -> %s' % c)
660         command('%s %s' % (self.variables_string(), c))
661
662     def cleanup(self):
663         rmtree(self.directory)
664
665     def package(self, project, checkout, output_dir, options):
666         tree = globals.trees.get(project, checkout, self)
667         with TreeDirectory(tree):
668             name = read_wscript_variable(os.getcwd(), 'APPNAME')
669             command('./waf dist')
670             p = os.path.abspath('%s-%s.tar.bz2' % (name, tree.version))
671             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
672
673 # @param s Target string:
674 #       windows-{32,64}
675 #    or ubuntu-version-{32,64}
676 #    or debian-version-{32,64}
677 #    or centos-version-{32,64}
678 #    or fedora-version-{32,64}
679 #    or mageia-version-{32,64}
680 #    or osx-{32,64}
681 #    or source
682 # @param debug True to build with debugging symbols (where possible)
683 def target_factory(args):
684     s = args.target
685     target = None
686     if s.startswith('windows-'):
687         x = s.split('-')
688         if len(x) == 2:
689             target = WindowsTarget(None, int(x[1]), args.work)
690         elif len(x) == 3:
691             target = WindowsTarget(x[1], int(x[2]), args.work)
692         else:
693             raise Error("Bad Windows target name `%s'")
694     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-') or s.startswith('fedora-') or s.startswith('mageia-'):
695         p = s.split('-')
696         if len(p) != 3:
697             raise Error("Bad Linux target name `%s'; must be something like ubuntu-16.04-32 (i.e. distro-version-bits)" % s)
698         target = LinuxTarget(p[0], p[1], int(p[2]), args.work)
699     elif s.startswith('arch-'):
700         p = s.split('-')
701         if len(p) != 2:
702             raise Error("Bad Arch target name `%s'; must be arch-32 or arch-64")
703         target = LinuxTarget(p[0], None, int(p[1]), args.work)
704     elif s == 'raspbian':
705         target = LinuxTarget(s, None, None, args.work)
706     elif s.startswith('osx-'):
707         target = OSXSingleTarget(int(s.split('-')[1]), args.work)
708     elif s == 'osx':
709         if globals.command == 'build':
710             target = OSXSingleTarget(64, args.work)
711         else:
712             target = OSXUniversalTarget(args.work)
713     elif s == 'source':
714         target = SourceTarget()
715
716     if target is None:
717         raise Error("Bad target `%s'" % s)
718
719     target.debug = args.debug
720
721     if args.environment is not None:
722         for e in args.environment:
723             target.set(e, os.environ[e])
724
725     if args.mount is not None:
726         for m in args.mount:
727             target.mount(m)
728
729     target.setup()
730     return target
731
732
733 #
734 # Tree
735 #
736
737 class Tree(object):
738     """Description of a tree, which is a checkout of a project,
739        possibly built.  This class is never exposed to cscripts.
740        Attributes:
741            name -- name of git repository (without the .git)
742            specifier -- git tag or revision to use
743            target -- target object that we are using
744            version -- version from the wscript (if one is present)
745            git_commit -- git revision that is actually being used
746            built -- true if the tree has been built yet in this run
747            required_by -- name of the tree that requires this one
748     """
749
750     def __init__(self, name, specifier, target, required_by):
751         self.name = name
752         self.specifier = specifier
753         self.target = target
754         self.version = None
755         self.git_commit = None
756         self.built = False
757         self.required_by = required_by
758
759         cwd = os.getcwd()
760
761         flags = ''
762         redirect = ''
763         if globals.quiet:
764             flags = '-q'
765             redirect = '>/dev/null'
766         command('git clone %s %s/%s.git %s/src/%s' % (flags, config.get('git_prefix'), self.name, target.directory, self.name))
767         os.chdir('%s/src/%s' % (target.directory, self.name))
768
769         spec = self.specifier
770         if spec is None:
771             spec = 'master'
772
773         command('git checkout %s %s %s' % (flags, spec, redirect))
774         self.git_commit = command_and_read('git rev-parse --short=7 HEAD').readline().strip()
775         command('git submodule init --quiet')
776         command('git submodule update --quiet')
777
778         proj = '%s/src/%s' % (target.directory, self.name)
779
780         self.cscript = {}
781         exec(open('%s/cscript' % proj).read(), self.cscript)
782
783         if os.path.exists('%s/wscript' % proj):
784             v = read_wscript_variable(proj, "VERSION");
785             if v is not None:
786                 try:
787                     self.version = Version(v)
788                 except:
789                     self.version = Version(subprocess.Popen(shlex.split('git -C %s describe --tags --abbrev=0' % proj), stdout=subprocess.PIPE).communicate()[0][1:])
790
791         os.chdir(cwd)
792
793     def call(self, function, *args):
794         with TreeDirectory(self):
795             return self.cscript[function](self.target, *args)
796
797     def add_defaults(self, options):
798         """Add the defaults from this into a dict options"""
799         if 'option_defaults' in self.cscript:
800             for k, v in self.cscript['option_defaults']().items():
801                 if not k in options:
802                     options[k] = v
803
804     def build_dependencies(self, options):
805         if not 'dependencies' in self.cscript:
806             return
807
808         if len(inspect.getargspec(self.cscript['dependencies']).args) == 2:
809             deps = self.call('dependencies', options)
810         else:
811             log("Deprecated cscript dependencies() method with no options parameter")
812             deps = self.call('dependencies')
813
814         for d in deps:
815             dep = globals.trees.get(d[0], d[1], self.target, self.name)
816
817             # Start with the options passed in
818             dep_options = copy.copy(options)
819             # Add things specified by the parent
820             if len(d) > 2:
821                 for k, v in d[2].items():
822                     if not k in dep_options:
823                         dep_options[k] = v
824             # Then fill in the dependency's defaults
825             dep.add_defaults(dep_options)
826
827             msg = 'Building dependency %s %s of %s' % (d[0], d[1], self.name)
828             if len(dep_options) > 0:
829                 msg += ' with options %s' % dep_options
830             log(msg)
831
832             dep.build_dependencies(dep_options)
833             dep.build(dep_options)
834
835     def build(self, options):
836         if self.built:
837             return
838
839         variables = copy.copy(self.target.variables)
840
841         # Start with the options passed in
842         options = copy.copy(options)
843         # Fill in the defaults
844         self.add_defaults(options)
845
846         if not globals.dry_run:
847             if len(inspect.getargspec(self.cscript['build']).args) == 2:
848                 self.call('build', options)
849             else:
850                 self.call('build')
851
852         self.target.variables = variables
853         self.built = True
854
855 #
856 # Command-line parser
857 #
858
859 def main():
860
861     commands = {
862         "build": "build project",
863         "package": "package and build project",
864         "release": "release a project using its next version number (changing wscript and tagging)",
865         "pot": "build the project's .pot files",
866         "changelog": "generate a simple HTML changelog",
867         "manual": "build the project's manual",
868         "doxygen": "build the project's Doxygen documentation",
869         "latest": "print out the latest version",
870         "test": "run the project's unit tests",
871         "shell": "build the project then start a shell",
872         "checkout": "check out the project",
873         "revision": "print the head git revision number"
874     }
875
876     one_of = "Command is one of:\n"
877     summary = ""
878     for k, v in commands.items():
879         one_of += "\t%s\t%s\n" % (k, v)
880         summary += k + " "
881
882     parser = argparse.ArgumentParser()
883     parser.add_argument('command', help=summary)
884     parser.add_argument('-p', '--project', help='project name')
885     parser.add_argument('--minor', help='minor version number bump', action='store_true')
886     parser.add_argument('--micro', help='micro version number bump', action='store_true')
887     parser.add_argument('--latest-major', help='major version to return with latest', type=int)
888     parser.add_argument('--latest-minor', help='minor version to return with latest', type=int)
889     parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
890     parser.add_argument('-o', '--output', help='output directory', default='.')
891     parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
892     parser.add_argument('-t', '--target', help='target')
893     parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
894     parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
895     parser.add_argument('-w', '--work', help='override default work directory')
896     parser.add_argument('-g', '--git-prefix', help='override configured git prefix')
897     parser.add_argument('--test', help="name of test to run (with `test'), defaults to all")
898     parser.add_argument('-n', '--dry-run', help='run the process without building anything', action='store_true')
899     parser.add_argument('-e', '--environment', help='pass the value of the named environment variable into the build', action='append')
900     parser.add_argument('-m', '--mount', help='mount a given directory in the build environment', action='append')
901     parser.add_argument('--no-version-commit', help="use just tags for versioning, don't modify wscript, ChangeLog etc.", action='store_true')
902     parser.add_argument('--option', help='set an option for the build (use --option key:value)', action='append')
903     args = parser.parse_args()
904
905     # Override configured stuff
906     if args.git_prefix is not None:
907         config.set('git_prefix', args.git_prefix)
908
909     if args.output.find(':') == -1:
910         # This isn't of the form host:path so make it absolute
911         args.output = os.path.abspath(args.output) + '/'
912     else:
913         if args.output[-1] != ':' and args.output[-1] != '/':
914             args.output += '/'
915
916     # Now, args.output is 'host:', 'host:path/' or 'path/'
917
918     if args.work is not None:
919         args.work = os.path.abspath(args.work)
920
921     if args.project is None and args.command != 'shell':
922         raise Error('you must specify -p or --project')
923
924     globals.quiet = args.quiet
925     globals.command = args.command
926     globals.dry_run = args.dry_run
927
928     if not globals.command in commands:
929         e = 'command must be one of:\n' + one_of
930         raise Error('command must be one of:\n%s' % one_of)
931
932     if globals.command == 'build':
933         if args.target is None:
934             raise Error('you must specify -t or --target')
935
936         target = target_factory(args)
937         target.build(args.project, args.checkout, argument_options(args))
938         if not args.keep:
939             target.cleanup()
940
941     elif globals.command == 'package':
942         if args.target is None:
943             raise Error('you must specify -t or --target')
944
945         target = target_factory(args)
946
947         if target.platform == 'linux':
948             if target.distro != 'arch':
949                 output_dir = os.path.join(args.output, '%s-%s-%d' % (target.distro, target.version, target.bits))
950             else:
951                 output_dir = os.path.join(args.output, '%s-%d' % (target.distro, target.bits))
952         else:
953             output_dir = args.output
954
955         makedirs(output_dir)
956         target.package(args.project, args.checkout, output_dir, argument_options(args))
957
958         if not args.keep:
959             target.cleanup()
960
961     elif globals.command == 'release':
962         if args.minor is False and args.micro is False:
963             raise Error('you must specify --minor or --micro')
964
965         target = SourceTarget()
966         tree = globals.trees.get(args.project, args.checkout, target)
967
968         version = tree.version
969         version.to_release()
970         if args.minor:
971             version.bump_minor()
972         else:
973             version.bump_micro()
974
975         with TreeDirectory(tree):
976             if not args.no_version_commit:
977                 set_version_in_wscript(version)
978                 append_version_to_changelog(version)
979                 append_version_to_debian_changelog(version)
980                 command('git commit -a -m "Bump version"')
981
982             command('git tag -m "v%s" v%s' % (version, version))
983
984             if not args.no_version_commit:
985                 version.to_devel()
986                 set_version_in_wscript(version)
987                 command('git commit -a -m "Bump version"')
988                 command('git push')
989
990             command('git push --tags')
991
992         target.cleanup()
993
994     elif globals.command == 'pot':
995         target = SourceTarget()
996         tree = globals.trees.get(args.project, args.checkout, target)
997
998         pots = tree.call('make_pot')
999         for p in pots:
1000             copyfile(p, '%s%s' % (args.output, os.path.basename(p)))
1001
1002         target.cleanup()
1003
1004     elif globals.command == 'changelog':
1005         target = SourceTarget()
1006         tree = globals.trees.get(args.project, args.checkout, target)
1007
1008         with TreeDirectory(tree):
1009             text = open('ChangeLog', 'r')
1010
1011         html = tempfile.NamedTemporaryFile()
1012         versions = 8
1013
1014         last = None
1015         changes = []
1016
1017         while True:
1018             l = text.readline()
1019             if l == '':
1020                 break
1021
1022             if len(l) > 0 and l[0] == "\t":
1023                 s = l.split()
1024                 if len(s) == 4 and s[1] == "Version" and s[3] == "released.":
1025                     v = Version(s[2])
1026                     if v.micro == 0:
1027                         if last is not None and len(changes) > 0:
1028                             print("<h2>Changes between version %s and %s</h2>" % (s[2], last), file=html)
1029                             print("<ul>", file=html)
1030                             for c in changes:
1031                                 print("<li>%s" % c, file=html)
1032                             print("</ul>", file=html)
1033                         last = s[2]
1034                         changes = []
1035                         versions -= 1
1036                         if versions < 0:
1037                             break
1038                 else:
1039                     c = l.strip()
1040                     if len(c) > 0:
1041                         if c[0] == '*':
1042                             changes.append(c[2:])
1043                         else:
1044                             changes[-1] += " " + c
1045
1046         copyfile(html.file, '%schangelog.html' % args.output)
1047         html.close()
1048         target.cleanup()
1049
1050     elif globals.command == 'manual':
1051         target = SourceTarget()
1052         tree = globals.trees.get(args.project, args.checkout, target)
1053
1054         outs = tree.call('make_manual')
1055         for o in outs:
1056             if os.path.isfile(o):
1057                 copyfile(o, '%s%s' % (args.output, os.path.basename(o)))
1058             else:
1059                 copytree(o, '%s%s' % (args.output, os.path.basename(o)))
1060
1061         target.cleanup()
1062
1063     elif globals.command == 'doxygen':
1064         target = SourceTarget()
1065         tree = globals.trees.get(args.project, args.checkout, target)
1066
1067         dirs = tree.call('make_doxygen')
1068         if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
1069             dirs = [dirs]
1070
1071         for d in dirs:
1072             copytree(d, args.output)
1073
1074         target.cleanup()
1075
1076     elif globals.command == 'latest':
1077         target = SourceTarget()
1078         tree = globals.trees.get(args.project, args.checkout, target)
1079
1080         with TreeDirectory(tree):
1081             f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
1082             latest = None
1083             while latest is None:
1084                 t = f.readline()
1085                 m = re.compile(".*\((.*)\).*").match(t)
1086                 if m:
1087                     tags = m.group(1).split(', ')
1088                     for t in tags:
1089                         s = t.split()
1090                         if len(s) > 1:
1091                             t = s[1]
1092                         if len(t) > 0 and t[0] == 'v':
1093                             v = Version(t[1:])
1094                             if (args.latest_major is None or v.major == args.latest_major) and (args.latest_minor is None or v.minor == args.latest_minor):
1095                                 latest = v
1096
1097         print(latest)
1098         target.cleanup()
1099
1100     elif globals.command == 'test':
1101         if args.target is None:
1102             raise Error('you must specify -t or --target')
1103
1104         target = None
1105         try:
1106             target = target_factory(args)
1107             tree = globals.trees.get(args.project, args.checkout, target)
1108             with TreeDirectory(tree):
1109                 target.test(tree, args.test, argument_options(args))
1110         except Error as e:
1111             if target is not None and not args.keep:
1112                 target.cleanup()
1113             raise
1114
1115         if target is not None and not args.keep:
1116             target.cleanup()
1117
1118     elif globals.command == 'shell':
1119         if args.target is None:
1120             raise Error('you must specify -t or --target')
1121
1122         target = target_factory(args)
1123         target.command('bash')
1124
1125     elif globals.command == 'revision':
1126
1127         target = SourceTarget()
1128         tree = globals.trees.get(args.project, args.checkout, target)
1129         with TreeDirectory(tree):
1130             print(command_and_read('git rev-parse HEAD').readline().strip()[:7])
1131         target.cleanup()
1132
1133     elif globals.command == 'checkout':
1134
1135         if args.output is None:
1136             raise Error('you must specify -o or --output')
1137
1138         target = SourceTarget()
1139         tree = globals.trees.get(args.project, args.checkout, target)
1140         with TreeDirectory(tree):
1141             shutil.copytree('.', args.output)
1142         target.cleanup()
1143
1144     else:
1145         raise Error('invalid command %s' % globals.command)
1146
1147 try:
1148     main()
1149 except Error as e:
1150     print('cdist: %s' % str(e), file=sys.stderr)
1151     sys.exit(1)