chiark / gitweb /
More PEP8 compliance (build.py, partial)
[fdroidserver.git] / fdroidserver / build.py
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3 #
4 # build.py - part of the FDroid server tools
5 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
6 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
7 #
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU Affero General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
12 #
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 # GNU Affero General Public License for more details.
17 #
18 # You should have received a copy of the GNU Affero General Public License
19 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
21 import sys
22 import os
23 import shutil
24 import subprocess
25 import re
26 import tarfile
27 import traceback
28 import time
29 import json
30 from ConfigParser import ConfigParser
31 from optparse import OptionParser, OptionError
32 import logging
33
34 import common
35 import metadata
36 from common import BuildException, VCSException, FDroidPopen, SilentPopen
37
38
39 def get_builder_vm_id():
40     vd = os.path.join('builder', '.vagrant')
41     if os.path.isdir(vd):
42         # Vagrant 1.2 (and maybe 1.1?) it's a directory tree...
43         with open(os.path.join(vd, 'machines', 'default',
44                   'virtualbox', 'id')) as vf:
45             id = vf.read()
46         return id
47     else:
48         # Vagrant 1.0 - it's a json file...
49         with open(os.path.join('builder', '.vagrant')) as vf:
50             v = json.load(vf)
51         return v['active']['default']
52
53
54 def got_valid_builder_vm():
55     """Returns True if we have a valid-looking builder vm
56     """
57     if not os.path.exists(os.path.join('builder', 'Vagrantfile')):
58         return False
59     vd = os.path.join('builder', '.vagrant')
60     if not os.path.exists(vd):
61         return False
62     if not os.path.isdir(vd):
63         # Vagrant 1.0 - if the directory is there, it's valid...
64         return True
65     # Vagrant 1.2 - the directory can exist, but the id can be missing...
66     if not os.path.exists(os.path.join(vd, 'machines', 'default',
67                           'virtualbox', 'id')):
68         return False
69     return True
70
71
72 def vagrant(params, cwd=None, printout=False):
73     """Run vagrant.
74
75     :param: list of parameters to pass to vagrant
76     :cwd: directory to run in, or None for current directory
77     :returns: (ret, out) where ret is the return code, and out
78                is the stdout (and stderr) from vagrant
79     """
80     p = FDroidPopen(['vagrant'] + params, cwd=cwd)
81     return (p.returncode, p.stdout)
82
83
84 # Note that 'force' here also implies test mode.
85 def build_server(app, thisbuild, vcs, build_dir, output_dir, force):
86     """Do a build on the build server."""
87
88     import paramiko
89     if options.verbose:
90         logging.getLogger("paramiko").setLevel(logging.DEBUG)
91     else:
92         logging.getLogger("paramiko").setLevel(logging.WARN)
93
94     # Reset existing builder machine to a clean state if possible.
95     vm_ok = False
96     if not options.resetserver:
97         logging.info("Checking for valid existing build server")
98
99         if got_valid_builder_vm():
100             logging.info("...VM is present")
101             p = FDroidPopen(['VBoxManage', 'snapshot',
102                              get_builder_vm_id(), 'list',
103                              '--details'], cwd='builder')
104             if 'fdroidclean' in p.stdout:
105                 logging.info("...snapshot exists - resetting build server to "
106                              "clean state")
107                 retcode, output = vagrant(['status'], cwd='builder')
108
109                 if 'running' in output:
110                     logging.info("...suspending")
111                     vagrant(['suspend'], cwd='builder')
112                     logging.info("...waiting a sec...")
113                     time.sleep(10)
114                 p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
115                                  'restore', 'fdroidclean'],
116                                 cwd='builder')
117
118                 if p.returncode == 0:
119                     logging.info("...reset to snapshot - server is valid")
120                     retcode, output = vagrant(['up'], cwd='builder')
121                     if retcode != 0:
122                         raise BuildException("Failed to start build server")
123                     logging.info("...waiting a sec...")
124                     time.sleep(10)
125                     vm_ok = True
126                 else:
127                     logging.info("...failed to reset to snapshot")
128             else:
129                 logging.info("...snapshot doesn't exist - "
130                              "VBoxManage snapshot list:\n" + p.stdout)
131
132     # If we can't use the existing machine for any reason, make a
133     # new one from scratch.
134     if not vm_ok:
135         if os.path.exists('builder'):
136             logging.info("Removing broken/incomplete/unwanted build server")
137             vagrant(['destroy', '-f'], cwd='builder')
138             shutil.rmtree('builder')
139         os.mkdir('builder')
140
141         p = subprocess.Popen('vagrant --version', shell=True,
142                              stdout=subprocess.PIPE)
143         vver = p.communicate()[0]
144         if vver.startswith('Vagrant version 1.2'):
145             with open('builder/Vagrantfile', 'w') as vf:
146                 vf.write('Vagrant.configure("2") do |config|\n')
147                 vf.write('config.vm.box = "buildserver"\n')
148                 vf.write('end\n')
149         else:
150             with open('builder/Vagrantfile', 'w') as vf:
151                 vf.write('Vagrant::Config.run do |config|\n')
152                 vf.write('config.vm.box = "buildserver"\n')
153                 vf.write('end\n')
154
155         logging.info("Starting new build server")
156         retcode, _ = vagrant(['up'], cwd='builder')
157         if retcode != 0:
158             raise BuildException("Failed to start build server")
159
160         # Open SSH connection to make sure it's working and ready...
161         logging.info("Connecting to virtual machine...")
162         if subprocess.call('vagrant ssh-config >sshconfig',
163                            cwd='builder', shell=True) != 0:
164             raise BuildException("Error getting ssh config")
165         vagranthost = 'default'  # Host in ssh config file
166         sshconfig = paramiko.SSHConfig()
167         sshf = open('builder/sshconfig', 'r')
168         sshconfig.parse(sshf)
169         sshf.close()
170         sshconfig = sshconfig.lookup(vagranthost)
171         sshs = paramiko.SSHClient()
172         sshs.set_missing_host_key_policy(paramiko.AutoAddPolicy())
173         idfile = sshconfig['identityfile']
174         if isinstance(idfile, list):
175             idfile = idfile[0]
176         elif idfile.startswith('"') and idfile.endswith('"'):
177             idfile = idfile[1:-1]
178         sshs.connect(sshconfig['hostname'], username=sshconfig['user'],
179                      port=int(sshconfig['port']), timeout=300,
180                      look_for_keys=False,
181                      key_filename=idfile)
182         sshs.close()
183
184         logging.info("Saving clean state of new build server")
185         retcode, _ = vagrant(['suspend'], cwd='builder')
186         if retcode != 0:
187             raise BuildException("Failed to suspend build server")
188         logging.info("...waiting a sec...")
189         time.sleep(10)
190         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
191                          'take', 'fdroidclean'],
192                         cwd='builder')
193         if p.returncode != 0:
194             raise BuildException("Failed to take snapshot")
195         logging.info("...waiting a sec...")
196         time.sleep(10)
197         logging.info("Restarting new build server")
198         retcode, _ = vagrant(['up'], cwd='builder')
199         if retcode != 0:
200             raise BuildException("Failed to start build server")
201         logging.info("...waiting a sec...")
202         time.sleep(10)
203         # Make sure it worked...
204         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
205                          'list', '--details'],
206                         cwd='builder')
207         if 'fdroidclean' not in p.stdout:
208             raise BuildException("Failed to take snapshot.")
209
210     try:
211
212         # Get SSH configuration settings for us to connect...
213         logging.info("Getting ssh configuration...")
214         subprocess.call('vagrant ssh-config >sshconfig',
215                         cwd='builder', shell=True)
216         vagranthost = 'default'  # Host in ssh config file
217
218         # Load and parse the SSH config...
219         sshconfig = paramiko.SSHConfig()
220         sshf = open('builder/sshconfig', 'r')
221         sshconfig.parse(sshf)
222         sshf.close()
223         sshconfig = sshconfig.lookup(vagranthost)
224
225         # Open SSH connection...
226         logging.info("Connecting to virtual machine...")
227         sshs = paramiko.SSHClient()
228         sshs.set_missing_host_key_policy(paramiko.AutoAddPolicy())
229         idfile = sshconfig['identityfile'][0]
230         if idfile.startswith('"') and idfile.endswith('"'):
231             idfile = idfile[1:-1]
232         sshs.connect(sshconfig['hostname'], username=sshconfig['user'],
233                      port=int(sshconfig['port']), timeout=300,
234                      look_for_keys=False, key_filename=idfile)
235
236         # Get an SFTP connection...
237         ftp = sshs.open_sftp()
238         ftp.get_channel().settimeout(15)
239
240         # Put all the necessary files in place...
241         ftp.chdir('/home/vagrant')
242
243         # Helper to copy the contents of a directory to the server...
244         def send_dir(path):
245             root = os.path.dirname(path)
246             main = os.path.basename(path)
247             ftp.mkdir(main)
248             for r, d, f in os.walk(path):
249                 rr = os.path.relpath(r, root)
250                 ftp.chdir(rr)
251                 for dd in d:
252                     ftp.mkdir(dd)
253                 for ff in f:
254                     lfile = os.path.join(root, rr, ff)
255                     if not os.path.islink(lfile):
256                         ftp.put(lfile, ff)
257                         ftp.chmod(ff, os.stat(lfile).st_mode)
258                 for i in range(len(rr.split('/'))):
259                     ftp.chdir('..')
260             ftp.chdir('..')
261
262         logging.info("Preparing server for build...")
263         serverpath = os.path.abspath(os.path.dirname(__file__))
264         ftp.put(os.path.join(serverpath, 'build.py'), 'build.py')
265         ftp.put(os.path.join(serverpath, 'common.py'), 'common.py')
266         ftp.put(os.path.join(serverpath, 'metadata.py'), 'metadata.py')
267         ftp.put(os.path.join(serverpath, '..', 'buildserver',
268                 'config.buildserver.py'), 'config.py')
269         ftp.chmod('config.py', 0o600)
270
271         # Copy over the ID (head commit hash) of the fdroidserver in use...
272         subprocess.call('git rev-parse HEAD >' +
273                         os.path.join(os.getcwd(), 'tmp', 'fdroidserverid'),
274                         shell=True, cwd=serverpath)
275         ftp.put('tmp/fdroidserverid', 'fdroidserverid')
276
277         # Copy the metadata - just the file for this app...
278         ftp.mkdir('metadata')
279         ftp.mkdir('srclibs')
280         ftp.chdir('metadata')
281         ftp.put(os.path.join('metadata', app['id'] + '.txt'),
282                 app['id'] + '.txt')
283         # And patches if there are any...
284         if os.path.exists(os.path.join('metadata', app['id'])):
285             send_dir(os.path.join('metadata', app['id']))
286
287         ftp.chdir('/home/vagrant')
288         # Create the build directory...
289         ftp.mkdir('build')
290         ftp.chdir('build')
291         ftp.mkdir('extlib')
292         ftp.mkdir('srclib')
293         # Copy any extlibs that are required...
294         if 'extlibs' in thisbuild:
295             ftp.chdir('/home/vagrant/build/extlib')
296             for lib in thisbuild['extlibs']:
297                 lib = lib.strip()
298                 libsrc = os.path.join('build/extlib', lib)
299                 if not os.path.exists(libsrc):
300                     raise BuildException("Missing extlib {0}".format(libsrc))
301                 lp = lib.split('/')
302                 for d in lp[:-1]:
303                     if d not in ftp.listdir():
304                         ftp.mkdir(d)
305                     ftp.chdir(d)
306                 ftp.put(libsrc, lp[-1])
307                 for _ in lp[:-1]:
308                     ftp.chdir('..')
309         # Copy any srclibs that are required...
310         srclibpaths = []
311         if 'srclibs' in thisbuild:
312             for lib in thisbuild['srclibs']:
313                 srclibpaths.append(
314                     common.getsrclib(lib, 'build/srclib', srclibpaths,
315                                      basepath=True, prepare=False))
316
317         # If one was used for the main source, add that too.
318         basesrclib = vcs.getsrclib()
319         if basesrclib:
320             srclibpaths.append(basesrclib)
321         for name, number, lib in srclibpaths:
322             logging.info("Sending srclib '%s'" % lib)
323             ftp.chdir('/home/vagrant/build/srclib')
324             if not os.path.exists(lib):
325                 raise BuildException("Missing srclib directory '" + lib + "'")
326             fv = '.fdroidvcs-' + name
327             ftp.put(os.path.join('build/srclib', fv), fv)
328             send_dir(lib)
329             # Copy the metadata file too...
330             ftp.chdir('/home/vagrant/srclibs')
331             ftp.put(os.path.join('srclibs', name + '.txt'),
332                     name + '.txt')
333         # Copy the main app source code
334         # (no need if it's a srclib)
335         if (not basesrclib) and os.path.exists(build_dir):
336             ftp.chdir('/home/vagrant/build')
337             fv = '.fdroidvcs-' + app['id']
338             ftp.put(os.path.join('build', fv), fv)
339             send_dir(build_dir)
340
341         # Execute the build script...
342         logging.info("Starting build...")
343         chan = sshs.get_transport().open_session()
344         chan.get_pty()
345         cmdline = 'python build.py --on-server'
346         if force:
347             cmdline += ' --force --test'
348         if options.verbose:
349             cmdline += ' --verbose'
350         cmdline += " %s:%s" % (app['id'], thisbuild['vercode'])
351         chan.exec_command('bash -c ". ~/.bsenv && ' + cmdline + '"')
352         output = ''
353         while not chan.exit_status_ready():
354             while chan.recv_ready():
355                 output += chan.recv(1024)
356             time.sleep(0.1)
357         logging.info("...getting exit status")
358         returncode = chan.recv_exit_status()
359         while True:
360             get = chan.recv(1024)
361             if len(get) == 0:
362                 break
363             output += get
364         if returncode != 0:
365             raise BuildException("Build.py failed on server for %s:%s" % (app['id'], thisbuild['version']), output)
366
367         # Retrieve the built files...
368         logging.info("Retrieving build output...")
369         if force:
370             ftp.chdir('/home/vagrant/tmp')
371         else:
372             ftp.chdir('/home/vagrant/unsigned')
373         apkfile = common.getapkname(app, thisbuild)
374         tarball = common.getsrcname(app, thisbuild)
375         try:
376             ftp.get(apkfile, os.path.join(output_dir, apkfile))
377             if not options.notarball:
378                 ftp.get(tarball, os.path.join(output_dir, tarball))
379         except:
380             raise BuildException("Build failed for %s:%s - missing output files" % (app['id'], thisbuild['version']), output)
381         ftp.close()
382
383     finally:
384
385         # Suspend the build server.
386         logging.info("Suspending build server")
387         subprocess.call(['vagrant', 'suspend'], cwd='builder')
388
389
390 def adapt_gradle(build_dir):
391     for root, dirs, files in os.walk(build_dir):
392         if 'build.gradle' in files:
393             path = os.path.join(root, 'build.gradle')
394             logging.info("Adapting build.gradle at %s" % path)
395
396             FDroidPopen(['sed', '-i',
397                     r's@buildToolsVersion\([ =]*\)["\'][0-9\.]*["\']@buildToolsVersion\1"'
398                     + config['build_tools'] + '"@g', path])
399
400
401 def build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver):
402     """Do a build locally."""
403
404     if thisbuild.get('buildjni') not in (None, ['no']):
405         if not config['ndk_path']:
406             logging.critical("$ANDROID_NDK is not set!")
407             sys.exit(3)
408         elif not os.path.isdir(config['sdk_path']):
409             logging.critical("$ANDROID_NDK points to a non-existing directory!")
410             sys.exit(3)
411
412     # Prepare the source code...
413     root_dir, srclibpaths = common.prepare_source(vcs, app, thisbuild,
414             build_dir, srclib_dir, extlib_dir, onserver)
415
416     # We need to clean via the build tool in case the binary dirs are
417     # different from the default ones
418     p = None
419     if thisbuild['type'] == 'maven':
420         logging.info("Cleaning Maven project...")
421         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
422
423         if '@' in thisbuild['maven']:
424             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@',1)[1])
425             maven_dir = os.path.normpath(maven_dir)
426         else:
427             maven_dir = root_dir
428
429         p = FDroidPopen(cmd, cwd=maven_dir)
430
431     elif thisbuild['type'] == 'gradle':
432
433         logging.info("Cleaning Gradle project...")
434         cmd = [config['gradle'], 'clean']
435
436         if '@' in thisbuild['gradle']:
437             gradle_dir = os.path.join(root_dir, thisbuild['gradle'].split('@',1)[1])
438             gradle_dir = os.path.normpath(gradle_dir)
439         else:
440             gradle_dir = root_dir
441
442         adapt_gradle(build_dir)
443         for name, number, libpath in srclibpaths:
444             adapt_gradle(libpath)
445
446         p = FDroidPopen(cmd, cwd=gradle_dir)
447
448     elif thisbuild['type'] == 'kivy':
449         pass
450
451     elif thisbuild['type'] == 'ant':
452         logging.info("Cleaning Ant project...")
453         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
454
455     if p is not None and p.returncode != 0:
456         raise BuildException("Error cleaning %s:%s" %
457                 (app['id'], thisbuild['version']), p.stdout)
458
459     logging.info("Getting rid of Gradle wrapper binaries...")
460     for root, dirs, files in os.walk(build_dir):
461         # Don't remove possibly necessary 'gradle' dirs if 'gradlew' is not there
462         if 'gradlew' in files:
463             os.remove(os.path.join(root, 'gradlew'))
464             if 'gradlew.bat' in files:
465                 os.remove(os.path.join(root, 'gradlew.bat'))
466             if 'gradle' in dirs:
467                 shutil.rmtree(os.path.join(root, 'gradle'))
468
469     if not options.skipscan:
470         # Scan before building...
471         logging.info("Scanning source for common problems...")
472         count = common.scan_source(build_dir, root_dir, thisbuild)
473         if count > 0:
474             if force:
475                 logging.warn('Scanner found %d problems:' % count)
476             else:
477                 raise BuildException("Can't build due to %d errors while scanning" % count)
478
479     if not options.notarball:
480         # Build the source tarball right before we build the release...
481         logging.info("Creating source tarball...")
482         tarname = common.getsrcname(app, thisbuild)
483         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
484
485         def tarexc(f):
486             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
487         tarball.add(build_dir, tarname, exclude=tarexc)
488         tarball.close()
489
490     if onserver:
491         manifest = os.path.join(root_dir, 'AndroidManifest.xml')
492         if os.path.exists(manifest):
493             with open('/home/vagrant/buildserverid', 'r') as f:
494                 buildserverid = f.read()
495             with open('/home/vagrant/fdroidserverid', 'r') as f:
496                 fdroidserverid = f.read()
497             with open(manifest, 'r') as f:
498                 manifestcontent = f.read()
499             manifestcontent = manifestcontent.replace('</manifest>',
500                     '<fdroid buildserverid="' + buildserverid + '"' + 
501                     ' fdroidserverid="' + fdroidserverid + '"' +
502                     '/></manifest>')
503             with open(manifest, 'w') as f:
504                 f.write(manifestcontent)
505
506     # Run a build command if one is required...
507     if 'build' in thisbuild:
508         cmd = common.replace_config_vars(thisbuild['build'])
509         # Substitute source library paths into commands...
510         for name, number, libpath in srclibpaths:
511             libpath = os.path.relpath(libpath, root_dir)
512             cmd = cmd.replace('$$' + name + '$$', libpath)
513         logging.info("Running 'build' commands in %s" % root_dir)
514
515         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
516
517         if p.returncode != 0:
518             raise BuildException("Error running build command for %s:%s" %
519                     (app['id'], thisbuild['version']), p.stdout)
520
521     # Build native stuff if required...
522     if thisbuild.get('buildjni') not in (None, ['no']):
523         logging.info("Building native libraries...")
524         jni_components = thisbuild.get('buildjni')
525         if jni_components == ['yes']:
526             jni_components = ['']
527         cmd = [os.path.join(config['ndk_path'], "ndk-build"), "-j1"]
528         for d in jni_components:
529             if d:
530                 logging.info("Building native code in '%s'" % d)
531             else:
532                 logging.info("Building native code in the main project")
533             manifest = root_dir + '/' + d + '/AndroidManifest.xml'
534             if os.path.exists(manifest):
535                 # Read and write the whole AM.xml to fix newlines and avoid
536                 # the ndk r8c or later 'wordlist' errors. The outcome of this
537                 # under gnu/linux is the same as when using tools like
538                 # dos2unix, but the native python way is faster and will
539                 # work in non-unix systems.
540                 manifest_text = open(manifest, 'U').read()
541                 open(manifest, 'w').write(manifest_text)
542                 # In case the AM.xml read was big, free the memory
543                 del manifest_text
544             p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
545             if p.returncode != 0:
546                 raise BuildException("NDK build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout)
547
548     p = None
549     # Build the release...
550     if thisbuild['type'] == 'maven':
551         logging.info("Building Maven project...")
552
553         if '@' in thisbuild['maven']:
554             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@', 1)[1])
555         else:
556             maven_dir = root_dir
557
558         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
559                 '-Dmaven.jar.sign.skip=true', '-Dmaven.test.skip=true',
560                 '-Dandroid.sign.debug=false', '-Dandroid.release=true',
561                 'package']
562         if 'target' in thisbuild:
563             target = thisbuild["target"].split('-')[1]
564             FDroidPopen(['sed', '-i',
565                     's@<platform>[0-9]*</platform>@<platform>'+target+'</platform>@g',
566                     'pom.xml'], cwd=root_dir)
567             if '@' in thisbuild['maven']:
568                 FDroidPopen(['sed', '-i',
569                         's@<platform>[0-9]*</platform>@<platform>'+target+'</platform>@g',
570                         'pom.xml'], cwd=maven_dir)
571
572         if 'mvnflags' in thisbuild:
573             mvncmd += thisbuild['mvnflags']
574
575         p = FDroidPopen(mvncmd, cwd=maven_dir)
576
577         bindir = os.path.join(root_dir, 'target')
578
579     elif thisbuild['type'] == 'kivy':
580         logging.info("Building Kivy project...")
581
582         spec = os.path.join(root_dir, 'buildozer.spec')
583         if not os.path.exists(spec):
584             raise BuildException("Expected to find buildozer-compatible spec at {0}"
585                     .format(spec))
586
587         defaults = {'orientation': 'landscape', 'icon': '',
588                     'permissions': '', 'android.api': "18"}
589         bconfig = ConfigParser(defaults, allow_no_value=True)
590         bconfig.read(spec)
591
592         distdir = 'python-for-android/dist/fdroid'
593         if os.path.exists(distdir):
594             shutil.rmtree(distdir)
595
596         modules = bconfig.get('app', 'requirements').split(',')
597
598         cmd = 'ANDROIDSDK=' + config['sdk_path']
599         cmd += ' ANDROIDNDK=' + config['ndk_path']
600         cmd += ' ANDROIDNDKVER=r9'
601         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
602         cmd += ' VIRTUALENV=virtualenv'
603         cmd += ' ./distribute.sh'
604         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
605         cmd += ' -d fdroid'
606         p = FDroidPopen(cmd, cwd='python-for-android', shell=True)
607         if p.returncode != 0:
608             raise BuildException("Distribute build failed")
609
610         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
611         if cid != app['id']:
612             raise BuildException("Package ID mismatch between metadata and spec")
613
614         orientation = bconfig.get('app', 'orientation', 'landscape')
615         if orientation == 'all':
616             orientation = 'sensor'
617
618         cmd = ['./build.py'
619                '--dir', root_dir,
620                '--name', bconfig.get('app', 'title'),
621                '--package', app['id'],
622                '--version', bconfig.get('app', 'version'),
623                '--orientation', orientation
624               ]
625
626         perms = bconfig.get('app', 'permissions')
627         for perm in perms.split(','):
628             cmd.extend(['--permission', perm])
629
630         if config.get('app', 'fullscreen') == 0:
631             cmd.append('--window')
632
633         icon = bconfig.get('app', 'icon.filename')
634         if icon:
635             cmd.extend(['--icon', os.path.join(root_dir, icon)])
636
637         cmd.append('release')
638         p = FDroidPopen(cmd, cwd=distdir)
639
640     elif thisbuild['type'] == 'gradle':
641         logging.info("Building Gradle project...")
642         if '@' in thisbuild['gradle']:
643             flavours = thisbuild['gradle'].split('@')[0].split(',')
644             gradle_dir = thisbuild['gradle'].split('@')[1]
645             gradle_dir = os.path.join(root_dir, gradle_dir)
646         else:
647             flavours = thisbuild['gradle'].split(',')
648             gradle_dir = root_dir
649
650         if len(flavours) == 1 and flavours[0] in ['main', 'yes', '']:
651             flavours[0] = ''
652
653         commands = [config['gradle']]
654         if 'preassemble' in thisbuild:
655             commands += thisbuild['preassemble'].split()
656
657         flavours_cmd = ''.join(flavours)
658         if flavours_cmd:
659             flavours_cmd = flavours_cmd[0].upper() + flavours_cmd[1:]
660
661         commands += ['assemble'+flavours_cmd+'Release']
662
663         p = FDroidPopen(commands, cwd=gradle_dir)
664
665     elif thisbuild['type'] == 'ant':
666         logging.info("Building Ant project...")
667         cmd = ['ant']
668         if 'antcommand' in thisbuild:
669             cmd += [thisbuild['antcommand']]
670         else:
671             cmd += ['release']
672         p = FDroidPopen(cmd, cwd=root_dir)
673
674         bindir = os.path.join(root_dir, 'bin')
675
676     if p is not None and p.returncode != 0:
677         raise BuildException("Build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout)
678     logging.info("Successfully built version " + thisbuild['version'] + ' of ' + app['id'])
679
680     if thisbuild['type'] == 'maven':
681         stdout_apk = '\n'.join([
682             line for line in p.stdout.splitlines() if any(a in line for a in ('.apk','.ap_'))])
683         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
684                 stdout_apk, re.S|re.M)
685         if not m:
686             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
687                     stdout_apk, re.S|re.M)
688         if not m:
689             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
690                     stdout_apk, re.S|re.M)
691         if not m:
692             raise BuildException('Failed to find output')
693         src = m.group(1)
694         src = os.path.join(bindir, src) + '.apk'
695     elif thisbuild['type'] == 'kivy':
696         src = 'python-for-android/dist/default/bin/{0}-{1}-release.apk'.format(
697                 bconfig.get('app', 'title'), bconfig.get('app', 'version'))
698     elif thisbuild['type'] == 'gradle':
699         basename = app['id']
700         dd = build_dir
701         if 'subdir' in thisbuild:
702             dd = os.path.join(dd, thisbuild['subdir'])
703             basename = os.path.basename(thisbuild['subdir'])
704         if '@' in thisbuild['gradle']:
705             dd = os.path.join(dd, thisbuild['gradle'].split('@')[1])
706             basename = app['id']
707         if len(flavours) == 1 and flavours[0] == '':
708             name = '-'.join([basename, 'release', 'unsigned'])
709         else:
710             name = '-'.join([basename, '-'.join(flavours), 'release', 'unsigned'])
711         dd = os.path.normpath(dd)
712         src = os.path.join(dd, 'build', 'apk', name+'.apk')
713     elif thisbuild['type'] == 'ant':
714         stdout_apk = '\n'.join([
715             line for line in p.stdout.splitlines() if '.apk' in line])
716         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
717             re.S|re.M).group(1)
718         src = os.path.join(bindir, src)
719     elif thisbuild['type'] == 'raw':
720         src = os.path.join(root_dir, thisbuild['output'])
721         src = os.path.normpath(src)
722
723     # Make sure it's not debuggable...
724     if common.isApkDebuggable(src, config):
725         raise BuildException("APK is debuggable")
726
727     # By way of a sanity check, make sure the version and version
728     # code in our new apk match what we expect...
729     logging.info("Checking " + src)
730     if not os.path.exists(src):
731         raise BuildException("Unsigned apk is not at expected location of " + src)
732
733     p = SilentPopen([os.path.join(config['sdk_path'],
734         'build-tools', config['build_tools'], 'aapt'),
735         'dump', 'badging', src])
736
737     vercode = None
738     version = None
739     foundid = None
740     nativecode = None
741     for line in p.stdout.splitlines():
742         if line.startswith("package:"):
743             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
744             m = pat.match(line)
745             if m:
746                 foundid = m.group(1)
747             pat = re.compile(".*versionCode='([0-9]*)'.*")
748             m = pat.match(line)
749             if m:
750                 vercode = m.group(1)
751             pat = re.compile(".*versionName='([^']*)'.*")
752             m = pat.match(line)
753             if m:
754                 version = m.group(1)
755         elif line.startswith("native-code:"):
756             nativecode = line[12:]
757
758     if thisbuild.get('buildjni') is not None:
759         if nativecode is None or "'" not in nativecode:
760             raise BuildException("Native code should have been built but none was packaged")
761     if thisbuild['novcheck']:
762         vercode = thisbuild['vercode']
763         version = thisbuild['version']
764     if not version or not vercode:
765         raise BuildException("Could not find version information in build in output")
766     if not foundid:
767         raise BuildException("Could not find package ID in output")
768     if foundid != app['id']:
769         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app['id'])
770
771     # Some apps (e.g. Timeriffic) have had the bonkers idea of
772     # including the entire changelog in the version number. Remove
773     # it so we can compare. (TODO: might be better to remove it
774     # before we compile, in fact)
775     index = version.find(" //")
776     if index != -1:
777         version = version[:index]
778
779     if (version != thisbuild['version'] or
780             vercode != thisbuild['vercode']):
781         raise BuildException(("Unexpected version/version code in output;"
782                              " APK: '%s' / '%s', "
783                              " Expected: '%s' / '%s'")
784                              % (version, str(vercode), thisbuild['version'], str(thisbuild['vercode']))
785                             )
786
787     # Copy the unsigned apk to our destination directory for further
788     # processing (by publish.py)...
789     dest = os.path.join(output_dir, common.getapkname(app, thisbuild))
790     shutil.copyfile(src, dest)
791
792     # Move the source tarball into the output directory...
793     if output_dir != tmp_dir and not options.notarball:
794         shutil.move(os.path.join(tmp_dir, tarname),
795                     os.path.join(output_dir, tarname))
796
797
798 def trybuild(app, thisbuild, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
799         tmp_dir, repo_dir, vcs, test, server, force, onserver):
800     """
801     Build a particular version of an application, if it needs building.
802
803     :param output_dir: The directory where the build output will go. Usually
804        this is the 'unsigned' directory.
805     :param repo_dir: The repo directory - used for checking if the build is
806        necessary.
807     :paaram also_check_dir: An additional location for checking if the build
808        is necessary (usually the archive repo)
809     :param test: True if building in test mode, in which case the build will
810        always happen, even if the output already exists. In test mode, the
811        output directory should be a temporary location, not any of the real
812        ones.
813
814     :returns: True if the build was done, False if it wasn't necessary.
815     """
816
817     dest_apk = common.getapkname(app, thisbuild)
818
819     dest = os.path.join(output_dir, dest_apk)
820     dest_repo = os.path.join(repo_dir, dest_apk)
821
822     if not test:
823         if os.path.exists(dest) or os.path.exists(dest_repo):
824             return False
825
826         if also_check_dir:
827             dest_also = os.path.join(also_check_dir, dest_apk)
828             if os.path.exists(dest_also):
829                 return False
830
831     if 'disable' in thisbuild:
832         return False
833
834     logging.info("Building version " + thisbuild['version'] + ' of ' + app['id'])
835
836     if server:
837         # When using server mode, still keep a local cache of the repo, by
838         # grabbing the source now.
839         vcs.gotorevision(thisbuild['commit'])
840
841         build_server(app, thisbuild, vcs, build_dir, output_dir, force)
842     else:
843         build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver)
844     return True
845
846
847 def parse_commandline():
848     """Parse the command line. Returns options, args."""
849
850     parser = OptionParser(usage="Usage: %prog [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
851     parser.add_option("-v", "--verbose", action="store_true", default=False,
852                       help="Spew out even more information than normal")
853     parser.add_option("-q", "--quiet", action="store_true", default=False,
854                       help="Restrict output to warnings and errors")
855     parser.add_option("-l", "--latest", action="store_true", default=False,
856                       help="Build only the latest version of each package")
857     parser.add_option("-s", "--stop", action="store_true", default=False,
858                       help="Make the build stop on exceptions")
859     parser.add_option("-t", "--test", action="store_true", default=False,
860                       help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
861     parser.add_option("--server", action="store_true", default=False,
862                       help="Use build server")
863     parser.add_option("--resetserver", action="store_true", default=False,
864                       help="Reset and create a brand new build server, even if the existing one appears to be ok.")
865     parser.add_option("--on-server", dest="onserver", action="store_true", default=False,
866                       help="Specify that we're running on the build server")
867     parser.add_option("--skip-scan", dest="skipscan", action="store_true", default=False,
868                       help="Skip scanning the source code for binaries and other problems")
869     parser.add_option("--no-tarball", dest="notarball", action="store_true", default=False,
870                       help="Don't create a source tarball, useful when testing a build")
871     parser.add_option("-f", "--force", action="store_true", default=False,
872                       help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
873     parser.add_option("-a", "--all", action="store_true", default=False,
874                       help="Build all applications available")
875     parser.add_option("-w", "--wiki", default=False, action="store_true",
876                       help="Update the wiki")
877     options, args = parser.parse_args()
878
879     # Force --stop with --on-server to get correct exit code
880     if options.onserver:
881         options.stop = True
882
883     if options.force and not options.test:
884         raise OptionError("Force is only allowed in test mode", "force")
885
886     return options, args
887
888 options = None
889 config = None
890
891
892 def main():
893
894     global options, config
895
896     options, args = parse_commandline()
897     if not args and not options.all:
898         raise OptionError("If you really want to build all the apps, use --all", "all")
899
900     config = common.read_config(options)
901
902     if config['build_server_always']:
903         options.server = True
904     if options.resetserver and not options.server:
905         raise OptionError("Using --resetserver without --server makes no sense", "resetserver")
906
907     log_dir = 'logs'
908     if not os.path.isdir(log_dir):
909         logging.info("Creating log directory")
910         os.makedirs(log_dir)
911
912     tmp_dir = 'tmp'
913     if not os.path.isdir(tmp_dir):
914         logging.info("Creating temporary directory")
915         os.makedirs(tmp_dir)
916
917     if options.test:
918         output_dir = tmp_dir
919     else:
920         output_dir = 'unsigned'
921         if not os.path.isdir(output_dir):
922             logging.info("Creating output directory")
923             os.makedirs(output_dir)
924
925     if config['archive_older'] != 0:
926         also_check_dir = 'archive'
927     else:
928         also_check_dir = None
929
930     repo_dir = 'repo'
931
932     build_dir = 'build'
933     if not os.path.isdir(build_dir):
934         logging.info("Creating build directory")
935         os.makedirs(build_dir)
936     srclib_dir = os.path.join(build_dir, 'srclib')
937     extlib_dir = os.path.join(build_dir, 'extlib')
938
939     # Get all apps...
940     allapps = metadata.read_metadata(xref=not options.onserver)
941
942     apps = common.read_app_args(args, allapps, True)
943     apps = [app for app in apps if (options.force or not app['Disabled']) and
944             len(app['Repo Type']) > 0 and len(app['builds']) > 0]
945
946     if len(apps) == 0:
947         raise Exception("No apps to process.")
948
949     if options.latest:
950         for app in apps:
951             for build in reversed(app['builds']):
952                 if 'disable' in build:
953                     continue
954                 app['builds'] = [build]
955                 break
956
957     if options.wiki:
958         import mwclient
959         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
960                 path=config['wiki_path'])
961         site.login(config['wiki_user'], config['wiki_password'])
962
963     # Build applications...
964     failed_apps = {}
965     build_succeeded = []
966     for app in apps:
967
968         first = True
969
970         for thisbuild in app['builds']:
971             wikilog = None
972             try:
973
974                 # For the first build of a particular app, we need to set up
975                 # the source repo. We can reuse it on subsequent builds, if
976                 # there are any.
977                 if first:
978                     if app['Repo Type'] == 'srclib':
979                         build_dir = os.path.join('build', 'srclib', app['Repo'])
980                     else:
981                         build_dir = os.path.join('build', app['id'])
982
983                     # Set up vcs interface and make sure we have the latest code...
984                     logging.debug("Getting {0} vcs interface for {1}".format(
985                             app['Repo Type'], app['Repo']))
986                     vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
987
988                     first = False
989
990                 logging.debug("Checking " + thisbuild['version'])
991                 if trybuild(app, thisbuild, build_dir, output_dir, also_check_dir,
992                         srclib_dir, extlib_dir, tmp_dir, repo_dir, vcs, options.test,
993                         options.server, options.force, options.onserver):
994                     build_succeeded.append(app)
995                     wikilog = "Build succeeded"
996             except BuildException as be:
997                 logfile = open(os.path.join(log_dir, app['id'] + '.log'), 'a+')
998                 logfile.write(str(be))
999                 logfile.close()
1000                 reason = str(be).split('\n', 1)[0] if options.verbose else str(be)
1001                 print("Could not build app %s due to BuildException: %s" % (
1002                     app['id'], reason))
1003                 if options.stop:
1004                     sys.exit(1)
1005                 failed_apps[app['id']] = be
1006                 wikilog = be.get_wikitext()
1007             except VCSException as vcse:
1008                 print("VCS error while building app %s: %s" % (app['id'], vcse))
1009                 if options.stop:
1010                     sys.exit(1)
1011                 failed_apps[app['id']] = vcse
1012                 wikilog = str(vcse)
1013             except Exception as e:
1014                 print("Could not build app %s due to unknown error: %s" % (app['id'], traceback.format_exc()))
1015                 if options.stop:
1016                     sys.exit(1)
1017                 failed_apps[app['id']] = e
1018                 wikilog = str(e)
1019
1020             if options.wiki and wikilog:
1021                 try:
1022                     newpage = site.Pages[app['id'] + '/lastbuild']
1023                     txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + wikilog
1024                     newpage.save(txt, summary='Build log')
1025                 except:
1026                     logging.info("Error while attempting to publish build log")
1027
1028     for app in build_succeeded:
1029         logging.info("success: %s" % (app['id']))
1030
1031     if not options.verbose:
1032         for fa in failed_apps:
1033             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1034
1035     logging.info("Finished.")
1036     if len(build_succeeded) > 0:
1037         logging.info(str(len(build_succeeded)) + ' builds succeeded')
1038     if len(failed_apps) > 0:
1039         logging.info(str(len(failed_apps)) + ' builds failed')
1040
1041     sys.exit(0)
1042
1043 if __name__ == "__main__":
1044     main()