Fix up options parsing for all cases.
[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(m):
176     if not globals.quiet:
177         print('\x1b[33m* %s\x1b[0m' % m)
178
179 def escape_spaces(s):
180     return s.replace(' ', '\\ ')
181
182 def scp_escape(n):
183     """Escape a host:filename string for use with an scp command"""
184     s = n.split(':')
185     assert(len(s) == 1 or len(s) == 2)
186     if len(s) == 2:
187         return '%s:"\'%s\'"' % (s[0], s[1])
188     else:
189         return '\"%s\"' % s[0]
190
191 def mv_escape(n):
192     return '\"%s\"' % n.substr(' ', '\\ ')
193
194 def copytree(a, b):
195     log('copy %s -> %s' % (scp_escape(a), scp_escape(b)))
196     if b.startswith('s3://'):
197         command('s3cmd -P -r put "%s" "%s"' % (a, b))
198     else:
199         command('scp -r %s %s' % (scp_escape(a), scp_escape(b)))
200
201 def copyfile(a, b):
202     log('copy %s -> %s' % (scp_escape(a), scp_escape(b)))
203     if b.startswith('s3://'):
204         command('s3cmd -P put "%s" "%s"' % (a, b))
205     else:
206         bc = b.find(":")
207         if bc != -1:
208             host = b[:bc]
209             path = b[bc+1:]
210             temp_path = os.path.join(os.path.dirname(path), ".tmp." + os.path.basename(path))
211             command('scp %s %s' % (scp_escape(a), scp_escape(host + ":" + temp_path)))
212             command('ssh %s -- mv "%s" "%s"' % (host, escape_spaces(temp_path), escape_spaces(path)))
213         else:
214             command('scp %s %s' % (scp_escape(a), scp_escape(b)))
215
216 def makedirs(d):
217     """
218     Make directories either locally or on a remote host; remotely if
219     d includes a colon, otherwise locally.
220     """
221     if d.startswith('s3://'):
222         # No need to create folders on S3
223         return
224
225     if d.find(':') == -1:
226         try:
227             os.makedirs(d)
228         except OSError as e:
229             if e.errno != 17:
230                 raise e
231     else:
232         s = d.split(':')
233         command('ssh %s -- mkdir -p %s' % (s[0], s[1]))
234
235 def rmdir(a):
236     log('remove %s' % a)
237     os.rmdir(a)
238
239 def rmtree(a):
240     log('remove %s' % a)
241     shutil.rmtree(a, ignore_errors=True)
242
243 def command(c):
244     log(c)
245     r = os.system(c)
246     if (r >> 8):
247         raise Error('command %s failed' % c)
248
249 def command_and_read(c):
250     log(c)
251     p = subprocess.Popen(c.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
252     (out, err) = p.communicate()
253     if p.returncode != 0:
254         raise Error('command %s failed (%s)' % (c, err))
255     return out.splitlines()
256
257 def read_wscript_variable(directory, variable):
258     f = open('%s/wscript' % directory, 'r')
259     while True:
260         l = f.readline()
261         if l == '':
262             break
263
264         s = l.split()
265         if len(s) == 3 and s[0] == variable:
266             f.close()
267             return s[2][1:-1]
268
269     f.close()
270     return None
271
272 def set_version_in_wscript(version):
273     f = open('wscript', 'rw')
274     o = open('wscript.tmp', 'w')
275     while True:
276         l = f.readline()
277         if l == '':
278             break
279
280         s = l.split()
281         if len(s) == 3 and s[0] == "VERSION":
282             print("VERSION = '%s'" % version, file=o)
283         else:
284             print(l, file=o, end="")
285     f.close()
286     o.close()
287
288     os.rename('wscript.tmp', 'wscript')
289
290 def append_version_to_changelog(version):
291     try:
292         f = open('ChangeLog', 'r')
293     except:
294         log('Could not open ChangeLog')
295         return
296
297     c = f.read()
298     f.close()
299
300     f = open('ChangeLog', 'w')
301     now = datetime.datetime.now()
302     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))
303     f.write(c)
304
305 def append_version_to_debian_changelog(version):
306     if not os.path.exists('debian'):
307         log('Could not find debian directory')
308         return
309
310     command('dch -b -v %s-1 "New upstream release."' % version)
311
312 def devel_to_git(git_commit, filename):
313     if git_commit is not None:
314         filename = filename.replace('devel', '-%s' % git_commit)
315     return filename
316
317
318 def get_options(args, target):
319     options = dict()
320     if args.option is not None:
321         for o in args.option:
322             b = o.split(':')
323             if len(b) != 2:
324                 raise Error("Bad option `%s'" % o)
325             if b[1] == 'False':
326                 opts[b[0]] = False
327             elif b[1] == 'True':
328                 opts[b[0]] = True
329             else:
330                 opts[b[0]] = b[1]
331     # Add defaults for any unspecified options
332     tree = globals.trees.get(args.project, args.checkout, target)
333     tree.add_defaults(options)
334     return options
335
336
337 class TreeDirectory:
338     def __init__(self, tree):
339         self.tree = tree
340     def __enter__(self):
341         self.cwd = os.getcwd()
342         os.chdir('%s/src/%s' % (self.tree.target.directory, self.tree.name))
343     def __exit__(self, type, value, traceback):
344         os.chdir(self.cwd)
345
346 #
347 # Version
348 #
349
350 class Version:
351     def __init__(self, s):
352         self.devel = False
353
354         if s.startswith("'"):
355             s = s[1:]
356         if s.endswith("'"):
357             s = s[0:-1]
358
359         if s.endswith('devel'):
360             s = s[0:-5]
361             self.devel = True
362
363         if s.endswith('pre'):
364             s = s[0:-3]
365
366         p = s.split('.')
367         self.major = int(p[0])
368         self.minor = int(p[1])
369         if len(p) == 3:
370             self.micro = int(p[2])
371         else:
372             self.micro = 0
373
374     @classmethod
375     def from_git_tag(cls, tag):
376         bits = tag.split('-')
377         c = cls(bits[0])
378         if len(bits) > 1 and int(bits[1]) > 0:
379             c.devel = True
380         return c
381
382     def bump_minor(self):
383         self.minor += 1
384         self.micro = 0
385
386     def bump_micro(self):
387         self.micro += 1
388
389     def to_devel(self):
390         self.devel = True
391
392     def to_release(self):
393         self.devel = False
394
395     def __str__(self):
396         s = '%d.%d.%d' % (self.major, self.minor, self.micro)
397         if self.devel:
398             s += 'devel'
399
400         return s
401
402 #
403 # Targets
404 #
405
406 class Target(object):
407     """
408     Class representing the target that we are building for.  This is exposed to cscripts,
409     though not all of it is guaranteed 'API'.  cscripts may expect:
410
411     platform: platform string (e.g. 'windows', 'linux', 'osx')
412     parallel: number of parallel jobs to run
413     directory: directory to work in
414     variables: dict of environment variables
415     debug: True to build a debug version, otherwise False
416     ccache: True to use ccache, False to not
417     set(a, b): set the value of variable 'a' to 'b'
418     unset(a): unset the value of variable 'a'
419     command(c): run the command 'c' in the build environment
420
421     """
422
423     def __init__(self, platform, directory=None):
424         """
425         platform -- platform string (e.g. 'windows', 'linux', 'osx')
426         directory -- directory to work in; if None we will use a temporary directory
427         Temporary directories will be removed after use; specified directories will not.
428         """
429         self.platform = platform
430         self.parallel = int(config.get('parallel'))
431
432         # Environment variables that we will use when we call cscripts
433         self.variables = {}
434         self.debug = False
435         self._ccache = False
436         # True to build our dependencies ourselves; False if this is taken care
437         # of in some other way
438         self.build_dependencies = True
439
440         if directory is None:
441             self.directory = tempfile.mkdtemp('', 'tmp', TEMPORARY_DIRECTORY)
442             self.rmdir = True
443             self.set('CCACHE_BASEDIR', os.path.realpath(self.directory))
444             self.set('CCACHE_NOHASHDIR', '')
445         else:
446             self.directory = directory
447             self.rmdir = False
448
449
450     def setup(self):
451         pass
452
453     def package(self, project, checkout, output_dir, options):
454         tree = globals.trees.get(project, checkout, self)
455         if self.build_dependencies:
456             tree.build_dependencies(options)
457         tree.build(options)
458         if len(inspect.getargspec(tree.cscript['package']).args) == 3:
459             packages = tree.call('package', tree.version, options)
460         else:
461             log("Deprecated cscript package() method with no options parameter")
462             packages = tree.call('package', tree.version)
463
464         if isinstance(packages, (str, unicode)):
465             copyfile(packages, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, packages))))
466         else:
467             for p in packages:
468                 copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
469
470     def build(self, project, checkout, options):
471         tree = globals.trees.get(project, checkout, self)
472         if self.build_dependencies:
473             tree.build_dependencies(options)
474         tree.build(options)
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('Deprecated property library_prefix: use environment_prefix')
642         return self.environment_prefix
643
644     @property
645     def windows_prefix(self):
646         log('Deprecated property windows_prefix: use environment_prefix')
647         return self.environment_prefix
648
649     @property
650     def mingw_prefixes(self):
651         log('Deprecated property mingw_prefixes: use environment_prefix')
652         return [self.environment_prefix]
653
654     @property
655     def mingw_path(self):
656         log('Deprecated property mingw_path: use tool_path')
657         return self.tool_path
658
659     @property
660     def mingw_name(self):
661         log('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("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('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 this 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("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         if not 'dependencies' in self.cscript:
953             return
954
955         if len(inspect.getargspec(self.cscript['dependencies']).args) == 2:
956             deps = self.call('dependencies', options)
957         else:
958             log("Deprecated cscript dependencies() method with no options parameter")
959             deps = self.call('dependencies')
960
961         for d in deps:
962             dep = globals.trees.get(d[0], d[1], self.target, self.name)
963
964             # Start with the options passed in
965             dep_options = copy.copy(options)
966             # Add things specified by the parent
967             if len(d) > 2:
968                 for k, v in d[2].items():
969                     if not k in dep_options:
970                         dep_options[k] = v
971             # Then fill in the dependency's defaults
972             dep.add_defaults(dep_options)
973
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         for i in self.dependencies(options):
984             global args
985             if args.verbose:
986                 print('Building a dependency of %s %s %s with %s' % (self.name, self.specifier, self.version, options))
987             i[0].build(i[1])
988
989     def build(self, options):
990         if self.built:
991             return
992
993         global args
994         if args.verbose:
995             print("* Building %s %s %s with %s" % (self.name, self.specifier, self.version, options))
996
997         variables = copy.copy(self.target.variables)
998
999         # Start with the options passed in
1000         options = copy.copy(options)
1001         # Fill in the defaults
1002         self.add_defaults(options)
1003
1004         if not globals.dry_run:
1005             if len(inspect.getargspec(self.cscript['build']).args) == 2:
1006                 self.call('build', options)
1007             else:
1008                 self.call('build')
1009
1010         self.target.variables = variables
1011         self.built = True
1012
1013 #
1014 # Command-line parser
1015 #
1016
1017 def main():
1018
1019     commands = {
1020         "build": "build project",
1021         "package": "package and build project",
1022         "release": "release a project using its next version number (changing wscript and tagging)",
1023         "pot": "build the project's .pot files",
1024         "changelog": "generate a simple HTML changelog",
1025         "manual": "build the project's manual",
1026         "doxygen": "build the project's Doxygen documentation",
1027         "latest": "print out the latest version",
1028         "test": "run the project's unit tests",
1029         "shell": "build the project then start a shell",
1030         "checkout": "check out the project",
1031         "revision": "print the head git revision number"
1032     }
1033
1034     one_of = "Command is one of:\n"
1035     summary = ""
1036     for k, v in commands.items():
1037         one_of += "\t%s\t%s\n" % (k, v)
1038         summary += k + " "
1039
1040     parser = argparse.ArgumentParser()
1041     parser.add_argument('command', help=summary)
1042     parser.add_argument('-p', '--project', help='project name')
1043     parser.add_argument('--minor', help='minor version number bump', action='store_true')
1044     parser.add_argument('--micro', help='micro version number bump', action='store_true')
1045     parser.add_argument('--latest-major', help='major version to return with latest', type=int)
1046     parser.add_argument('--latest-minor', help='minor version to return with latest', type=int)
1047     parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
1048     parser.add_argument('-o', '--output', help='output directory', default='.')
1049     parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
1050     parser.add_argument('-t', '--target', help='target', action='append')
1051     parser.add_argument('--environment-version', help='version of environment to use')
1052     parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
1053     parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
1054     parser.add_argument('-w', '--work', help='override default work directory')
1055     parser.add_argument('-g', '--git-prefix', help='override configured git prefix')
1056     parser.add_argument('--test', help="name of test to run (with `test'), defaults to all")
1057     parser.add_argument('-n', '--dry-run', help='run the process without building anything', action='store_true')
1058     parser.add_argument('-e', '--environment', help='pass the value of the named environment variable into the build', action='append')
1059     parser.add_argument('-m', '--mount', help='mount a given directory in the build environment', action='append')
1060     parser.add_argument('--no-version-commit', help="use just tags for versioning, don't modify wscript, ChangeLog etc.", action='store_true')
1061     parser.add_argument('--option', help='set an option for the build (use --option key:value)', action='append')
1062     parser.add_argument('--ccache', help='use ccache', action='store_true')
1063     parser.add_argument('--verbose', help='be verbose', action='store_true')
1064     global args
1065     args = parser.parse_args()
1066
1067     # Check for incorrect multiple parameters
1068     if args.target is not None:
1069         if len(args.target) > 1:
1070             parser.error('multiple -t options specified')
1071             sys.exit(1)
1072         else:
1073             args.target = args.target[0]
1074
1075     # Override configured stuff
1076     if args.git_prefix is not None:
1077         config.set('git_prefix', args.git_prefix)
1078
1079     if args.output.find(':') == -1:
1080         # This isn't of the form host:path so make it absolute
1081         args.output = os.path.abspath(args.output) + '/'
1082     else:
1083         if args.output[-1] != ':' and args.output[-1] != '/':
1084             args.output += '/'
1085
1086     # Now, args.output is 'host:', 'host:path/' or 'path/'
1087
1088     if args.work is not None:
1089         args.work = os.path.abspath(args.work)
1090
1091     if args.project is None and args.command != 'shell':
1092         raise Error('you must specify -p or --project')
1093
1094     globals.quiet = args.quiet
1095     globals.command = args.command
1096     globals.dry_run = args.dry_run
1097
1098     if not globals.command in commands:
1099         e = 'command must be one of:\n' + one_of
1100         raise Error('command must be one of:\n%s' % one_of)
1101
1102     if globals.command == 'build':
1103         if args.target is None:
1104             raise Error('you must specify -t or --target')
1105
1106         target = target_factory(args)
1107         target.build(args.project, args.checkout, get_options(args, target))
1108         if not args.keep:
1109             target.cleanup()
1110
1111     elif globals.command == 'package':
1112         if args.target is None:
1113             raise Error('you must specify -t or --target')
1114
1115         target = None
1116         try:
1117             target = target_factory(args)
1118
1119             if target.platform == 'linux' and target.detail != "appimage":
1120                 if target.distro != 'arch':
1121                     output_dir = os.path.join(args.output, '%s-%s-%d' % (target.distro, target.version, target.bits))
1122                 else:
1123                     output_dir = os.path.join(args.output, '%s-%d' % (target.distro, target.bits))
1124             else:
1125                 output_dir = args.output
1126
1127             makedirs(output_dir)
1128             target.package(args.project, args.checkout, output_dir, get_options(args, target))
1129         except Error as e:
1130             if target is not None and not args.keep:
1131                 target.cleanup()
1132             raise
1133
1134         if target is not None and not args.keep:
1135             target.cleanup()
1136
1137     elif globals.command == 'release':
1138         if args.minor is False and args.micro is False:
1139             raise Error('you must specify --minor or --micro')
1140
1141         target = SourceTarget()
1142         tree = globals.trees.get(args.project, args.checkout, target)
1143
1144         version = tree.version
1145         version.to_release()
1146         if args.minor:
1147             version.bump_minor()
1148         else:
1149             version.bump_micro()
1150
1151         with TreeDirectory(tree):
1152             if not args.no_version_commit:
1153                 set_version_in_wscript(version)
1154                 append_version_to_changelog(version)
1155                 append_version_to_debian_changelog(version)
1156                 command('git commit -a -m "Bump version"')
1157
1158             command('git tag -m "v%s" v%s' % (version, version))
1159
1160             if not args.no_version_commit:
1161                 version.to_devel()
1162                 set_version_in_wscript(version)
1163                 command('git commit -a -m "Bump version"')
1164                 command('git push')
1165
1166             command('git push --tags')
1167
1168         target.cleanup()
1169
1170     elif globals.command == 'pot':
1171         target = SourceTarget()
1172         tree = globals.trees.get(args.project, args.checkout, target)
1173
1174         pots = tree.call('make_pot')
1175         for p in pots:
1176             copyfile(p, '%s%s' % (args.output, os.path.basename(p)))
1177
1178         target.cleanup()
1179
1180     elif globals.command == 'changelog':
1181         target = SourceTarget()
1182         tree = globals.trees.get(args.project, args.checkout, target)
1183
1184         with TreeDirectory(tree):
1185             text = open('ChangeLog', 'r')
1186
1187         html = tempfile.NamedTemporaryFile()
1188         versions = 8
1189
1190         last = None
1191         changes = []
1192
1193         while True:
1194             l = text.readline()
1195             if l == '':
1196                 break
1197
1198             if len(l) > 0 and l[0] == "\t":
1199                 s = l.split()
1200                 if len(s) == 4 and s[1] == "Version" and s[3] == "released.":
1201                     v = Version(s[2])
1202                     if v.micro == 0:
1203                         if last is not None and len(changes) > 0:
1204                             print("<h2>Changes between version %s and %s</h2>" % (s[2], last), file=html)
1205                             print("<ul>", file=html)
1206                             for c in changes:
1207                                 print("<li>%s" % c, file=html)
1208                             print("</ul>", file=html)
1209                         last = s[2]
1210                         changes = []
1211                         versions -= 1
1212                         if versions < 0:
1213                             break
1214                 else:
1215                     c = l.strip()
1216                     if len(c) > 0:
1217                         if c[0] == '*':
1218                             changes.append(c[2:])
1219                         else:
1220                             changes[-1] += " " + c
1221
1222         copyfile(html.file, '%schangelog.html' % args.output)
1223         html.close()
1224         target.cleanup()
1225
1226     elif globals.command == 'manual':
1227         target = SourceTarget()
1228         tree = globals.trees.get(args.project, args.checkout, target)
1229
1230         outs = tree.call('make_manual')
1231         for o in outs:
1232             if os.path.isfile(o):
1233                 copyfile(o, '%s%s' % (args.output, os.path.basename(o)))
1234             else:
1235                 copytree(o, '%s%s' % (args.output, os.path.basename(o)))
1236
1237         target.cleanup()
1238
1239     elif globals.command == 'doxygen':
1240         target = SourceTarget()
1241         tree = globals.trees.get(args.project, args.checkout, target)
1242
1243         dirs = tree.call('make_doxygen')
1244         if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
1245             dirs = [dirs]
1246
1247         for d in dirs:
1248             copytree(d, args.output)
1249
1250         target.cleanup()
1251
1252     elif globals.command == 'latest':
1253         target = SourceTarget()
1254         tree = globals.trees.get(args.project, args.checkout, target)
1255
1256         with TreeDirectory(tree):
1257             f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
1258             latest = None
1259             line = 0
1260             while latest is None:
1261                 t = f[line]
1262                 line += 1
1263                 m = re.compile(".*\((.*)\).*").match(t)
1264                 if m:
1265                     tags = m.group(1).split(', ')
1266                     for t in tags:
1267                         s = t.split()
1268                         if len(s) > 1:
1269                             t = s[1]
1270                         if len(t) > 0 and t[0] == 'v':
1271                             v = Version(t[1:])
1272                             if (args.latest_major is None or v.major == args.latest_major) and (args.latest_minor is None or v.minor == args.latest_minor):
1273                                 latest = v
1274
1275         print(latest)
1276         target.cleanup()
1277
1278     elif globals.command == 'test':
1279         if args.target is None:
1280             raise Error('you must specify -t or --target')
1281
1282         target = None
1283         try:
1284             target = target_factory(args)
1285             tree = globals.trees.get(args.project, args.checkout, target)
1286             with TreeDirectory(tree):
1287                 target.test(tree, args.test, get_options(args, target))
1288         except Error as e:
1289             if target is not None and not args.keep:
1290                 target.cleanup()
1291             raise
1292
1293         if target is not None and not args.keep:
1294             target.cleanup()
1295
1296     elif globals.command == 'shell':
1297         if args.target is None:
1298             raise Error('you must specify -t or --target')
1299
1300         target = target_factory(args)
1301         target.command('bash')
1302
1303     elif globals.command == 'revision':
1304
1305         target = SourceTarget()
1306         tree = globals.trees.get(args.project, args.checkout, target)
1307         with TreeDirectory(tree):
1308             print(command_and_read('git rev-parse HEAD')[0].strip()[:7])
1309         target.cleanup()
1310
1311     elif globals.command == 'checkout':
1312
1313         if args.output is None:
1314             raise Error('you must specify -o or --output')
1315
1316         target = SourceTarget()
1317         tree = globals.trees.get(args.project, args.checkout, target)
1318         with TreeDirectory(tree):
1319             shutil.copytree('.', args.output)
1320         target.cleanup()
1321
1322     else:
1323         raise Error('invalid command %s' % globals.command)
1324
1325 try:
1326     main()
1327 except Error as e:
1328     print('cdist: %s' % str(e), file=sys.stderr)
1329     sys.exit(1)