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