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