Optionally pass options into test().
[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 = os.path.realpath(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
484         tree.add_defaults(options)
485         if len(inspect.getfullargspec(tree.cscript['test']).args) == 3:
486             return tree.call('test', options, test)
487         else:
488             log_normal('Deprecated cscript test() method with no options parameter')
489             return tree.call('test', test)
490
491     def set(self, a, b):
492         self.variables[a] = b
493
494     def unset(self, a):
495         del(self.variables[a])
496
497     def get(self, a):
498         return self.variables[a]
499
500     def append(self, k, v, s):
501         if (not k in self.variables) or len(self.variables[k]) == 0:
502             self.variables[k] = '"%s"' % v
503         else:
504             e = self.variables[k]
505             if e[0] == '"' and e[-1] == '"':
506                 self.variables[k] = '"%s%s%s"' % (e[1:-1], s, v)
507             else:
508                 self.variables[k] = '"%s%s%s"' % (e, s, v)
509
510     def append_with_space(self, k, v):
511         return self.append(k, v, ' ')
512
513     def append_with_colon(self, k, v):
514         return self.append(k, v, ':')
515
516     def variables_string(self, escaped_quotes=False):
517         e = ''
518         for k, v in self.variables.items():
519             if escaped_quotes:
520                 v = v.replace('"', '\\"')
521             e += '%s=%s ' % (k, v)
522         return e
523
524     def cleanup(self):
525         if self.rmdir:
526             rmtree(self.directory)
527
528     def mount(self, m):
529         pass
530
531     @property
532     def ccache(self):
533         return self._ccache
534
535     @ccache.setter
536     def ccache(self, v):
537         self._ccache = v
538
539
540 class DockerTarget(Target):
541     def __init__(self, platform, directory):
542         super(DockerTarget, self).__init__(platform, directory)
543         self.mounts = []
544         self.privileged = False
545
546     def _user_tag(self):
547         if config.get('docker_no_user'):
548             return ''
549         return '-u %s' % getpass.getuser()
550
551     def _mount_option(self, d):
552         return '-v %s:%s ' % (os.path.realpath(d), os.path.realpath(d))
553
554     def setup(self):
555         opts = self._mount_option(self.directory)
556         for m in self.mounts:
557             opts += self._mount_option(m)
558         if config.has('git_reference'):
559             opts += self._mount_option(config.get('git_reference'))
560         if self.privileged:
561             opts += '--privileged=true '
562         if self.ccache:
563             opts += "-e CCACHE_DIR=/ccache/%s-%d --mount source=ccache,target=/ccache" % (self.image, os.getuid())
564
565         tag = self.image
566         if config.has('docker_hub_repository'):
567             tag = '%s:%s' % (config.get('docker_hub_repository'), tag)
568
569         self.container = command_and_read('%s run %s %s -itd %s /bin/bash' % (config.docker(), self._user_tag(), opts, tag))[0].strip()
570
571     def command(self, cmd):
572         dir = os.path.join(self.directory, os.path.relpath(os.getcwd(), self.directory))
573         interactive_flag = '-i ' if sys.stdin.isatty() else ''
574         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))
575
576     def cleanup(self):
577         super(DockerTarget, self).cleanup()
578         command('%s kill %s' % (config.docker(), self.container))
579
580     def mount(self, m):
581         self.mounts.append(m)
582
583
584 class FlatpakTarget(Target):
585     def __init__(self, project, checkout):
586         super(FlatpakTarget, self).__init__('flatpak')
587         self.build_dependencies = False
588         self.project = project
589         self.checkout = checkout
590
591     def setup(self):
592         pass
593
594     def command(self, cmd):
595         command(cmd)
596
597     def checkout_dependencies(self):
598         tree = globals.trees.get(self.project, self.checkout, self)
599         return tree.checkout_dependencies()
600
601     def flatpak(self):
602         return 'flatpak'
603
604     def flatpak_builder(self):
605         b = 'flatpak-builder'
606         if config.has('flatpak_state_dir'):
607             b += ' --state-dir=%s' % config.get('flatpak_state_dir')
608         return b
609
610
611 class WindowsTarget(DockerTarget):
612     """
613     This target exposes the following additional API:
614
615     version: Windows version ('xp' or None)
616     bits: bitness of Windows (32 or 64)
617     name: name of our target e.g. x86_64-w64-mingw32.shared
618     environment_prefix: path to Windows environment for the appropriate target (libraries and some tools)
619     tool_path: path to 32- and 64-bit tools
620     """
621     def __init__(self, windows_version, bits, directory, environment_version):
622         super(WindowsTarget, self).__init__('windows', directory)
623         self.version = windows_version
624         self.bits = bits
625
626         self.tool_path = '%s/usr/bin' % config.get('mxe_prefix')
627         if self.bits == 32:
628             self.name = 'i686-w64-mingw32.shared'
629         else:
630             self.name = 'x86_64-w64-mingw32.shared'
631         self.environment_prefix = '%s/usr/%s' % (config.get('mxe_prefix'), self.name)
632
633         self.set('PKG_CONFIG_LIBDIR', '%s/lib/pkgconfig' % self.environment_prefix)
634         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/bin/pkgconfig' % (self.directory, self.directory))
635         self.set('PATH', '%s/bin:%s:%s' % (self.environment_prefix, self.tool_path, os.environ['PATH']))
636         self.set('LD', '%s-ld' % self.name)
637         self.set('RANLIB', '%s-ranlib' % self.name)
638         self.set('WINRC', '%s-windres' % self.name)
639         cxx = '-I%s/include -I%s/include' % (self.environment_prefix, self.directory)
640         link = '-L%s/lib -L%s/lib' % (self.environment_prefix, self.directory)
641         self.set('CXXFLAGS', '"%s"' % cxx)
642         self.set('CPPFLAGS', '')
643         self.set('LINKFLAGS', '"%s"' % link)
644         self.set('LDFLAGS', '"%s"' % link)
645
646         self.image = 'windows'
647         if environment_version is not None:
648             self.image += '_%s' % environment_version
649
650     def setup(self):
651         super().setup()
652         if self.ccache:
653             self.set('CC', '"ccache %s-gcc"' % self.name)
654             self.set('CXX', '"ccache %s-g++"' % self.name)
655         else:
656             self.set('CC', '%s-gcc' % self.name)
657             self.set('CXX', '%s-g++' % self.name)
658
659     @property
660     def library_prefix(self):
661         log_normal('Deprecated property library_prefix: use environment_prefix')
662         return self.environment_prefix
663
664     @property
665     def windows_prefix(self):
666         log_normal('Deprecated property windows_prefix: use environment_prefix')
667         return self.environment_prefix
668
669     @property
670     def mingw_prefixes(self):
671         log_normal('Deprecated property mingw_prefixes: use environment_prefix')
672         return [self.environment_prefix]
673
674     @property
675     def mingw_path(self):
676         log_normal('Deprecated property mingw_path: use tool_path')
677         return self.tool_path
678
679     @property
680     def mingw_name(self):
681         log_normal('Deprecated property mingw_name: use name')
682         return self.name
683
684
685 class LinuxTarget(DockerTarget):
686     """
687     Build for Linux in a docker container.
688     This target exposes the following additional API:
689
690     distro: distribution ('debian', 'ubuntu', 'centos' or 'fedora')
691     version: distribution version (e.g. '12.04', '8', '6.5')
692     bits: bitness of the distribution (32 or 64)
693     detail: None or 'appimage' if we are building for appimage
694     """
695
696     def __init__(self, distro, version, bits, directory=None):
697         super(LinuxTarget, self).__init__('linux', directory)
698         self.distro = distro
699         self.version = version
700         self.bits = bits
701         self.detail = None
702
703         self.set('CXXFLAGS', '-I%s/include' % self.directory)
704         self.set('CPPFLAGS', '')
705         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
706         self.set('PKG_CONFIG_PATH',
707                  '%s/lib/pkgconfig:%s/lib64/pkgconfig:/usr/local/lib64/pkgconfig:/usr/local/lib/pkgconfig' % (self.directory, self.directory))
708         self.set('PATH', '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin')
709
710         if self.version is None:
711             self.image = '%s-%s' % (self.distro, self.bits)
712         else:
713             self.image = '%s-%s-%s' % (self.distro, self.version, self.bits)
714
715     def setup(self):
716         super(LinuxTarget, self).setup()
717         if self.ccache:
718             self.set('CC', '"ccache gcc"')
719             self.set('CXX', '"ccache g++"')
720
721     def test(self, tree, test, options):
722         self.append_with_colon('PATH', '%s/bin' % self.directory)
723         self.append_with_colon('LD_LIBRARY_PATH', '%s/lib' % self.directory)
724         super(LinuxTarget, self).test(tree, test, options)
725
726
727 class AppImageTarget(LinuxTarget):
728     def __init__(self, work):
729         super(AppImageTarget, self).__init__('ubuntu', '18.04', 64, work)
730         self.detail = 'appimage'
731         self.privileged = True
732
733
734 class OSXTarget(Target):
735     def __init__(self, directory=None):
736         super(OSXTarget, self).__init__('osx', directory)
737         self.sdk = config.get('osx_sdk')
738         self.sdk_prefix = config.get('osx_sdk_prefix')
739         self.environment_prefix = config.get('osx_environment_prefix')
740         self.apple_id = config.get('apple_id')
741         self.apple_password = config.get('apple_password')
742
743     def command(self, c):
744         command('%s %s' % (self.variables_string(False), c))
745
746
747 class OSXSingleTarget(OSXTarget):
748     def __init__(self, bits, directory=None):
749         super(OSXSingleTarget, self).__init__(directory)
750         self.bits = bits
751
752         if bits == 32:
753             arch = 'i386'
754         else:
755             arch = 'x86_64'
756
757         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (self.sdk_prefix, self.sdk, arch)
758         enviro = '%s/%d' % (config.get('osx_environment_prefix'), bits)
759
760         # Environment variables
761         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
762         self.set('CPPFLAGS', '')
763         self.set('CXXFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
764         self.set('LDFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
765         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
766         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, enviro))
767         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % enviro)
768         self.set('MACOSX_DEPLOYMENT_TARGET', config.get('osx_sdk'))
769         self.set('CCACHE_BASEDIR', self.directory)
770
771     @Target.ccache.setter
772     def ccache(self, v):
773         Target.ccache.fset(self, v)
774         if v:
775             self.set('CC', '"ccache gcc"')
776             self.set('CXX', '"ccache g++"')
777
778
779 class OSXUniversalTarget(OSXTarget):
780     def __init__(self, directory=None):
781         super(OSXUniversalTarget, self).__init__(directory)
782         self.bits = None
783
784     def package(self, project, checkout, output_dir, options):
785
786         for b in [32, 64]:
787             target = OSXSingleTarget(b, os.path.join(self.directory, '%d' % b))
788             target.ccache = self.ccache
789             tree = globals.trees.get(project, checkout, target)
790             tree.build_dependencies(options)
791             tree.build(options)
792
793         tree = globals.trees.get(project, checkout, self)
794         with TreeDirectory(tree):
795             if len(inspect.getfullargspec(tree.cscript['package']).args) == 3:
796                 packages = tree.call('package', tree.version, options)
797             else:
798                 log_normal("Deprecated cscript package() method with no options parameter")
799                 packages = tree.call('package', tree.version)
800             for p in packages:
801                 copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
802
803 class SourceTarget(Target):
804     """Build a source .tar.bz2"""
805     def __init__(self):
806         super(SourceTarget, self).__init__('source')
807
808     def command(self, c):
809         log_normal('host -> %s' % c)
810         command('%s %s' % (self.variables_string(), c))
811
812     def cleanup(self):
813         rmtree(self.directory)
814
815     def package(self, project, checkout, output_dir, options):
816         tree = globals.trees.get(project, checkout, self)
817         with TreeDirectory(tree):
818             name = read_wscript_variable(os.getcwd(), 'APPNAME')
819             command('./waf dist')
820             p = os.path.abspath('%s-%s.tar.bz2' % (name, tree.version))
821             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
822
823 # @param s Target string:
824 #       windows-{32,64}
825 #    or ubuntu-version-{32,64}
826 #    or debian-version-{32,64}
827 #    or centos-version-{32,64}
828 #    or fedora-version-{32,64}
829 #    or mageia-version-{32,64}
830 #    or osx-{32,64}
831 #    or source
832 #    or flatpak
833 #    or appimage
834 # @param debug True to build with debugging symbols (where possible)
835 def target_factory(args):
836     s = args.target
837     target = None
838     if s.startswith('windows-'):
839         x = s.split('-')
840         if len(x) == 2:
841             target = WindowsTarget(None, int(x[1]), args.work, args.environment_version)
842         elif len(x) == 3:
843             target = WindowsTarget(x[1], int(x[2]), args.work, args.environment_version)
844         else:
845             raise Error("Bad Windows target name `%s'")
846     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-') or s.startswith('fedora-') or s.startswith('mageia-'):
847         p = s.split('-')
848         if len(p) != 3:
849             raise Error("Bad Linux target name `%s'; must be something like ubuntu-16.04-32 (i.e. distro-version-bits)" % s)
850         target = LinuxTarget(p[0], p[1], int(p[2]), args.work)
851     elif s.startswith('arch-'):
852         p = s.split('-')
853         if len(p) != 2:
854             raise Error("Bad Arch target name `%s'; must be arch-32 or arch-64")
855         target = LinuxTarget(p[0], None, int(p[1]), args.work)
856     elif s == 'raspbian':
857         target = LinuxTarget(s, None, None, args.work)
858     elif s.startswith('osx-'):
859         target = OSXSingleTarget(int(s.split('-')[1]), args.work)
860     elif s == 'osx':
861         if globals.command == 'build':
862             target = OSXSingleTarget(64, args.work)
863         else:
864             target = OSXUniversalTarget(args.work)
865     elif s == 'source':
866         target = SourceTarget()
867     elif s == 'flatpak':
868         target = FlatpakTarget(args.project, args.checkout)
869     elif s == 'appimage':
870         target = AppImageTarget(args.work)
871
872     if target is None:
873         raise Error("Bad target `%s'" % s)
874
875     target.debug = args.debug
876     target.ccache = args.ccache
877
878     if args.environment is not None:
879         for e in args.environment:
880             target.set(e, os.environ[e])
881
882     if args.mount is not None:
883         for m in args.mount:
884             target.mount(m)
885
886     target.setup()
887     return target
888
889
890 #
891 # Tree
892 #
893
894 class Tree(object):
895     """Description of a tree, which is a checkout of a project,
896        possibly built.  This class is never exposed to cscripts.
897        Attributes:
898            name -- name of git repository (without the .git)
899            specifier -- git tag or revision to use
900            target -- target object that we are using
901            version -- version from the wscript (if one is present)
902            git_commit -- git revision that is actually being used
903            built -- true if the tree has been built yet in this run
904            required_by -- name of the tree that requires this one
905     """
906
907     def __init__(self, name, specifier, target, required_by):
908         self.name = name
909         self.specifier = specifier
910         self.target = target
911         self.version = None
912         self.git_commit = None
913         self.built = False
914         self.required_by = required_by
915
916         cwd = os.getcwd()
917
918         flags = ''
919         redirect = ''
920         if globals.quiet:
921             flags = '-q'
922             redirect = '>/dev/null'
923         if config.has('git_reference'):
924             ref = '--reference-if-able %s/%s.git' % (config.get('git_reference'), self.name)
925         else:
926             ref = ''
927         command('git clone %s %s %s/%s.git %s/src/%s' % (flags, ref, config.get('git_prefix'), self.name, target.directory, self.name))
928         os.chdir('%s/src/%s' % (target.directory, self.name))
929
930         spec = self.specifier
931         if spec is None:
932             spec = 'master'
933
934         command('git checkout %s %s %s' % (flags, spec, redirect))
935         self.git_commit = command_and_read('git rev-parse --short=7 HEAD')[0].strip()
936
937         proj = '%s/src/%s' % (target.directory, self.name)
938
939         self.cscript = {}
940         exec(open('%s/cscript' % proj).read(), self.cscript)
941
942         # cscript can include submodules = False to stop submodules being fetched
943         if (not 'submodules' in self.cscript or self.cscript['submodules'] == True) and os.path.exists('.gitmodules'):
944             command('git submodule --quiet init')
945             paths = command_and_read('git config --file .gitmodules --get-regexp path')
946             urls = command_and_read('git config --file .gitmodules --get-regexp url')
947             for path, url in zip(paths, urls):
948                 path = path.split(' ')[1]
949                 url = url.split(' ')[1]
950             ref = ''
951             if config.has('git_reference'):
952                 ref_path = os.path.join(config.get('git_reference'), os.path.basename(url))
953                 if os.path.exists(ref_path):
954                     ref = '--reference %s' % ref_path
955             command('git submodule --quiet update %s %s' % (ref, path))
956
957         if os.path.exists('%s/wscript' % proj):
958             v = read_wscript_variable(proj, "VERSION");
959             if v is not None:
960                 try:
961                     self.version = Version(v)
962                 except:
963                     tag = command_and_read('git -C %s describe --tags' % proj)[0][1:]
964                     self.version = Version.from_git_tag(tag)
965
966         os.chdir(cwd)
967
968     def call(self, function, *args):
969         with TreeDirectory(self):
970             return self.cscript[function](self.target, *args)
971
972     def add_defaults(self, options):
973         """Add the defaults from self into a dict options"""
974         if 'option_defaults' in self.cscript:
975             from_cscript = self.cscript['option_defaults']
976             if isinstance(from_cscript, dict):
977                 defaults_dict = from_cscript
978             else:
979                 log_normal("Deprecated cscript option_defaults method; replace with a dict")
980                 defaults_dict = from_cscript()
981             for k, v in defaults_dict.items():
982                 if not k in options:
983                     options[k] = v
984
985     def dependencies(self, options):
986         """
987         yield details of the dependencies of this tree.  Each dependency is returned
988         as a tuple of (tree, options).  The 'options' parameter are the options that
989         we want to force for 'self'.
990         """
991         if not 'dependencies' in self.cscript:
992             return
993
994         if len(inspect.getfullargspec(self.cscript['dependencies']).args) == 2:
995             self_options = copy.copy(options)
996             self.add_defaults(self_options)
997             deps = self.call('dependencies', self_options)
998         else:
999             log_normal("Deprecated cscript dependencies() method with no options parameter")
1000             deps = self.call('dependencies')
1001
1002         # Loop over our immediate dependencies
1003         for d in deps:
1004             dep = globals.trees.get(d[0], d[1], self.target, self.name)
1005
1006             # deps only get their options from the parent's cscript
1007             dep_options = d[2] if len(d) > 2 else {}
1008             for i in dep.dependencies(dep_options):
1009                 yield i
1010             yield (dep, dep_options)
1011
1012     def checkout_dependencies(self, options={}):
1013         for i in self.dependencies(options):
1014             pass
1015
1016     def build_dependencies(self, options):
1017         """
1018         Called on the 'main' project tree (-p on the command line) to build all dependencies.
1019         'options' will be the ones from the command line.
1020         """
1021         for i in self.dependencies(options):
1022             i[0].build(i[1])
1023
1024     def build(self, options):
1025         if self.built:
1026             return
1027
1028         log_verbose("Building %s %s %s with %s" % (self.name, self.specifier, self.version, options))
1029
1030         variables = copy.copy(self.target.variables)
1031
1032         options = copy.copy(options)
1033         self.add_defaults(options)
1034
1035         if not globals.dry_run:
1036             if len(inspect.getfullargspec(self.cscript['build']).args) == 2:
1037                 self.call('build', options)
1038             else:
1039                 self.call('build')
1040
1041         self.target.variables = variables
1042         self.built = True
1043
1044 #
1045 # Command-line parser
1046 #
1047
1048 def main():
1049
1050     commands = {
1051         "build": "build project",
1052         "package": "package and build project",
1053         "release": "release a project using its next version number (changing wscript and tagging)",
1054         "pot": "build the project's .pot files",
1055         "manual": "build the project's manual",
1056         "doxygen": "build the project's Doxygen documentation",
1057         "latest": "print out the latest version",
1058         "test": "run the project's unit tests",
1059         "shell": "build the project then start a shell",
1060         "checkout": "check out the project",
1061         "revision": "print the head git revision number"
1062     }
1063
1064     one_of = "Command is one of:\n"
1065     summary = ""
1066     for k, v in commands.items():
1067         one_of += "\t%s\t%s\n" % (k, v)
1068         summary += k + " "
1069
1070     parser = argparse.ArgumentParser()
1071     parser.add_argument('command', help=summary)
1072     parser.add_argument('-p', '--project', help='project name')
1073     parser.add_argument('--minor', help='minor version number bump', action='store_true')
1074     parser.add_argument('--micro', help='micro version number bump', action='store_true')
1075     parser.add_argument('--latest-major', help='major version to return with latest', type=int)
1076     parser.add_argument('--latest-minor', help='minor version to return with latest', type=int)
1077     parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
1078     parser.add_argument('-o', '--output', help='output directory', default='.')
1079     parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
1080     parser.add_argument('-t', '--target', help='target', action='append')
1081     parser.add_argument('--environment-version', help='version of environment to use')
1082     parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
1083     parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
1084     parser.add_argument('-w', '--work', help='override default work directory')
1085     parser.add_argument('-g', '--git-prefix', help='override configured git prefix')
1086     parser.add_argument('--test', help="name of test to run (with `test'), defaults to all")
1087     parser.add_argument('-n', '--dry-run', help='run the process without building anything', action='store_true')
1088     parser.add_argument('-e', '--environment', help='pass the value of the named environment variable into the build', action='append')
1089     parser.add_argument('-m', '--mount', help='mount a given directory in the build environment', action='append')
1090     parser.add_argument('--no-version-commit', help="use just tags for versioning, don't modify wscript, ChangeLog etc.", action='store_true')
1091     parser.add_argument('--option', help='set an option for the build (use --option key:value)', action='append')
1092     parser.add_argument('--ccache', help='use ccache', action='store_true')
1093     parser.add_argument('--verbose', help='be verbose', action='store_true')
1094     global args
1095     args = parser.parse_args()
1096
1097     # Check for incorrect multiple parameters
1098     if args.target is not None:
1099         if len(args.target) > 1:
1100             parser.error('multiple -t options specified')
1101             sys.exit(1)
1102         else:
1103             args.target = args.target[0]
1104
1105     # Override configured stuff
1106     if args.git_prefix is not None:
1107         config.set('git_prefix', args.git_prefix)
1108
1109     if args.output.find(':') == -1:
1110         # This isn't of the form host:path so make it absolute
1111         args.output = os.path.abspath(args.output) + '/'
1112     else:
1113         if args.output[-1] != ':' and args.output[-1] != '/':
1114             args.output += '/'
1115
1116     # Now, args.output is 'host:', 'host:path/' or 'path/'
1117
1118     if args.work is not None:
1119         args.work = os.path.abspath(args.work)
1120         if not os.path.exists(args.work):
1121             os.makedirs(args.work)
1122
1123     if args.project is None and args.command != 'shell':
1124         raise Error('you must specify -p or --project')
1125
1126     globals.quiet = args.quiet
1127     globals.verbose = args.verbose
1128     globals.command = args.command
1129     globals.dry_run = args.dry_run
1130
1131     if not globals.command in commands:
1132         e = 'command must be one of:\n' + one_of
1133         raise Error('command must be one of:\n%s' % one_of)
1134
1135     if globals.command == 'build':
1136         if args.target is None:
1137             raise Error('you must specify -t or --target')
1138
1139         target = target_factory(args)
1140         target.build(args.project, args.checkout, get_command_line_options(args))
1141         if not args.keep:
1142             target.cleanup()
1143
1144     elif globals.command == 'package':
1145         if args.target is None:
1146             raise Error('you must specify -t or --target')
1147
1148         target = None
1149         try:
1150             target = target_factory(args)
1151
1152             if target.platform == 'linux' and target.detail != "appimage":
1153                 if target.distro != 'arch':
1154                     output_dir = os.path.join(args.output, '%s-%s-%d' % (target.distro, target.version, target.bits))
1155                 else:
1156                     output_dir = os.path.join(args.output, '%s-%d' % (target.distro, target.bits))
1157             else:
1158                 output_dir = args.output
1159
1160             makedirs(output_dir)
1161             target.package(args.project, args.checkout, output_dir, get_command_line_options(args))
1162         except Error as e:
1163             if target is not None and not args.keep:
1164                 target.cleanup()
1165             raise
1166
1167         if target is not None and not args.keep:
1168             target.cleanup()
1169
1170     elif globals.command == 'release':
1171         if args.minor is False and args.micro is False:
1172             raise Error('you must specify --minor or --micro')
1173
1174         target = SourceTarget()
1175         tree = globals.trees.get(args.project, args.checkout, target)
1176
1177         version = tree.version
1178         version.to_release()
1179         if args.minor:
1180             version.bump_minor()
1181         else:
1182             version.bump_micro()
1183
1184         with TreeDirectory(tree):
1185             if not args.no_version_commit:
1186                 set_version_in_wscript(version)
1187                 append_version_to_changelog(version)
1188                 append_version_to_debian_changelog(version)
1189                 command('git commit -a -m "Bump version"')
1190
1191             command('git tag -m "v%s" v%s' % (version, version))
1192
1193             if not args.no_version_commit:
1194                 version.to_devel()
1195                 set_version_in_wscript(version)
1196                 command('git commit -a -m "Bump version"')
1197                 command('git push')
1198
1199             command('git push --tags')
1200
1201         target.cleanup()
1202
1203     elif globals.command == 'pot':
1204         target = SourceTarget()
1205         tree = globals.trees.get(args.project, args.checkout, target)
1206
1207         pots = tree.call('make_pot')
1208         for p in pots:
1209             copyfile(p, '%s%s' % (args.output, os.path.basename(p)))
1210
1211         target.cleanup()
1212
1213     elif globals.command == 'manual':
1214         target = SourceTarget()
1215         tree = globals.trees.get(args.project, args.checkout, target)
1216
1217         outs = tree.call('make_manual')
1218         for o in outs:
1219             if os.path.isfile(o):
1220                 copyfile(o, '%s%s' % (args.output, os.path.basename(o)))
1221             else:
1222                 copytree(o, '%s%s' % (args.output, os.path.basename(o)))
1223
1224         target.cleanup()
1225
1226     elif globals.command == 'doxygen':
1227         target = SourceTarget()
1228         tree = globals.trees.get(args.project, args.checkout, target)
1229
1230         dirs = tree.call('make_doxygen')
1231         if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
1232             dirs = [dirs]
1233
1234         for d in dirs:
1235             copytree(d, args.output)
1236
1237         target.cleanup()
1238
1239     elif globals.command == 'latest':
1240         target = SourceTarget()
1241         tree = globals.trees.get(args.project, args.checkout, target)
1242
1243         with TreeDirectory(tree):
1244             f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
1245             latest = None
1246             line = 0
1247             while latest is None:
1248                 t = f[line]
1249                 line += 1
1250                 m = re.compile(".*\((.*)\).*").match(t)
1251                 if m:
1252                     tags = m.group(1).split(', ')
1253                     for t in tags:
1254                         s = t.split()
1255                         if len(s) > 1:
1256                             t = s[1]
1257                         if len(t) > 0 and t[0] == 'v':
1258                             v = Version(t[1:])
1259                             if (args.latest_major is None or v.major == args.latest_major) and (args.latest_minor is None or v.minor == args.latest_minor):
1260                                 latest = v
1261
1262         print(latest)
1263         target.cleanup()
1264
1265     elif globals.command == 'test':
1266         if args.target is None:
1267             raise Error('you must specify -t or --target')
1268
1269         target = None
1270         try:
1271             target = target_factory(args)
1272             tree = globals.trees.get(args.project, args.checkout, target)
1273             with TreeDirectory(tree):
1274                 target.test(tree, args.test, get_command_line_options(args))
1275         except Error as e:
1276             if target is not None and not args.keep:
1277                 target.cleanup()
1278             raise
1279
1280         if target is not None and not args.keep:
1281             target.cleanup()
1282
1283     elif globals.command == 'shell':
1284         if args.target is None:
1285             raise Error('you must specify -t or --target')
1286
1287         target = target_factory(args)
1288         target.command('bash')
1289
1290     elif globals.command == 'revision':
1291
1292         target = SourceTarget()
1293         tree = globals.trees.get(args.project, args.checkout, target)
1294         with TreeDirectory(tree):
1295             print(command_and_read('git rev-parse HEAD')[0].strip()[:7])
1296         target.cleanup()
1297
1298     elif globals.command == 'checkout':
1299
1300         if args.output is None:
1301             raise Error('you must specify -o or --output')
1302
1303         target = SourceTarget()
1304         tree = globals.trees.get(args.project, args.checkout, target)
1305         with TreeDirectory(tree):
1306             shutil.copytree('.', args.output)
1307         target.cleanup()
1308
1309     else:
1310         raise Error('invalid command %s' % globals.command)
1311
1312 try:
1313     main()
1314 except Error as e:
1315     print('cdist: %s' % str(e), file=sys.stderr)
1316     sys.exit(1)