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