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