blob: 244c093c4f86a93131bacbd675950522d0ab10b2 [file] [log] [blame]
Søren Gjessecdae8792018-12-12 09:02:43 +01001#!/usr/bin/env python
2# Copyright (c) 2018, the R8 project authors. Please see the AUTHORS file
3# for details. All rights reserved. Use of this source code is governed by a
4# BSD-style license that can be found in the LICENSE file.
5
6import apk_utils
7import os
8import optparse
9import subprocess
10import sys
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +010011import time
Søren Gjessecdae8792018-12-12 09:02:43 +010012import utils
Christoffer Quist Adamsen404aade2018-12-20 13:00:09 +010013import zipfile
Søren Gjessecdae8792018-12-12 09:02:43 +010014
Christoffer Quist Adamsen10b7db82018-12-13 14:50:38 +010015import as_utils
16
Christoffer Quist Adamsenf8ad4792019-01-09 13:19:19 +010017SHRINKERS = ['r8', 'r8full', 'r8-minified', 'r8full-minified', 'proguard']
Christoffer Quist Adamsen10b7db82018-12-13 14:50:38 +010018WORKING_DIR = utils.BUILD
19
20if 'R8_BENCHMARK_DIR' in os.environ and os.path.isdir(os.environ['R8_BENCHMARK_DIR']):
21 WORKING_DIR = os.environ['R8_BENCHMARK_DIR']
Søren Gjessecdae8792018-12-12 09:02:43 +010022
23APPS = {
24 # 'app-name': {
25 # 'git_repo': ...
26 # 'app_module': ... (default app)
27 # 'archives_base_name': ... (default same as app_module)
28 # 'flavor': ... (default no flavor)
Søren Gjesse8c111482018-12-21 14:47:57 +010029 # 'releaseTarget': ... (default <app_module>:assemble<flavor>Release
Søren Gjessecdae8792018-12-12 09:02:43 +010030 # },
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +010031 'AnExplorer': {
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +010032 'app_id': 'dev.dworks.apps.anexplorer.pro',
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +010033 'git_repo': 'https://github.com/1hakr/AnExplorer',
34 'flavor': 'googleMobilePro',
35 'signed-apk-name': 'AnExplorer-googleMobileProRelease-4.0.3.apk',
36 },
Christoffer Quist Adamsen10b7db82018-12-13 14:50:38 +010037 'AntennaPod': {
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +010038 'app_id': 'de.danoeh.antennapod',
Christoffer Quist Adamsen10b7db82018-12-13 14:50:38 +010039 'git_repo': 'https://github.com/AntennaPod/AntennaPod.git',
40 'flavor': 'play',
41 },
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +010042 'apps-android-wikipedia': {
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +010043 'app_id': 'org.wikipedia',
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +010044 'git_repo': 'https://github.com/wikimedia/apps-android-wikipedia',
45 'flavor': 'prod',
46 'signed-apk-name': 'app-prod-universal-release.apk'
47 },
Christoffer Quist Adamsen61b8c802018-12-20 08:18:40 +010048 'friendlyeats-android': {
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +010049 'app_id': 'com.google.firebase.example.fireeats',
Christoffer Quist Adamsen61b8c802018-12-20 08:18:40 +010050 'git_repo': 'https://github.com/firebase/friendlyeats-android.git'
51 },
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +010052 'KISS': {
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +010053 'app_id': 'fr.neamar.kiss',
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +010054 'git_repo': 'https://github.com/Neamar/KISS',
55 },
56 'materialistic': {
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +010057 'app_id': 'io.github.hidroh.materialistic',
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +010058 'git_repo': 'https://github.com/hidroh/materialistic',
59 },
60 'Minimal-Todo': {
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +010061 'app_id': 'com.avjindersinghsekhon.minimaltodo',
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +010062 'git_repo': 'https://github.com/avjinder/Minimal-Todo',
63 },
64 'NewPipe': {
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +010065 'app_id': 'org.schabi.newpipe',
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +010066 'git_repo': 'https://github.com/TeamNewPipe/NewPipe',
67 },
68 'Simple-Calendar': {
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +010069 'app_id': 'com.simplemobiletools.calendar.pro',
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +010070 'git_repo': 'https://github.com/SimpleMobileTools/Simple-Calendar',
71 'signed-apk-name': 'calendar-release.apk'
72 },
Søren Gjessecdae8792018-12-12 09:02:43 +010073 'tachiyomi': {
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +010074 'app_id': 'eu.kanade.tachiyomi',
Søren Gjessecdae8792018-12-12 09:02:43 +010075 'git_repo': 'https://github.com/sgjesse/tachiyomi.git',
76 'flavor': 'standard',
Søren Gjesse8c111482018-12-21 14:47:57 +010077 'releaseTarget': 'app:assembleRelease',
Søren Gjessecdae8792018-12-12 09:02:43 +010078 },
79 # This does not build yet.
80 'muzei': {
81 'git_repo': 'https://github.com/sgjesse/muzei.git',
82 'app_module': 'main',
83 'archives_base_name': 'muzei',
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +010084 'skip': True,
Søren Gjessecdae8792018-12-12 09:02:43 +010085 },
86}
87
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +010088# Common environment setup.
89user_home = os.path.expanduser('~')
90android_home = os.path.join(user_home, 'Android', 'Sdk')
91android_build_tools_version = '28.0.3'
92android_build_tools = os.path.join(
93 android_home, 'build-tools', android_build_tools_version)
94
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +010095# TODO(christofferqa): Do not rely on 'emulator-5554' name
96emulator_id = 'emulator-5554'
97
Christoffer Quist Adamsen404aade2018-12-20 13:00:09 +010098def ComputeSizeOfDexFilesInApk(apk):
99 dex_size = 0
100 z = zipfile.ZipFile(apk, 'r')
101 for filename in z.namelist():
102 if filename.endswith('.dex'):
103 dex_size += z.getinfo(filename).file_size
104 return dex_size
105
Christoffer Quist Adamsen10b7db82018-12-13 14:50:38 +0100106def IsBuiltWithR8(apk):
107 script = os.path.join(utils.TOOLS_DIR, 'extractmarker.py')
108 return '~~R8' in subprocess.check_output(['python', script, apk]).strip()
109
Christoffer Quist Adamsenf8ad4792019-01-09 13:19:19 +0100110def IsMinifiedR8(shrinker):
111 return shrinker == 'r8-minified' or shrinker == 'r8full-minified'
112
Christoffer Quist Adamsen10b7db82018-12-13 14:50:38 +0100113def IsTrackedByGit(file):
114 return subprocess.check_output(['git', 'ls-files', file]).strip() != ''
115
Søren Gjessecdae8792018-12-12 09:02:43 +0100116def GitClone(git_url):
117 return subprocess.check_output(['git', 'clone', git_url]).strip()
118
119def GitPull():
Christoffer Quist Adamsen01c7f0b2019-01-10 10:59:16 +0100120 # Use --no-edit to accept the auto-generated merge message, if any.
121 return subprocess.call(['git', 'pull', '--no-edit']) == 0
Søren Gjessecdae8792018-12-12 09:02:43 +0100122
123def GitCheckout(file):
124 return subprocess.check_output(['git', 'checkout', file]).strip()
125
Christoffer Quist Adamsen10b7db82018-12-13 14:50:38 +0100126def MoveApkToDest(apk, apk_dest):
127 print('Moving `{}` to `{}`'.format(apk, apk_dest))
128 assert os.path.isfile(apk)
129 if os.path.isfile(apk_dest):
130 os.remove(apk_dest)
131 os.rename(apk, apk_dest)
132
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +0100133def InstallApkOnEmulator(apk_dest):
134 subprocess.check_call(
135 ['adb', '-s', emulator_id, 'install', '-r', '-d', apk_dest])
136
137def WaitForEmulator():
138 stdout = subprocess.check_output(['adb', 'devices'])
139 if '{}\tdevice'.format(emulator_id) in stdout:
140 return
141
142 print('Emulator \'{}\' not connected; waiting for connection'.format(
143 emulator_id))
144
145 time_waited = 0
146 while True:
147 time.sleep(10)
148 time_waited += 10
149 stdout = subprocess.check_output(['adb', 'devices'])
150 if '{}\tdevice'.format(emulator_id) not in stdout:
151 print('... still waiting for connection')
152 if time_waited >= 5 * 60:
153 raise Exception('No emulator connected for 5 minutes')
154 else:
155 return
156
Christoffer Quist Adamsen724bfb02019-01-10 09:54:38 +0100157def GetResultsForApp(app, config, options):
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +0100158 git_repo = config['git_repo']
159
160 # Checkout and build in the build directory.
161 checkout_dir = os.path.join(WORKING_DIR, app)
162
Christoffer Quist Adamsen724bfb02019-01-10 09:54:38 +0100163 result = {}
164
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +0100165 if not os.path.exists(checkout_dir):
166 with utils.ChangedWorkingDirectory(WORKING_DIR):
167 GitClone(git_repo)
Christoffer Quist Adamsen860fa932019-01-10 14:27:39 +0100168 elif options.pull:
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +0100169 with utils.ChangedWorkingDirectory(checkout_dir):
Christoffer Quist Adamsen860fa932019-01-10 14:27:39 +0100170 # Checkout build.gradle to avoid merge conflicts.
171 if IsTrackedByGit('build.gradle'):
172 GitCheckout('build.gradle')
173
Christoffer Quist Adamsen724bfb02019-01-10 09:54:38 +0100174 if not GitPull():
175 result['status'] = 'failed'
176 result['error_message'] = 'Unable to pull from remote'
177 return result
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +0100178
Christoffer Quist Adamsen724bfb02019-01-10 09:54:38 +0100179 result['status'] = 'success'
180
181 result_per_shrinker = BuildAppWithSelectedShrinkers(
182 app, config, options, checkout_dir)
183 for shrinker, shrinker_result in result_per_shrinker.iteritems():
184 result[shrinker] = shrinker_result
185
186 return result
187
188def BuildAppWithSelectedShrinkers(app, config, options, checkout_dir):
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +0100189 result_per_shrinker = {}
Christoffer Quist Adamsen404aade2018-12-20 13:00:09 +0100190
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +0100191 with utils.ChangedWorkingDirectory(checkout_dir):
192 for shrinker in SHRINKERS:
Christoffer Quist Adamsen860fa932019-01-10 14:27:39 +0100193 if options.shrinker and shrinker not in options.shrinker:
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +0100194 continue
195
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +0100196 apk_dest = None
197 result = {}
198 try:
199 apk_dest = BuildAppWithShrinker(
200 app, config, shrinker, checkout_dir, options)
201 dex_size = ComputeSizeOfDexFilesInApk(apk_dest)
202 result['apk_dest'] = apk_dest,
203 result['build_status'] = 'success'
204 result['dex_size'] = dex_size
205 except:
206 warn('Failed to build {} with {}'.format(app, shrinker))
207 result['build_status'] = 'failed'
Christoffer Quist Adamsen404aade2018-12-20 13:00:09 +0100208
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +0100209 if options.monkey:
210 if result.get('build_status') == 'success':
211 result['monkey_status'] = 'success' if RunMonkey(
212 app, config, apk_dest) else 'failed'
213
214 result_per_shrinker[shrinker] = result
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +0100215
216 if IsTrackedByGit('gradle.properties'):
217 GitCheckout('gradle.properties')
218
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +0100219 return result_per_shrinker
Christoffer Quist Adamsen404aade2018-12-20 13:00:09 +0100220
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +0100221def BuildAppWithShrinker(app, config, shrinker, checkout_dir, options):
222 print('Building {} with {}'.format(app, shrinker))
223
Christoffer Quist Adamsenf8ad4792019-01-09 13:19:19 +0100224 if options.disable_tot:
225 as_utils.remove_r8_dependency(checkout_dir)
226 else:
227 as_utils.add_r8_dependency(checkout_dir, IsMinifiedR8(shrinker))
228
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +0100229 app_module = config.get('app_module', 'app')
230 archives_base_name = config.get(' archives_base_name', app_module)
231 flavor = config.get('flavor')
232
233 # Ensure that gradle.properties are not modified before modifying it to
234 # select shrinker.
235 if IsTrackedByGit('gradle.properties'):
236 GitCheckout('gradle.properties')
237 with open("gradle.properties", "a") as gradle_properties:
238 if 'r8' in shrinker:
239 gradle_properties.write('\nandroid.enableR8=true\n')
Christoffer Quist Adamsenf8ad4792019-01-09 13:19:19 +0100240 if shrinker == 'r8full' or shrinker == 'r8full-minified':
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +0100241 gradle_properties.write('android.enableR8.fullMode=true\n')
242 else:
243 assert shrinker == 'proguard'
244 gradle_properties.write('\nandroid.enableR8=false\n')
245
246 out = os.path.join(checkout_dir, 'out', shrinker)
247 if not os.path.exists(out):
248 os.makedirs(out)
249
250 env = os.environ.copy()
251 env['ANDROID_HOME'] = android_home
252 env['JAVA_OPTS'] = '-ea'
Søren Gjesse8c111482018-12-21 14:47:57 +0100253 releaseTarget = config.get('releaseTarget')
254 if not releaseTarget:
255 releaseTarget = app_module + ':' + 'assemble' + (
256 flavor.capitalize() if flavor else '') + 'Release'
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +0100257
258 cmd = ['./gradlew', '--no-daemon', 'clean', releaseTarget, '--stacktrace']
259 utils.PrintCmd(cmd)
260 subprocess.check_call(cmd, env=env)
261
262 apk_base_name = (archives_base_name
263 + (('-' + flavor) if flavor else '') + '-release')
264 signed_apk_name = config.get('signed-apk-name', apk_base_name + '.apk')
265 unsigned_apk_name = apk_base_name + '-unsigned.apk'
266
267 build_dir = config.get('build_dir', 'build')
268 build_output_apks = os.path.join(app_module, build_dir, 'outputs', 'apk')
269 if flavor:
270 build_output_apks = os.path.join(build_output_apks, flavor, 'release')
271 else:
272 build_output_apks = os.path.join(build_output_apks, 'release')
273
274 signed_apk = os.path.join(build_output_apks, signed_apk_name)
275 unsigned_apk = os.path.join(build_output_apks, unsigned_apk_name)
276
277 if options.sign_apks and not os.path.isfile(signed_apk):
278 assert os.path.isfile(unsigned_apk)
279 if options.sign_apks:
280 keystore = 'app.keystore'
281 keystore_password = 'android'
282 apk_utils.sign_with_apksigner(
283 android_build_tools,
284 unsigned_apk,
285 signed_apk,
286 keystore,
287 keystore_password)
288
289 if os.path.isfile(signed_apk):
290 apk_dest = os.path.join(out, signed_apk_name)
291 MoveApkToDest(signed_apk, apk_dest)
292 else:
293 apk_dest = os.path.join(out, unsigned_apk_name)
294 MoveApkToDest(unsigned_apk, apk_dest)
295
296 assert IsBuiltWithR8(apk_dest) == ('r8' in shrinker), (
297 'Unexpected marker in generated APK for {}'.format(shrinker))
298
Christoffer Quist Adamsen404aade2018-12-20 13:00:09 +0100299 return apk_dest
300
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +0100301def RunMonkey(app, config, apk_dest):
302 WaitForEmulator()
303 InstallApkOnEmulator(apk_dest)
304
305 app_id = config.get('app_id')
306 number_of_events_to_generate = 50
307
308 stdout = subprocess.check_output(['adb', 'shell', 'monkey', '-p', app_id,
309 str(number_of_events_to_generate)])
310 return 'Events injected: {}'.format(number_of_events_to_generate) in stdout
311
312def LogResults(result_per_shrinker_per_app, options):
313 for app, result_per_shrinker in result_per_shrinker_per_app.iteritems():
Christoffer Quist Adamsen404aade2018-12-20 13:00:09 +0100314 print(app + ':')
Christoffer Quist Adamsen724bfb02019-01-10 09:54:38 +0100315
316 if result_per_shrinker.get('status') != 'success':
317 error_message = result_per_shrinker.get('error_message')
318 print(' skipped ({})'.format(error_message))
319 continue
320
Christoffer Quist Adamsen860fa932019-01-10 14:27:39 +0100321 baseline = float(
322 result_per_shrinker.get('proguard', {}).get('dex_size', -1))
323 for shrinker in SHRINKERS:
324 if shrinker not in result_per_shrinker:
325 continue
326 result = result_per_shrinker.get(shrinker)
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +0100327 build_status = result.get('build_status')
328 if build_status != 'success':
329 warn(' {}: {}'.format(shrinker, build_status))
Christoffer Quist Adamsen404aade2018-12-20 13:00:09 +0100330 else:
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +0100331 print(' {}:'.format(shrinker))
332 dex_size = result.get('dex_size')
333 if dex_size != baseline and baseline >= 0:
334 if dex_size < baseline:
Christoffer Quist Adamsen860fa932019-01-10 14:27:39 +0100335 success(' dex size: {} ({}, -{}%)'.format(
336 dex_size, dex_size - baseline,
337 round((1.0 - dex_size / baseline) * 100), 1))
338 elif dex_size >= baseline:
339 warn(' dex size: {} ({}, +{}%)'.format(
340 dex_size, dex_size - baseline,
341 round((baseline - dex_size) / dex_size * 100, 1)))
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +0100342 else:
343 print(' dex size: {}'.format(dex_size))
344 if options.monkey:
345 monkey_status = result.get('monkey_status')
346 if monkey_status != 'success':
347 warn(' monkey: {}'.format(monkey_status))
348 else:
349 success(' monkey: {}'.format(monkey_status))
Christoffer Quist Adamsen404aade2018-12-20 13:00:09 +0100350
Søren Gjessecdae8792018-12-12 09:02:43 +0100351def ParseOptions(argv):
352 result = optparse.OptionParser()
353 result.add_option('--app',
354 help='What app to run on',
355 choices=APPS.keys())
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +0100356 result.add_option('--monkey',
357 help='Whether to install and run app(s) with monkey',
358 default=False,
359 action='store_true')
Christoffer Quist Adamsen860fa932019-01-10 14:27:39 +0100360 result.add_option('--pull',
361 help='Whether to pull the latest version of each app',
362 default=False,
363 action='store_true')
Christoffer Quist Adamsen10b7db82018-12-13 14:50:38 +0100364 result.add_option('--sign_apks',
365 help='Whether the APKs should be signed',
366 default=False,
367 action='store_true')
368 result.add_option('--shrinker',
Christoffer Quist Adamsen860fa932019-01-10 14:27:39 +0100369 help='The shrinkers to use (by default, all are run)',
370 action='append')
Christoffer Quist Adamsenf8ad4792019-01-09 13:19:19 +0100371 result.add_option('--disable_tot',
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +0100372 help='Whether to disable the use of the ToT version of R8',
Christoffer Quist Adamsenf8ad4792019-01-09 13:19:19 +0100373 default=False,
374 action='store_true')
Christoffer Quist Adamsen860fa932019-01-10 14:27:39 +0100375 (options, args) = result.parse_args(argv)
376 if options.shrinker:
377 for shrinker in options.shrinker:
378 assert shrinker in SHRINKERS
379 return (options, args)
Søren Gjessecdae8792018-12-12 09:02:43 +0100380
381def main(argv):
Christoffer Quist Adamsenf8ad4792019-01-09 13:19:19 +0100382 global SHRINKERS
383
Søren Gjessecdae8792018-12-12 09:02:43 +0100384 (options, args) = ParseOptions(argv)
Christoffer Quist Adamsenf8ad4792019-01-09 13:19:19 +0100385 assert options.disable_tot or os.path.isfile(utils.R8_JAR), (
Christoffer Quist Adamsen10b7db82018-12-13 14:50:38 +0100386 'Cannot build from ToT without r8.jar')
Christoffer Quist Adamsenf8ad4792019-01-09 13:19:19 +0100387 assert options.disable_tot or os.path.isfile(utils.R8LIB_JAR), (
388 'Cannot build from ToT without r8lib.jar')
389
390 if options.disable_tot:
391 # Cannot run r8 lib without adding r8lib.jar as an dependency
392 SHRINKERS = [
393 shrinker for shrinker in SHRINKERS
394 if 'minified' not in shrinker]
Søren Gjessecdae8792018-12-12 09:02:43 +0100395
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +0100396 result_per_shrinker_per_app = {}
Christoffer Quist Adamsen404aade2018-12-20 13:00:09 +0100397
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +0100398 if options.app:
Christoffer Quist Adamsen724bfb02019-01-10 09:54:38 +0100399 result_per_shrinker_per_app[options.app] = GetResultsForApp(
Christoffer Quist Adamsen404aade2018-12-20 13:00:09 +0100400 options.app, APPS.get(options.app), options)
Søren Gjessecdae8792018-12-12 09:02:43 +0100401 else:
Christoffer Quist Adamsen3aa8d252018-12-13 14:56:40 +0100402 for app, config in APPS.iteritems():
403 if not config.get('skip', False):
Christoffer Quist Adamsen724bfb02019-01-10 09:54:38 +0100404 result_per_shrinker_per_app[app] = GetResultsForApp(
Christoffer Quist Adamsen404aade2018-12-20 13:00:09 +0100405 app, config, options)
406
Christoffer Quist Adamsen1d0a0fe2018-12-21 14:28:56 +0100407 LogResults(result_per_shrinker_per_app, options)
Christoffer Quist Adamsen404aade2018-12-20 13:00:09 +0100408
409def success(message):
410 CGREEN = '\033[32m'
411 CEND = '\033[0m'
412 print(CGREEN + message + CEND)
413
414def warn(message):
415 CRED = '\033[91m'
416 CEND = '\033[0m'
417 print(CRED + message + CEND)
Søren Gjessecdae8792018-12-12 09:02:43 +0100418
419if __name__ == '__main__':
420 sys.exit(main(sys.argv[1:]))