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