blob: 523e8c03e64bb7252fbf5324a535eec1367327f4 [file] [log] [blame]
Kuang-che Wu875c89a2020-01-08 14:30:55 +08001#!/usr/bin/env python3
Kuang-che Wu6e4beca2018-06-27 17:45:02 +08002# -*- coding: utf-8 -*-
Kuang-che Wu2ea804f2017-11-28 17:11:41 +08003# Copyright 2017 The Chromium OS Authors. All rights reserved.
4# Use of this source code is governed by a BSD-style license that can be
5# found in the LICENSE file.
Kuang-che Wu68db08a2018-03-30 11:50:34 +08006"""Helper script to manipulate chromeos DUT or query info."""
Kuang-che Wu2ea804f2017-11-28 17:11:41 +08007from __future__ import print_function
Kuang-che Wu5157dee2020-07-18 01:13:41 +08008import asyncio
Kuang-che Wu2ea804f2017-11-28 17:11:41 +08009import argparse
10import json
11import logging
Kuang-che Wu0c9b7942019-10-30 16:55:39 +080012import random
13import time
Kuang-che Wu2ea804f2017-11-28 17:11:41 +080014
Kuang-che Wufe1e88a2019-09-10 21:52:25 +080015from bisect_kit import cli
Kuang-che Wu2ea804f2017-11-28 17:11:41 +080016from bisect_kit import common
Kuang-che Wuc45cfa42019-01-15 00:15:01 +080017from bisect_kit import cros_lab_util
Kuang-che Wu2ea804f2017-11-28 17:11:41 +080018from bisect_kit import cros_util
Kuang-che Wu22aa9d42019-01-25 10:35:33 +080019from bisect_kit import errors
Kuang-che Wu2ea804f2017-11-28 17:11:41 +080020
Zheng-Jie Chang17f36c82020-06-16 05:21:59 +080021DEFAULT_DUT_POOL = 'DUT_POOL_QUOTA'
Kuang-che Wu2ea804f2017-11-28 17:11:41 +080022logger = logging.getLogger(__name__)
23
Kuang-che Wu25723422020-09-24 22:20:26 +080024models_to_avoid = {
25 # model: reason
26 'kasumi': 'b/160458394 stateful partition is too small',
27 'mimrock': 'b/160458394 stateful partition is too small',
28 'vorticon': 'b/160458394 stateful partition is too small',
29}
30
Kuang-che Wu2ea804f2017-11-28 17:11:41 +080031
32def cmd_version_info(opts):
33 info = cros_util.version_info(opts.board, opts.version)
34 if opts.name:
35 if opts.name not in info:
36 logger.error('unknown name=%s', opts.name)
37 print(info[opts.name])
38 else:
39 print(json.dumps(info, sort_keys=True, indent=4))
40
41
42def cmd_query_dut_board(opts):
43 assert cros_util.is_dut(opts.dut)
44 print(cros_util.query_dut_board(opts.dut))
45
46
47def cmd_reboot(opts):
Kuang-che Wu2534bab2020-10-23 17:37:16 +080048 if not cros_util.is_dut(opts.dut):
49 if opts.force:
50 logger.warning('%s is not a Chrome OS device?', opts.dut)
51 else:
52 raise errors.ArgumentError(
53 'dut', 'not a Chrome OS device (--force to continue)')
54
Kuang-che Wu2ac9a922020-09-03 16:50:12 +080055 cros_util.reboot(
56 opts.dut, force_reboot_callback=cros_lab_util.reboot_via_servo)
Kuang-che Wu2ea804f2017-11-28 17:11:41 +080057
58
Kuang-che Wuc45cfa42019-01-15 00:15:01 +080059def _get_label_by_prefix(info, prefix):
60 for label in info['Labels']:
61 if label.startswith(prefix + ':'):
62 return label
63 return None
64
65
Kuang-che Wuca456462019-11-04 17:32:55 +080066def cmd_lease_dut(opts):
Kuang-che Wu5157dee2020-07-18 01:13:41 +080067 if opts.duration is not None and opts.duration < 60:
68 raise errors.ArgumentError('--duration', 'must be at least 60 seconds')
Kuang-che Wu22aa9d42019-01-25 10:35:33 +080069 host = cros_lab_util.dut_host_name(opts.dut)
Kuang-che Wu220cc162019-10-31 00:29:37 +080070 logger.info('trying to lease %s', host)
71 if cros_lab_util.skylab_lease_dut(host, opts.duration):
Kuang-che Wuca456462019-11-04 17:32:55 +080072 logger.info('leased %s', host)
Kuang-che Wu22aa9d42019-01-25 10:35:33 +080073 else:
Kuang-che Wuca456462019-11-04 17:32:55 +080074 raise Exception('unable to lease %s' % host)
Kuang-che Wu22aa9d42019-01-25 10:35:33 +080075
76
Kuang-che Wuca456462019-11-04 17:32:55 +080077def cmd_release_dut(opts):
Kuang-che Wu22aa9d42019-01-25 10:35:33 +080078 host = cros_lab_util.dut_host_name(opts.dut)
Kuang-che Wu220cc162019-10-31 00:29:37 +080079 cros_lab_util.skylab_release_dut(host)
Kuang-che Wuca456462019-11-04 17:32:55 +080080 logger.info('%s released', host)
Kuang-che Wu22aa9d42019-01-25 10:35:33 +080081
82
Kuang-che Wu1e56ce22020-06-29 11:21:51 +080083def verify_dimensions_by_lab(dimensions):
84 result = []
85 bots_dimensions = cros_lab_util.swarming_bots_dimensions()
86 for dimension in dimensions:
87 key, value = dimension.split(':', 1)
88 if value in bots_dimensions.get(key, []):
89 result.append(dimension)
90 else:
91 logger.warning('dimension=%s is unknown in the lab, typo? ignored',
92 dimension)
93 return result
94
95
Kuang-che Wu5157dee2020-07-18 01:13:41 +080096def select_available_bots_randomly(dimensions, variants, num=1, is_busy=None):
Kuang-che Wu1e56ce22020-06-29 11:21:51 +080097 bots = []
98 for variant in variants:
99 # There might be thousand bots available, set 'limit' to reduce swarming
100 # API cost. This is not uniform random, but should be good enough.
101 bots += cros_lab_util.swarming_bots_list(
102 dimensions + [variant], is_busy=is_busy, limit=10)
103 if not bots:
104 return None
Kuang-che Wu25723422020-09-24 22:20:26 +0800105
106 known_bad = set()
107 good_bots = []
108 for bot in bots:
109 model = bot['dimensions']['label-model'][0]
110 if model in models_to_avoid:
111 if model not in known_bad:
112 logger.warning('model=%s is bad (reason:%s), ignore', model,
113 models_to_avoid[model])
114 known_bad.add(model)
115 continue
116 good_bots.append(bot)
117
118 return random.sample(good_bots, min(num, len(good_bots)))
Kuang-che Wu5157dee2020-07-18 01:13:41 +0800119
120
Kuang-che Wub529d2d2020-09-10 12:26:56 +0800121def filter_dimensions_by_board(boards_with_prebuilt, dimensions):
122 result = []
123 for dimension in dimensions:
124 bots = cros_lab_util.swarming_bots_list([dimension], is_busy=None, limit=1)
125 if not bots:
126 continue
127 board = bots[0]['dimensions']['label-board'][0]
128 if board not in boards_with_prebuilt:
129 logger.warning(
130 'dimension=%s (board=%s) does not have corresponding '
131 'prebuilt image, ignore', dimension, board)
132 continue
133 result.append(dimension)
134 return result
135
136
Kuang-che Wu04619772020-10-22 18:57:07 +0800137def filter_bots_by_board(boards_with_prebuilt, bots):
138 # Sometimes swarming database has inconsistent records. For example,
139 # label-model=kefka + label-board=strago are incorrect (should be
140 # label-board=kefka). It is probably human errors (strago is kefka's
141 # reference board).
142 # This function discards such bots.
143 result = []
144 for bot in bots:
145 board = bot['dimensions']['label-board'][0]
146 if board not in boards_with_prebuilt:
147 logger.warning('%s has unexpected board=%s ignore',
148 bot['dimensions']['dut_name'][0], board)
149 continue
150 result.append(bot)
151 return result
152
153
Kuang-che Wu5157dee2020-07-18 01:13:41 +0800154async def lease_dut_parallelly(duration, bots, timeout=None):
155 tasks = []
156 hosts = []
157 for bot in bots:
158 host = bot['dimensions']['dut_name'][0]
159 hosts.append(host)
160 tasks.append(
161 asyncio.create_task(cros_lab_util.async_lease(host, duration=duration)))
162
163 try:
164 logger.info('trying to lease %d DUTs: %s', len(hosts), hosts)
165 for coro in asyncio.as_completed(tasks, timeout=timeout):
166 host = await coro
167 if host:
168 logger.info('leased %s', host)
Kuang-che Wub529d2d2020-09-10 12:26:56 +0800169 # Unfinished lease tasks will be cancelled when asyncio.run is
170 # finishing.
Kuang-che Wu5157dee2020-07-18 01:13:41 +0800171 return host
172 return None
173 except asyncio.TimeoutError:
174 return None
Kuang-che Wu1e56ce22020-06-29 11:21:51 +0800175
176
Kuang-che Wu22aa9d42019-01-25 10:35:33 +0800177def do_allocate_dut(opts):
178 """Helper of cmd_allocate_dut.
179
180 Returns:
Kuang-che Wub529d2d2020-09-10 12:26:56 +0800181 (todo, host, board_to_build)
Kuang-che Wu22aa9d42019-01-25 10:35:33 +0800182 todo: 'ready' or 'wait'
Kuang-che Wuca456462019-11-04 17:32:55 +0800183 host: leased host name
Kuang-che Wub529d2d2020-09-10 12:26:56 +0800184 board_to_build: board name for building image
Kuang-che Wu22aa9d42019-01-25 10:35:33 +0800185 """
Kuang-che Wu1e56ce22020-06-29 11:21:51 +0800186 if not opts.dut_name and not opts.pool:
187 raise errors.ArgumentError('--pool',
188 'need to be specified if not --dut_name')
Kuang-che Wu7e8abe62020-07-02 09:42:27 +0800189 if opts.version_hint:
190 for v in opts.version_hint.split(','):
191 if cros_util.is_cros_version(v) or cros_util.is_cros_snapshot_version(v):
192 continue
193 raise errors.ArgumentError(
194 '--version_hint',
195 'should be Chrome OS version numbers, separated by comma')
Kuang-che Wu5157dee2020-07-18 01:13:41 +0800196 if opts.duration is not None and opts.duration < 60:
197 raise errors.ArgumentError('--duration', 'must be at least 60 seconds')
Kuang-che Wu22aa9d42019-01-25 10:35:33 +0800198
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800199 t0 = time.time()
Zheng-Jie Chang17f36c82020-06-16 05:21:59 +0800200 dimensions = ['dut_state:ready']
Kuang-che Wu1e56ce22020-06-29 11:21:51 +0800201 if not opts.dut_name:
Zheng-Jie Chang17f36c82020-06-16 05:21:59 +0800202 dimensions.append('label-pool:' + opts.pool)
203
Kuang-che Wu1e56ce22020-06-29 11:21:51 +0800204 variants = []
205 if opts.board:
206 for board in opts.board.split(','):
Kuang-che Wu1e56ce22020-06-29 11:21:51 +0800207 variants.append('label-board:' +
208 cros_lab_util.normalize_board_name(board))
Kuang-che Wu0c9b7942019-10-30 16:55:39 +0800209 if opts.model:
Kuang-che Wu1e56ce22020-06-29 11:21:51 +0800210 for model in opts.model.split(','):
Kuang-che Wu25723422020-09-24 22:20:26 +0800211 if model in models_to_avoid:
212 logger.warning('model=%s is bad (reason:%s), ignore', model,
213 models_to_avoid[model])
214 continue
Kuang-che Wu1e56ce22020-06-29 11:21:51 +0800215 variants.append('label-model:' + model)
Kuang-che Wu25723422020-09-24 22:20:26 +0800216 if not variants:
217 raise errors.ArgumentError('--model',
218 'all specified models are not supported')
Kuang-che Wu0c9b7942019-10-30 16:55:39 +0800219 if opts.sku:
Kuang-che Wu1e56ce22020-06-29 11:21:51 +0800220 for sku in opts.sku.split(','):
221 variants.append('label-hwid_sku:' + cros_lab_util.normalize_sku_name(sku))
222 if opts.dut_name:
223 for dut_name in opts.dut_name.split(','):
224 variants.append('dut_name:' + dut_name)
225
Kuang-che Wub529d2d2020-09-10 12:26:56 +0800226 variants = verify_dimensions_by_lab(variants)
227 variants = sorted(set(variants)) # dedup
228 if not variants:
229 raise errors.NoDutAvailable(
230 'Invalid constraints: %s;%s;%s;%s' %
231 (opts.board, opts.model, opts.sku, opts.dut_name))
232
233 # Filter variants by prebuilt images.
234 if opts.version_hint:
235 if not opts.builder_hint:
236 opts.builder_hint = opts.board
237 if not opts.builder_hint:
238 raise errors.ArgumentError('--builder_hint',
239 'must be specified along with --version_hint')
240 boards_with_prebuilt = []
241 versions = opts.version_hint.split(',')
242 for builder in opts.builder_hint.split(','):
243 if not all(cros_util.has_test_image(builder, v) for v in versions):
244 logger.warning(
245 'builder=%s does not have prebuilt test image for %s, ignore',
246 builder, opts.version_hint)
247 continue
248 boards_with_prebuilt.append(cros_lab_util.normalize_board_name(builder))
249 logger.info('boards with prebuilt: %s', boards_with_prebuilt)
250 if not boards_with_prebuilt:
251 raise errors.ArgumentError(
252 '--version_hint',
253 'given builders have no prebuilt for %s' % opts.version_hint)
254 variants = filter_dimensions_by_board(boards_with_prebuilt, variants)
255 if not variants:
256 raise errors.NoDutAvailable(
257 'Devices with specified constraints have no prebuilt. '
258 'Wrong version number?')
Kuang-che Wu0c9b7942019-10-30 16:55:39 +0800259
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800260 while True:
Kuang-che Wu5157dee2020-07-18 01:13:41 +0800261 # Query every time because each iteration takes a few minutes
262 bots = select_available_bots_randomly(
263 dimensions, variants, num=opts.parallel, is_busy=False)
264 if not bots:
265 bots = select_available_bots_randomly(
266 dimensions, variants, num=opts.parallel, is_busy=True)
Kuang-che Wu04619772020-10-22 18:57:07 +0800267 bots = filter_bots_by_board(boards_with_prebuilt, bots)
Kuang-che Wu5157dee2020-07-18 01:13:41 +0800268 if not bots:
Kuang-che Wu0c9b7942019-10-30 16:55:39 +0800269 raise errors.NoDutAvailable(
Kuang-che Wu1e56ce22020-06-29 11:21:51 +0800270 'no bots satisfy constraints; all are in maintenance state?')
Kuang-che Wu0c9b7942019-10-30 16:55:39 +0800271
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800272 remaining_time = opts.time_limit - (time.time() - t0)
273 if remaining_time <= 0:
274 break
Kuang-che Wu5157dee2020-07-18 01:13:41 +0800275 timeout = min(120, remaining_time)
276 host = asyncio.run(lease_dut_parallelly(opts.duration, bots, timeout))
277 if host:
Kuang-che Wub529d2d2020-09-10 12:26:56 +0800278 # Resolve what board we should build during bisection.
279 board_to_build = None
280 bots = cros_lab_util.swarming_bots_list(['dut_name:' + host])
281 host_board = bots[0]['dimensions']['label-board'][0]
282 if opts.builder_hint:
283 for builder in opts.builder_hint.split(','):
284 if cros_lab_util.normalize_board_name(builder) == host_board:
285 board_to_build = builder
286 break
Kuang-che Wu04619772020-10-22 18:57:07 +0800287 else:
288 raise errors.DutLeaseException('DUT with unexpected board:%s' %
289 host_board)
Kuang-che Wub529d2d2020-09-10 12:26:56 +0800290 else:
291 board_to_build = host_board
292
293 return 'ready', host, board_to_build
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800294 time.sleep(1)
Kuang-che Wu0c9b7942019-10-30 16:55:39 +0800295
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800296 logger.warning('unable to lease DUT in time limit')
Kuang-che Wub529d2d2020-09-10 12:26:56 +0800297 return 'wait', None, None
Kuang-che Wu0c9b7942019-10-30 16:55:39 +0800298
299
Kuang-che Wu22aa9d42019-01-25 10:35:33 +0800300def cmd_allocate_dut(opts):
Kuang-che Wuca456462019-11-04 17:32:55 +0800301 leased_dut = None
Kuang-che Wu22aa9d42019-01-25 10:35:33 +0800302 try:
Kuang-che Wub529d2d2020-09-10 12:26:56 +0800303 todo, host, board = do_allocate_dut(opts)
Kuang-che Wu5157dee2020-07-18 01:13:41 +0800304 leased_dut = cros_lab_util.dut_name_to_address(host) if host else None
Kuang-che Wub529d2d2020-09-10 12:26:56 +0800305 result = {'result': todo, 'leased_dut': leased_dut, 'board': board}
Kuang-che Wu22aa9d42019-01-25 10:35:33 +0800306 print(json.dumps(result))
307 except Exception as e:
308 logger.exception('cmd_allocate_dut failed')
309 exception_name = e.__class__.__name__
310 result = {
311 'result': 'failed',
312 'exception': exception_name,
313 'text': str(e),
314 }
315 print(json.dumps(result))
Kuang-che Wu22aa9d42019-01-25 10:35:33 +0800316
317
Kuang-che Wua8c3c3e2019-08-28 18:49:28 +0800318def cmd_repair_dut(opts):
319 cros_lab_util.repair(opts.dut)
320
321
Kuang-che Wufe1e88a2019-09-10 21:52:25 +0800322@cli.fatal_error_handler
Kuang-che Wu2ea804f2017-11-28 17:11:41 +0800323def main():
324 common.init()
325 parser = argparse.ArgumentParser()
Kuang-che Wufe1e88a2019-09-10 21:52:25 +0800326 cli.patching_argparser_exit(parser)
Kuang-che Wu2ea804f2017-11-28 17:11:41 +0800327 common.add_common_arguments(parser)
328 subparsers = parser.add_subparsers(
329 dest='command', title='commands', metavar='<command>')
330
331 parser_version_info = subparsers.add_parser(
332 'version_info',
333 help='Query version info of given chromeos build',
334 description='Given chromeos `board` and `version`, '
335 'print version information of components.')
336 parser_version_info.add_argument(
337 'board', help='ChromeOS board name, like "samus".')
338 parser_version_info.add_argument(
339 'version',
340 type=cros_util.argtype_cros_version,
341 help='ChromeOS version, like "9876.0.0" or "R62-9876.0.0"')
342 parser_version_info.add_argument(
343 'name',
344 nargs='?',
345 help='Component name. If specified, output its version string. '
346 'Otherwise output all version info as dict in json format.')
347 parser_version_info.set_defaults(func=cmd_version_info)
348
349 parser_query_dut_board = subparsers.add_parser(
350 'query_dut_board', help='Query board name of given DUT')
351 parser_query_dut_board.add_argument('dut')
352 parser_query_dut_board.set_defaults(func=cmd_query_dut_board)
353
354 parser_reboot = subparsers.add_parser(
355 'reboot',
356 help='Reboot a DUT',
357 description='Reboot a DUT and verify the reboot is successful.')
Kuang-che Wu2534bab2020-10-23 17:37:16 +0800358 parser_reboot.add_argument('--force', action='store_true')
Kuang-che Wu2ea804f2017-11-28 17:11:41 +0800359 parser_reboot.add_argument('dut')
360 parser_reboot.set_defaults(func=cmd_reboot)
361
Kuang-che Wuca456462019-11-04 17:32:55 +0800362 parser_lease_dut = subparsers.add_parser(
363 'lease_dut',
364 help='Lease a DUT in the lab',
365 description='Lease a DUT in the lab. '
366 'This is implemented by `skylab lease-dut` with additional checking.')
367 # "skylab lease-dut" doesn't take reason, so this is not required=True.
368 parser_lease_dut.add_argument('--session', help='session name')
369 parser_lease_dut.add_argument('dut')
370 parser_lease_dut.add_argument(
371 '--duration',
372 type=float,
373 help='duration in seconds; will be round to minutes')
374 parser_lease_dut.set_defaults(func=cmd_lease_dut)
375
376 parser_release_dut = subparsers.add_parser(
377 'release_dut',
378 help='Release a DUT in the lab',
379 description='Release a DUT in the lab. '
380 'This is implemented by `skylab release-dut` with additional checking.')
381 parser_release_dut.add_argument('--session', help='session name')
382 parser_release_dut.add_argument('dut')
383 parser_release_dut.set_defaults(func=cmd_release_dut)
384
Kuang-che Wu22aa9d42019-01-25 10:35:33 +0800385 parser_allocate_dut = subparsers.add_parser(
386 'allocate_dut',
387 help='Allocate a DUT in the lab',
Kuang-che Wuca456462019-11-04 17:32:55 +0800388 description='Allocate a DUT in the lab. It will lease a DUT in the lab '
389 'for bisecting. The caller (bisect-kit runner) of this command should '
390 'retry this command again later if no DUT available now.')
Kuang-che Wu22aa9d42019-01-25 10:35:33 +0800391 parser_allocate_dut.add_argument(
392 '--session', required=True, help='session name')
393 parser_allocate_dut.add_argument(
Kuang-che Wub529d2d2020-09-10 12:26:56 +0800394 '--pool',
395 help='Pool to search DUT (default: %(default)s)',
396 default=DEFAULT_DUT_POOL)
Kuang-che Wu1e56ce22020-06-29 11:21:51 +0800397 group = parser_allocate_dut.add_mutually_exclusive_group(required=True)
398 group.add_argument('--board', help='allocation criteria; comma separated')
399 group.add_argument('--model', help='allocation criteria; comma separated')
400 group.add_argument('--sku', help='allocation criteria; comma separated')
401 group.add_argument('--dut_name', help='allocation criteria; comma separated')
Kuang-che Wu22aa9d42019-01-25 10:35:33 +0800402 parser_allocate_dut.add_argument(
Kuang-che Wu1e56ce22020-06-29 11:21:51 +0800403 '--version_hint', help='chromeos version; comma separated')
Kuang-che Wub529d2d2020-09-10 12:26:56 +0800404 parser_allocate_dut.add_argument(
405 '--builder_hint', help='chromeos builder; comma separated')
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800406 # Pubsub ack deadline is 10 minutes (b/143663659). Default 9 minutes with 1
407 # minute buffer.
Kuang-che Wu22aa9d42019-01-25 10:35:33 +0800408 parser_allocate_dut.add_argument(
Kuang-che Wu0c9b7942019-10-30 16:55:39 +0800409 '--time_limit',
410 type=int,
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800411 default=9 * 60,
Kuang-che Wu0c9b7942019-10-30 16:55:39 +0800412 help='Time limit to attempt lease in seconds (default: %(default)s)')
413 parser_allocate_dut.add_argument(
414 '--duration',
415 type=float,
416 help='lease duration in seconds; will be round to minutes')
Kuang-che Wu5157dee2020-07-18 01:13:41 +0800417 parser_allocate_dut.add_argument(
418 '--parallel',
419 type=int,
420 default=1,
421 help='Submit multiple lease tasks to speed up (default: %(default)d)')
Kuang-che Wu22aa9d42019-01-25 10:35:33 +0800422 parser_allocate_dut.set_defaults(func=cmd_allocate_dut)
423
Kuang-che Wua8c3c3e2019-08-28 18:49:28 +0800424 parser_repair_dut = subparsers.add_parser(
425 'repair_dut',
426 help='Repair a DUT in the lab',
427 description='Repair a DUT in the lab. '
428 'This is simply wrapper of "deploy repair" with additional checking.')
429 parser_repair_dut.add_argument('dut')
430 parser_repair_dut.set_defaults(func=cmd_repair_dut)
431
Kuang-che Wu2ea804f2017-11-28 17:11:41 +0800432 opts = parser.parse_args()
433 common.config_logging(opts)
Kuang-che Wud3a4e842019-12-11 12:15:23 +0800434
435 # It's optional by default since python3.
436 if not opts.command:
437 parser.error('command is missing')
Kuang-che Wu2ea804f2017-11-28 17:11:41 +0800438 opts.func(opts)
439
440
441if __name__ == '__main__':
442 main()