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