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