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