c68ee22fd3f56ca74d76f5b2ca50e55bdf72fe84
[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 def argument_options(args):
318     opts = dict()
319     if args.option is not None:
320         for o in args.option:
321             b = o.split(':')
322             if len(b) != 2:
323                 raise Error("Bad option `%s'" % o)
324             if b[1] == 'False':
325                 opts[b[0]] = False
326             elif b[1] == 'True':
327                 opts[b[0]] = True
328             else:
329                 opts[b[0]] = b[1]
330     return opts
331
332
333 class TreeDirectory:
334     def __init__(self, tree):
335         self.tree = tree
336     def __enter__(self):
337         self.cwd = os.getcwd()
338         os.chdir('%s/src/%s' % (self.tree.target.directory, self.tree.name))
339     def __exit__(self, type, value, traceback):
340         os.chdir(self.cwd)
341
342 #
343 # Version
344 #
345
346 class Version:
347     def __init__(self, s):
348         self.devel = False
349
350         if s.startswith("'"):
351             s = s[1:]
352         if s.endswith("'"):
353             s = s[0:-1]
354
355         if s.endswith('devel'):
356             s = s[0:-5]
357             self.devel = True
358
359         if s.endswith('pre'):
360             s = s[0:-3]
361
362         p = s.split('.')
363         self.major = int(p[0])
364         self.minor = int(p[1])
365         if len(p) == 3:
366             self.micro = int(p[2])
367         else:
368             self.micro = 0
369
370     @classmethod
371     def from_git_tag(cls, tag):
372         bits = tag.split('-')
373         c = cls(bits[0])
374         if len(bits) > 1 and int(bits[1]) > 0:
375             c.devel = True
376         return c
377
378     def bump_minor(self):
379         self.minor += 1
380         self.micro = 0
381
382     def bump_micro(self):
383         self.micro += 1
384
385     def to_devel(self):
386         self.devel = True
387
388     def to_release(self):
389         self.devel = False
390
391     def __str__(self):
392         s = '%d.%d.%d' % (self.major, self.minor, self.micro)
393         if self.devel:
394             s += 'devel'
395
396         return s
397
398 #
399 # Targets
400 #
401
402 class Target(object):
403     """
404     Class representing the target that we are building for.  This is exposed to cscripts,
405     though not all of it is guaranteed 'API'.  cscripts may expect:
406
407     platform: platform string (e.g. 'windows', 'linux', 'osx')
408     parallel: number of parallel jobs to run
409     directory: directory to work in
410     variables: dict of environment variables
411     debug: True to build a debug version, otherwise False
412     ccache: True to use ccache, False to not
413     set(a, b): set the value of variable 'a' to 'b'
414     unset(a): unset the value of variable 'a'
415     command(c): run the command 'c' in the build environment
416
417     """
418
419     def __init__(self, platform, directory=None):
420         """
421         platform -- platform string (e.g. 'windows', 'linux', 'osx')
422         directory -- directory to work in; if None we will use a temporary directory
423         Temporary directories will be removed after use; specified directories will not.
424         """
425         self.platform = platform
426         self.parallel = int(config.get('parallel'))
427
428         # Environment variables that we will use when we call cscripts
429         self.variables = {}
430         self.debug = False
431         self._ccache = False
432         # True to build our dependencies ourselves; False if this is taken care
433         # of in some other way
434         self.build_dependencies = True
435
436         if directory is None:
437             self.directory = tempfile.mkdtemp('', 'tmp', TEMPORARY_DIRECTORY)
438             self.rmdir = True
439             self.set('CCACHE_BASEDIR', os.path.realpath(self.directory))
440             self.set('CCACHE_NOHASHDIR', '')
441         else:
442             self.directory = directory
443             self.rmdir = False
444
445
446     def setup(self):
447         pass
448
449     def package(self, project, checkout, output_dir, options):
450         tree = globals.trees.get(project, checkout, self)
451         if self.build_dependencies:
452             tree.build_dependencies(options)
453         tree.build(options)
454         if len(inspect.getargspec(tree.cscript['package']).args) == 3:
455             packages = tree.call('package', tree.version, options)
456         else:
457             log("Deprecated cscript package() method with no options parameter")
458             packages = tree.call('package', tree.version)
459
460         if isinstance(packages, (str, unicode)):
461             copyfile(packages, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, packages))))
462         else:
463             for p in packages:
464                 copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
465
466     def build(self, project, checkout, options):
467         tree = globals.trees.get(project, checkout, self)
468         if self.build_dependencies:
469             tree.build_dependencies(options)
470         tree.build(options)
471
472     def test(self, tree, test, options):
473         """test is the test case to run, or None"""
474         if self.build_dependencies:
475             tree.build_dependencies(options)
476         tree.build(options)
477         return tree.call('test', test)
478
479     def set(self, a, b):
480         self.variables[a] = b
481
482     def unset(self, a):
483         del(self.variables[a])
484
485     def get(self, a):
486         return self.variables[a]
487
488     def append(self, k, v, s):
489         if (not k in self.variables) or len(self.variables[k]) == 0:
490             self.variables[k] = '"%s"' % v
491         else:
492             e = self.variables[k]
493             if e[0] == '"' and e[-1] == '"':
494                 self.variables[k] = '"%s%s%s"' % (e[1:-1], s, v)
495             else:
496                 self.variables[k] = '"%s%s%s"' % (e, s, v)
497
498     def append_with_space(self, k, v):
499         return self.append(k, v, ' ')
500
501     def append_with_colon(self, k, v):
502         return self.append(k, v, ':')
503
504     def variables_string(self, escaped_quotes=False):
505         e = ''
506         for k, v in self.variables.items():
507             if escaped_quotes:
508                 v = v.replace('"', '\\"')
509             e += '%s=%s ' % (k, v)
510         return e
511
512     def cleanup(self):
513         if self.rmdir:
514             rmtree(self.directory)
515
516     def mount(self, m):
517         pass
518
519     @property
520     def ccache(self):
521         return self._ccache
522
523     @ccache.setter
524     def ccache(self, v):
525         self._ccache = v
526
527
528 class DockerTarget(Target):
529     def __init__(self, platform, directory):
530         super(DockerTarget, self).__init__(platform, directory)
531         self.mounts = []
532         self.privileged = False
533
534     def _user_tag(self):
535         if config.get('docker_no_user'):
536             return ''
537         return '-u %s' % getpass.getuser()
538
539     def setup(self):
540         opts = '-v %s:%s ' % (self.directory, self.directory)
541         for m in self.mounts:
542             opts += '-v %s:%s ' % (m, m)
543         if self.privileged:
544             opts += '--privileged=true '
545         if self.ccache:
546             opts += "-e CCACHE_DIR=/ccache --volumes-from ccache-%s" % self.image
547
548         tag = self.image
549         if config.has('docker_hub_repository'):
550             tag = '%s:%s' % (config.get('docker_hub_repository'), tag)
551
552         self.container = command_and_read('%s run %s %s -itd %s /bin/bash' % (config.docker(), self._user_tag(), opts, tag))[0].strip()
553
554     def command(self, cmd):
555         dir = os.path.join(self.directory, os.path.relpath(os.getcwd(), self.directory))
556         interactive_flag = '-i ' if sys.stdin.isatty() else ''
557         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))
558
559     def cleanup(self):
560         super(DockerTarget, self).cleanup()
561         command('%s kill %s' % (config.docker(), self.container))
562
563     def mount(self, m):
564         self.mounts.append(m)
565
566
567 class FlatpakTarget(Target):
568     def __init__(self, project, checkout):
569         super(FlatpakTarget, self).__init__('flatpak')
570         self.build_dependencies = False
571         self.project = project
572         self.checkout = checkout
573
574     def setup(self):
575         pass
576
577     def command(self, cmd):
578         command(cmd)
579
580     def checkout_dependencies(self):
581         tree = globals.trees.get(self.project, self.checkout, self)
582         return tree.checkout_dependencies()
583
584     def flatpak(self):
585         return 'flatpak'
586
587     def flatpak_builder(self):
588         b = 'flatpak-builder'
589         if config.has('flatpak_state_dir'):
590             b += ' --state-dir=%s' % config.get('flatpak_state_dir')
591         return b
592
593
594 class WindowsTarget(DockerTarget):
595     """
596     This target exposes the following additional API:
597
598     version: Windows version ('xp' or None)
599     bits: bitness of Windows (32 or 64)
600     name: name of our target e.g. x86_64-w64-mingw32.shared
601     environment_prefix: path to Windows environment for the appropriate target (libraries and some tools)
602     tool_path: path to 32- and 64-bit tools
603     """
604     def __init__(self, windows_version, bits, directory, environment_version):
605         super(WindowsTarget, self).__init__('windows', directory)
606         self.version = windows_version
607         self.bits = bits
608
609         self.tool_path = '%s/usr/bin' % config.get('mxe_prefix')
610         if self.bits == 32:
611             self.name = 'i686-w64-mingw32.shared'
612         else:
613             self.name = 'x86_64-w64-mingw32.shared'
614         self.environment_prefix = '%s/usr/%s' % (config.get('mxe_prefix'), self.name)
615
616         self.set('PKG_CONFIG_LIBDIR', '%s/lib/pkgconfig' % self.environment_prefix)
617         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/bin/pkgconfig' % (self.directory, self.directory))
618         self.set('PATH', '%s/bin:%s:%s' % (self.environment_prefix, self.tool_path, os.environ['PATH']))
619         self.set('CC', '%s-gcc' % self.name)
620         self.set('CXX', '%s-g++' % self.name)
621         self.set('LD', '%s-ld' % self.name)
622         self.set('RANLIB', '%s-ranlib' % self.name)
623         self.set('WINRC', '%s-windres' % self.name)
624         cxx = '-I%s/include -I%s/include' % (self.environment_prefix, self.directory)
625         link = '-L%s/lib -L%s/lib' % (self.environment_prefix, self.directory)
626         self.set('CXXFLAGS', '"%s"' % cxx)
627         self.set('CPPFLAGS', '')
628         self.set('LINKFLAGS', '"%s"' % link)
629         self.set('LDFLAGS', '"%s"' % link)
630
631         self.image = 'windows'
632         if environment_version is not None:
633             self.image += '_%s' % environment_version
634
635     @property
636     def library_prefix(self):
637         log('Deprecated property library_prefix: use environment_prefix')
638         return self.environment_prefix
639
640     @property
641     def windows_prefix(self):
642         log('Deprecated property windows_prefix: use environment_prefix')
643         return self.environment_prefix
644
645     @property
646     def mingw_prefixes(self):
647         log('Deprecated property mingw_prefixes: use environment_prefix')
648         return [self.environment_prefix]
649
650     @property
651     def mingw_path(self):
652         log('Deprecated property mingw_path: use tool_path')
653         return self.tool_path
654
655     @property
656     def mingw_name(self):
657         log('Deprecated property mingw_name: use name')
658         return self.name
659
660
661 class LinuxTarget(DockerTarget):
662     """
663     Build for Linux in a docker container.
664     This target exposes the following additional API:
665
666     distro: distribution ('debian', 'ubuntu', 'centos' or 'fedora')
667     version: distribution version (e.g. '12.04', '8', '6.5')
668     bits: bitness of the distribution (32 or 64)
669     detail: None or 'appimage' if we are building for appimage
670     """
671
672     def __init__(self, distro, version, bits, directory=None):
673         super(LinuxTarget, self).__init__('linux', directory)
674         self.distro = distro
675         self.version = version
676         self.bits = bits
677         self.detail = None
678
679         self.set('CXXFLAGS', '-I%s/include' % self.directory)
680         self.set('CPPFLAGS', '')
681         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
682         self.set('PKG_CONFIG_PATH',
683                  '%s/lib/pkgconfig:%s/lib64/pkgconfig:/usr/local/lib64/pkgconfig:/usr/local/lib/pkgconfig' % (self.directory, self.directory))
684         self.set('PATH', '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin')
685
686         if self.version is None:
687             self.image = '%s-%s' % (self.distro, self.bits)
688         else:
689             self.image = '%s-%s-%s' % (self.distro, self.version, self.bits)
690
691     def setup(self):
692         super(LinuxTarget, self).setup()
693         if self.ccache:
694             self.set('CC', '"ccache gcc"')
695             self.set('CXX', '"ccache g++"')
696
697     def test(self, tree, test, options):
698         self.append_with_colon('PATH', '%s/bin' % self.directory)
699         self.append_with_colon('LD_LIBRARY_PATH', '%s/lib' % self.directory)
700         super(LinuxTarget, self).test(tree, test, options)
701
702
703 class AppImageTarget(LinuxTarget):
704     def __init__(self, work):
705         super(AppImageTarget, self).__init__('ubuntu', '16.04', 64, work)
706         self.detail = 'appimage'
707         self.privileged = True
708
709
710 class OSXTarget(Target):
711     def __init__(self, directory=None):
712         super(OSXTarget, self).__init__('osx', directory)
713         self.sdk = config.get('osx_sdk')
714         self.sdk_prefix = config.get('osx_sdk_prefix')
715         self.environment_prefix = config.get('osx_environment_prefix')
716         self.apple_id = config.get('apple_id')
717         self.apple_password = config.get('apple_password')
718
719     def command(self, c):
720         command('%s %s' % (self.variables_string(False), c))
721
722
723 class OSXSingleTarget(OSXTarget):
724     def __init__(self, bits, directory=None):
725         super(OSXSingleTarget, self).__init__(directory)
726         self.bits = bits
727
728         if bits == 32:
729             arch = 'i386'
730         else:
731             arch = 'x86_64'
732
733         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (self.sdk_prefix, self.sdk, arch)
734         enviro = '%s/%d' % (config.get('osx_environment_prefix'), bits)
735
736         # Environment variables
737         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
738         self.set('CPPFLAGS', '')
739         self.set('CXXFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
740         self.set('LDFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
741         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
742         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, enviro))
743         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % enviro)
744         self.set('MACOSX_DEPLOYMENT_TARGET', config.get('osx_sdk'))
745         self.set('CCACHE_BASEDIR', self.directory)
746
747     @Target.ccache.setter
748     def ccache(self, v):
749         Target.ccache.fset(self, v)
750         if v:
751             self.set('CC', '"ccache gcc"')
752             self.set('CXX', '"ccache g++"')
753
754
755 class OSXUniversalTarget(OSXTarget):
756     def __init__(self, directory=None):
757         super(OSXUniversalTarget, self).__init__(directory)
758         self.bits = None
759
760     def package(self, project, checkout, output_dir, options):
761
762         for b in [32, 64]:
763             target = OSXSingleTarget(b, os.path.join(self.directory, '%d' % b))
764             target.ccache = self.ccache
765             tree = globals.trees.get(project, checkout, target)
766             tree.build_dependencies(options)
767             tree.build(options)
768
769         tree = globals.trees.get(project, checkout, self)
770         with TreeDirectory(tree):
771             if len(inspect.getargspec(tree.cscript['package']).args) == 3:
772                 packages = tree.call('package', tree.version, options)
773             else:
774                 log("Deprecated cscript package() method with no options parameter")
775                 packages = tree.call('package', tree.version)
776             for p in packages:
777                 copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
778
779 class SourceTarget(Target):
780     """Build a source .tar.bz2"""
781     def __init__(self):
782         super(SourceTarget, self).__init__('source')
783
784     def command(self, c):
785         log('host -> %s' % c)
786         command('%s %s' % (self.variables_string(), c))
787
788     def cleanup(self):
789         rmtree(self.directory)
790
791     def package(self, project, checkout, output_dir, options):
792         tree = globals.trees.get(project, checkout, self)
793         with TreeDirectory(tree):
794             name = read_wscript_variable(os.getcwd(), 'APPNAME')
795             command('./waf dist')
796             p = os.path.abspath('%s-%s.tar.bz2' % (name, tree.version))
797             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
798
799 # @param s Target string:
800 #       windows-{32,64}
801 #    or ubuntu-version-{32,64}
802 #    or debian-version-{32,64}
803 #    or centos-version-{32,64}
804 #    or fedora-version-{32,64}
805 #    or mageia-version-{32,64}
806 #    or osx-{32,64}
807 #    or source
808 #    or flatpak
809 #    or appimage
810 # @param debug True to build with debugging symbols (where possible)
811 def target_factory(args):
812     s = args.target
813     target = None
814     if s.startswith('windows-'):
815         x = s.split('-')
816         if len(x) == 2:
817             target = WindowsTarget(None, int(x[1]), args.work, args.environment_version)
818         elif len(x) == 3:
819             target = WindowsTarget(x[1], int(x[2]), args.work, args.environment_version)
820         else:
821             raise Error("Bad Windows target name `%s'")
822     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-') or s.startswith('fedora-') or s.startswith('mageia-'):
823         p = s.split('-')
824         if len(p) != 3:
825             raise Error("Bad Linux target name `%s'; must be something like ubuntu-16.04-32 (i.e. distro-version-bits)" % s)
826         target = LinuxTarget(p[0], p[1], int(p[2]), args.work)
827     elif s.startswith('arch-'):
828         p = s.split('-')
829         if len(p) != 2:
830             raise Error("Bad Arch target name `%s'; must be arch-32 or arch-64")
831         target = LinuxTarget(p[0], None, int(p[1]), args.work)
832     elif s == 'raspbian':
833         target = LinuxTarget(s, None, None, args.work)
834     elif s.startswith('osx-'):
835         target = OSXSingleTarget(int(s.split('-')[1]), args.work)
836     elif s == 'osx':
837         if globals.command == 'build':
838             target = OSXSingleTarget(64, args.work)
839         else:
840             target = OSXUniversalTarget(args.work)
841     elif s == 'source':
842         target = SourceTarget()
843     elif s == 'flatpak':
844         target = FlatpakTarget(args.project, args.checkout)
845     elif s == 'appimage':
846         target = AppImageTarget(args.work)
847
848     if target is None:
849         raise Error("Bad target `%s'" % s)
850
851     target.debug = args.debug
852     target.ccache = args.ccache
853
854     if args.environment is not None:
855         for e in args.environment:
856             target.set(e, os.environ[e])
857
858     if args.mount is not None:
859         for m in args.mount:
860             target.mount(m)
861
862     target.setup()
863     return target
864
865
866 #
867 # Tree
868 #
869
870 class Tree(object):
871     """Description of a tree, which is a checkout of a project,
872        possibly built.  This class is never exposed to cscripts.
873        Attributes:
874            name -- name of git repository (without the .git)
875            specifier -- git tag or revision to use
876            target -- target object that we are using
877            version -- version from the wscript (if one is present)
878            git_commit -- git revision that is actually being used
879            built -- true if the tree has been built yet in this run
880            required_by -- name of the tree that requires this one
881     """
882
883     def __init__(self, name, specifier, target, required_by):
884         self.name = name
885         self.specifier = specifier
886         self.target = target
887         self.version = None
888         self.git_commit = None
889         self.built = False
890         self.required_by = required_by
891
892         cwd = os.getcwd()
893
894         flags = ''
895         redirect = ''
896         if globals.quiet:
897             flags = '-q'
898             redirect = '>/dev/null'
899         command('git clone %s %s/%s.git %s/src/%s' % (flags, config.get('git_prefix'), self.name, target.directory, self.name))
900         os.chdir('%s/src/%s' % (target.directory, self.name))
901
902         spec = self.specifier
903         if spec is None:
904             spec = 'master'
905
906         command('git checkout %s %s %s' % (flags, spec, redirect))
907         self.git_commit = command_and_read('git rev-parse --short=7 HEAD')[0].strip()
908
909         proj = '%s/src/%s' % (target.directory, self.name)
910
911         self.cscript = {}
912         exec(open('%s/cscript' % proj).read(), self.cscript)
913
914         # cscript can include submodules = False to stop submodules being fetched
915         if not 'submodules' in self.cscript or self.cscript['submodules'] == True:
916             command('git submodule init --quiet')
917             command('git submodule update --quiet')
918
919         if os.path.exists('%s/wscript' % proj):
920             v = read_wscript_variable(proj, "VERSION");
921             if v is not None:
922                 try:
923                     self.version = Version(v)
924                 except:
925                     tag = subprocess.Popen(shlex.split('git -C %s describe --tags' % proj), stdout=subprocess.PIPE).communicate()[0][1:]
926                     self.version = Version.from_git_tag(tag)
927
928         os.chdir(cwd)
929
930     def call(self, function, *args):
931         with TreeDirectory(self):
932             return self.cscript[function](self.target, *args)
933
934     def add_defaults(self, options):
935         """Add the defaults from this into a dict options"""
936         if 'option_defaults' in self.cscript:
937             from_cscript = self.cscript['option_defaults']
938             if isinstance(from_cscript, dict):
939                 defaults_dict = from_cscript
940             else:
941                 log("Deprecated cscript option_defaults method; replace with a dict")
942                 defaults_dict = from_cscript()
943             for k, v in defaults_dict.items():
944                 if not k in options:
945                     options[k] = v
946
947     def dependencies(self, options):
948         if not 'dependencies' in self.cscript:
949             return
950
951         if len(inspect.getargspec(self.cscript['dependencies']).args) == 2:
952             deps = self.call('dependencies', options)
953         else:
954             log("Deprecated cscript dependencies() method with no options parameter")
955             deps = self.call('dependencies')
956
957         for d in deps:
958             dep = globals.trees.get(d[0], d[1], self.target, self.name)
959
960             # Start with the options passed in
961             dep_options = copy.copy(options)
962             # Add things specified by the parent
963             if len(d) > 2:
964                 for k, v in d[2].items():
965                     if not k in dep_options:
966                         dep_options[k] = v
967             # Then fill in the dependency's defaults
968             dep.add_defaults(dep_options)
969
970             for i in dep.dependencies(dep_options):
971                 yield i
972             yield (dep, dep_options)
973
974     def checkout_dependencies(self, options={}):
975         for i in self.dependencies(options):
976             pass
977
978     def build_dependencies(self, options):
979         for i in self.dependencies(options):
980             global args
981             if args.verbose:
982                 print('Building a dependency of %s %s %s with %s' % (self.name, self.specifier, self.version, options))
983             i[0].build(i[1])
984
985     def build(self, options):
986         if self.built:
987             return
988
989         global args
990         if args.verbose:
991             print("* Building %s %s %s with %s" % (self.name, self.specifier, self.version, options))
992
993         variables = copy.copy(self.target.variables)
994
995         # Start with the options passed in
996         options = copy.copy(options)
997         # Fill in the defaults
998         self.add_defaults(options)
999
1000         if not globals.dry_run:
1001             if len(inspect.getargspec(self.cscript['build']).args) == 2:
1002                 self.call('build', options)
1003             else:
1004                 self.call('build')
1005
1006         self.target.variables = variables
1007         self.built = True
1008
1009 #
1010 # Command-line parser
1011 #
1012
1013 def main():
1014
1015     commands = {
1016         "build": "build project",
1017         "package": "package and build project",
1018         "release": "release a project using its next version number (changing wscript and tagging)",
1019         "pot": "build the project's .pot files",
1020         "changelog": "generate a simple HTML changelog",
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
1087     if args.project is None and args.command != 'shell':
1088         raise Error('you must specify -p or --project')
1089
1090     globals.quiet = args.quiet
1091     globals.command = args.command
1092     globals.dry_run = args.dry_run
1093
1094     if not globals.command in commands:
1095         e = 'command must be one of:\n' + one_of
1096         raise Error('command must be one of:\n%s' % one_of)
1097
1098     if globals.command == 'build':
1099         if args.target is None:
1100             raise Error('you must specify -t or --target')
1101
1102         target = target_factory(args)
1103         target.build(args.project, args.checkout, argument_options(args))
1104         if not args.keep:
1105             target.cleanup()
1106
1107     elif globals.command == 'package':
1108         if args.target is None:
1109             raise Error('you must specify -t or --target')
1110
1111         target = None
1112         try:
1113             target = target_factory(args)
1114
1115             if target.platform == 'linux' and target.detail != "appimage":
1116                 if target.distro != 'arch':
1117                     output_dir = os.path.join(args.output, '%s-%s-%d' % (target.distro, target.version, target.bits))
1118                 else:
1119                     output_dir = os.path.join(args.output, '%s-%d' % (target.distro, target.bits))
1120             else:
1121                 output_dir = args.output
1122
1123             makedirs(output_dir)
1124
1125             # Start with the options passed on the command line
1126             options = copy.copy(argument_options(args))
1127             # Fill in the defaults
1128             tree = globals.trees.get(args.project, args.checkout, target)
1129             tree.add_defaults(options)
1130             target.package(args.project, args.checkout, output_dir, options)
1131         except Error as e:
1132             if target is not None and not args.keep:
1133                 target.cleanup()
1134             raise
1135
1136         if target is not None and not args.keep:
1137             target.cleanup()
1138
1139     elif globals.command == 'release':
1140         if args.minor is False and args.micro is False:
1141             raise Error('you must specify --minor or --micro')
1142
1143         target = SourceTarget()
1144         tree = globals.trees.get(args.project, args.checkout, target)
1145
1146         version = tree.version
1147         version.to_release()
1148         if args.minor:
1149             version.bump_minor()
1150         else:
1151             version.bump_micro()
1152
1153         with TreeDirectory(tree):
1154             if not args.no_version_commit:
1155                 set_version_in_wscript(version)
1156                 append_version_to_changelog(version)
1157                 append_version_to_debian_changelog(version)
1158                 command('git commit -a -m "Bump version"')
1159
1160             command('git tag -m "v%s" v%s' % (version, version))
1161
1162             if not args.no_version_commit:
1163                 version.to_devel()
1164                 set_version_in_wscript(version)
1165                 command('git commit -a -m "Bump version"')
1166                 command('git push')
1167
1168             command('git push --tags')
1169
1170         target.cleanup()
1171
1172     elif globals.command == 'pot':
1173         target = SourceTarget()
1174         tree = globals.trees.get(args.project, args.checkout, target)
1175
1176         pots = tree.call('make_pot')
1177         for p in pots:
1178             copyfile(p, '%s%s' % (args.output, os.path.basename(p)))
1179
1180         target.cleanup()
1181
1182     elif globals.command == 'changelog':
1183         target = SourceTarget()
1184         tree = globals.trees.get(args.project, args.checkout, target)
1185
1186         with TreeDirectory(tree):
1187             text = open('ChangeLog', 'r')
1188
1189         html = tempfile.NamedTemporaryFile()
1190         versions = 8
1191
1192         last = None
1193         changes = []
1194
1195         while True:
1196             l = text.readline()
1197             if l == '':
1198                 break
1199
1200             if len(l) > 0 and l[0] == "\t":
1201                 s = l.split()
1202                 if len(s) == 4 and s[1] == "Version" and s[3] == "released.":
1203                     v = Version(s[2])
1204                     if v.micro == 0:
1205                         if last is not None and len(changes) > 0:
1206                             print("<h2>Changes between version %s and %s</h2>" % (s[2], last), file=html)
1207                             print("<ul>", file=html)
1208                             for c in changes:
1209                                 print("<li>%s" % c, file=html)
1210                             print("</ul>", file=html)
1211                         last = s[2]
1212                         changes = []
1213                         versions -= 1
1214                         if versions < 0:
1215                             break
1216                 else:
1217                     c = l.strip()
1218                     if len(c) > 0:
1219                         if c[0] == '*':
1220                             changes.append(c[2:])
1221                         else:
1222                             changes[-1] += " " + c
1223
1224         copyfile(html.file, '%schangelog.html' % args.output)
1225         html.close()
1226         target.cleanup()
1227
1228     elif globals.command == 'manual':
1229         target = SourceTarget()
1230         tree = globals.trees.get(args.project, args.checkout, target)
1231
1232         outs = tree.call('make_manual')
1233         for o in outs:
1234             if os.path.isfile(o):
1235                 copyfile(o, '%s%s' % (args.output, os.path.basename(o)))
1236             else:
1237                 copytree(o, '%s%s' % (args.output, os.path.basename(o)))
1238
1239         target.cleanup()
1240
1241     elif globals.command == 'doxygen':
1242         target = SourceTarget()
1243         tree = globals.trees.get(args.project, args.checkout, target)
1244
1245         dirs = tree.call('make_doxygen')
1246         if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
1247             dirs = [dirs]
1248
1249         for d in dirs:
1250             copytree(d, args.output)
1251
1252         target.cleanup()
1253
1254     elif globals.command == 'latest':
1255         target = SourceTarget()
1256         tree = globals.trees.get(args.project, args.checkout, target)
1257
1258         with TreeDirectory(tree):
1259             f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
1260             latest = None
1261             line = 0
1262             while latest is None:
1263                 t = f[line]
1264                 line += 1
1265                 m = re.compile(".*\((.*)\).*").match(t)
1266                 if m:
1267                     tags = m.group(1).split(', ')
1268                     for t in tags:
1269                         s = t.split()
1270                         if len(s) > 1:
1271                             t = s[1]
1272                         if len(t) > 0 and t[0] == 'v':
1273                             v = Version(t[1:])
1274                             if (args.latest_major is None or v.major == args.latest_major) and (args.latest_minor is None or v.minor == args.latest_minor):
1275                                 latest = v
1276
1277         print(latest)
1278         target.cleanup()
1279
1280     elif globals.command == 'test':
1281         if args.target is None:
1282             raise Error('you must specify -t or --target')
1283
1284         target = None
1285         try:
1286             target = target_factory(args)
1287             tree = globals.trees.get(args.project, args.checkout, target)
1288             with TreeDirectory(tree):
1289                 target.test(tree, args.test, argument_options(args))
1290         except Error as e:
1291             if target is not None and not args.keep:
1292                 target.cleanup()
1293             raise
1294
1295         if target is not None and not args.keep:
1296             target.cleanup()
1297
1298     elif globals.command == 'shell':
1299         if args.target is None:
1300             raise Error('you must specify -t or --target')
1301
1302         target = target_factory(args)
1303         target.command('bash')
1304
1305     elif globals.command == 'revision':
1306
1307         target = SourceTarget()
1308         tree = globals.trees.get(args.project, args.checkout, target)
1309         with TreeDirectory(tree):
1310             print(command_and_read('git rev-parse HEAD')[0].strip()[:7])
1311         target.cleanup()
1312
1313     elif globals.command == 'checkout':
1314
1315         if args.output is None:
1316             raise Error('you must specify -o or --output')
1317
1318         target = SourceTarget()
1319         tree = globals.trees.get(args.project, args.checkout, target)
1320         with TreeDirectory(tree):
1321             shutil.copytree('.', args.output)
1322         target.cleanup()
1323
1324     else:
1325         raise Error('invalid command %s' % globals.command)
1326
1327 try:
1328     main()
1329 except Error as e:
1330     print('cdist: %s' % str(e), file=sys.stderr)
1331     sys.exit(1)