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