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