Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(923)

Side by Side Diff: appengine/swarming/swarming_bot/bot_code/bot_main.py

Issue 2024313003: Send authorization headers when calling Swarming backend. (Closed) Base URL: https://chromium.googlesource.com/external/github.com/luci/luci-py@master
Patch Set: keep it as part of environ Created 4 years, 6 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
OLDNEW
1 # Copyright 2013 The LUCI Authors. All rights reserved. 1 # Copyright 2013 The LUCI Authors. All rights reserved.
2 # Use of this source code is governed under the Apache License, Version 2.0 2 # Use of this source code is governed under the Apache License, Version 2.0
3 # that can be found in the LICENSE file. 3 # that can be found in the LICENSE file.
4 4
5 """Swarming bot main process. 5 """Swarming bot main process.
6 6
7 This is the program that communicates with the Swarming server, ensures the code 7 This is the program that communicates with the Swarming server, ensures the code
8 is always up to date and executes a child process to run tasks and upload 8 is always up to date and executes a child process to run tasks and upload
9 results back. 9 results back.
10 10
11 It manages self-update and rebooting the host in case of problems. 11 It manages self-update and rebooting the host in case of problems.
12 12
13 Set the environment variable SWARMING_LOAD_TEST=1 to disable the use of 13 Set the environment variable SWARMING_LOAD_TEST=1 to disable the use of
14 server-provided bot_config.py. This permits safe load testing. 14 server-provided bot_config.py. This permits safe load testing.
15 """ 15 """
16 16
17 import contextlib 17 import contextlib
18 import json 18 import json
19 import logging 19 import logging
20 import optparse 20 import optparse
21 import os 21 import os
22 import shutil 22 import shutil
23 import signal
24 import sys 23 import sys
25 import tempfile 24 import tempfile
26 import threading 25 import threading
27 import time 26 import time
28 import traceback 27 import traceback
29 import zipfile 28 import zipfile
30 29
30 import bot_auth
31 import common 31 import common
32 import file_refresher
33 import remote_client
32 import singleton 34 import singleton
33 from api import bot 35 from api import bot
34 from api import os_utilities 36 from api import os_utilities
35 from utils import file_path 37 from utils import file_path
36 from utils import net 38 from utils import net
37 from utils import on_error 39 from utils import on_error
38 from utils import subprocess42 40 from utils import subprocess42
39 from utils import zip_package 41 from utils import zip_package
40 42
41 43
(...skipping 121 matching lines...) Expand 10 before | Expand all | Expand 10 after
163 should_continue = bot_config.setup_bot(botobj) 165 should_continue = bot_config.setup_bot(botobj)
164 except Exception as e: 166 except Exception as e:
165 msg = '%s\n%s' % (e, traceback.format_exc()[-2048:]) 167 msg = '%s\n%s' % (e, traceback.format_exc()[-2048:])
166 botobj.post_error('bot_config.setup_bot() threw: %s' % msg) 168 botobj.post_error('bot_config.setup_bot() threw: %s' % msg)
167 return 169 return
168 170
169 if not should_continue and not skip_reboot: 171 if not should_continue and not skip_reboot:
170 botobj.restart('Starting new swarming bot: %s' % THIS_FILE) 172 botobj.restart('Starting new swarming bot: %s' % THIS_FILE)
171 173
172 174
175 def get_authentication_headers(botobj):
176 """Calls bot_config.get_authentication_headers() if it is defined.
177
178 Doesn't catch exceptions.
179 """
180 if _in_load_test_mode():
181 return (None, None)
182 logging.info('get_authentication_headers()')
183 from config import bot_config
184 func = getattr(bot_config, 'get_authentication_headers', None)
185 return func(botobj) if func else (None, None)
186
187
173 ### end of bot_config handler part. 188 ### end of bot_config handler part.
174 189
175 190
176 def get_min_free_space(): 191 def get_min_free_space():
177 """Returns free disk space needed. 192 """Returns free disk space needed.
178 193
179 Add a "250 MiB slack space" for logs, temporary files and whatever other leak. 194 Add a "250 MiB slack space" for logs, temporary files and whatever other leak.
180 """ 195 """
181 return int((os_utilities.get_min_free_space(THIS_FILE) + 250.) * 1024 * 1024) 196 return int((os_utilities.get_min_free_space(THIS_FILE) + 250.) * 1024 * 1024)
182 197
(...skipping 38 matching lines...) Expand 10 before | Expand all | Expand 10 after
221 exception handler for incoming commands from the Swarming server. If for 236 exception handler for incoming commands from the Swarming server. If for
222 any reason the local test runner script can not be run successfully, 237 any reason the local test runner script can not be run successfully,
223 this function is invoked. 238 this function is invoked.
224 """ 239 """
225 logging.error('Error: %s', error) 240 logging.error('Error: %s', error)
226 data = { 241 data = {
227 'id': botobj.id, 242 'id': botobj.id,
228 'message': error, 243 'message': error,
229 'task_id': task_id, 244 'task_id': task_id,
230 } 245 }
231 return net.url_read_json( 246 return botobj.remote.url_read_json(
232 botobj.server + '/swarming/api/v1/bot/task_error/%s' % task_id, data=data) 247 '/swarming/api/v1/bot/task_error/%s' % task_id, data=data)
233 248
234 249
235 def on_shutdown_hook(b): 250 def on_shutdown_hook(b):
236 """Called when the bot is restarting.""" 251 """Called when the bot is restarting."""
237 call_hook(b, 'on_bot_shutdown') 252 call_hook(b, 'on_bot_shutdown')
238 # Aggressively set itself up so we ensure the auto-reboot configuration is 253 # Aggressively set itself up so we ensure the auto-reboot configuration is
239 # fine before restarting the host. This is important as some tasks delete the 254 # fine before restarting the host. This is important as some tasks delete the
240 # autorestart script (!) 255 # autorestart script (!)
241 setup_bot(True) 256 setup_bot(True)
242 257
243 258
244 def get_bot(): 259 def get_bot():
245 """Returns a valid Bot instance. 260 """Returns a valid Bot instance.
246 261
247 Should only be called once in the process lifetime. 262 Should only be called once in the process lifetime.
248 """ 263 """
249 # This variable is used to bootstrap the initial bot.Bot object, which then is 264 # This variable is used to bootstrap the initial bot.Bot object, which then is
250 # used to get the dimensions and state. 265 # used to get the dimensions and state.
251 attributes = { 266 attributes = {
252 'dimensions': {u'id': ['none']}, 267 'dimensions': {u'id': ['none']},
253 'state': {}, 268 'state': {},
254 'version': generate_version(), 269 'version': generate_version(),
255 } 270 }
256 config = get_config() 271 config = get_config()
257 assert not config['server'].endswith('/'), config 272 assert not config['server'].endswith('/'), config
258 273
259 # Create a temporary object to call the hooks. 274 # Use temporary Bot object to call get_attributes. Attributes are needed to
275 # construct the "real" bot.Bot.
276 attributes = get_attributes(
277 bot.Bot(
278 remote_client.RemoteClient(config['server'], None),
279 attributes,
280 config['server'],
281 config['server_version'],
282 os.path.dirname(THIS_FILE),
283 on_shutdown_hook))
284
285 # Make remote client callback use the returned bot object. We assume here
286 # RemoteClient doesn't call its callback in the constructor (since 'botobj' is
287 # undefined during the construction).
260 botobj = bot.Bot( 288 botobj = bot.Bot(
289 remote_client.RemoteClient(
290 config['server'],
291 lambda: get_authentication_headers(botobj)),
261 attributes, 292 attributes,
262 config['server'], 293 config['server'],
263 config['server_version'], 294 config['server_version'],
264 os.path.dirname(THIS_FILE), 295 os.path.dirname(THIS_FILE),
265 on_shutdown_hook) 296 on_shutdown_hook)
266 return bot.Bot( 297 return botobj
267 get_attributes(botobj),
268 config['server'],
269 config['server_version'],
270 os.path.dirname(THIS_FILE),
271 on_shutdown_hook)
272 298
273 299
274 def clean_isolated_cache(botobj): 300 def clean_isolated_cache(botobj):
275 """Asks run_isolated to clean its cache. 301 """Asks run_isolated to clean its cache.
276 302
277 This may take a while but it ensures that in the case of a run_isolated run 303 This may take a while but it ensures that in the case of a run_isolated run
278 failed and it temporarily used more space than min_free_disk, it can cleans up 304 failed and it temporarily used more space than min_free_disk, it can cleans up
279 the mess properly. 305 the mess properly.
280 306
281 It will remove unexpected files, remove corrupted files, trim the cache size 307 It will remove unexpected files, remove corrupted files, trim the cache size
(...skipping 46 matching lines...) Expand 10 before | Expand all | Expand 10 after
328 # First thing is to get an arbitrary url. This also ensures the network is 354 # First thing is to get an arbitrary url. This also ensures the network is
329 # up and running, which is necessary before trying to get the FQDN below. 355 # up and running, which is necessary before trying to get the FQDN below.
330 resp = net.url_read(config['server'] + '/swarming/api/v1/bot/server_ping') 356 resp = net.url_read(config['server'] + '/swarming/api/v1/bot/server_ping')
331 if resp is None: 357 if resp is None:
332 logging.error('No response from server_ping') 358 logging.error('No response from server_ping')
333 except Exception as e: 359 except Exception as e:
334 # url_read() already traps pretty much every exceptions. This except 360 # url_read() already traps pretty much every exceptions. This except
335 # clause is kept there "just in case". 361 # clause is kept there "just in case".
336 logging.exception('server_ping threw') 362 logging.exception('server_ping threw')
337 363
364 # Next we make sure the bot can make authenticated calls by grabbing
365 # the auth headers, retrying on errors a bunch of times. We don't give up
366 # if it fails though (maybe the bot will "fix itself" later).
367 botobj = get_bot()
368 try:
369 botobj.remote.initialize(quit_bit)
370 except remote_client.InitializationError as exc:
371 botobj.post_error('failed to grab auth headers: %s' % exc.last_error)
372 logging.error('Can\'t grab auth headers, continuing anyway...')
373
338 if quit_bit.is_set(): 374 if quit_bit.is_set():
339 logging.info('Early quit 1') 375 logging.info('Early quit 1')
340 return 0 376 return 0
341 377
342 # If this fails, there's hardly anything that can be done, the bot can't 378 # If this fails, there's hardly anything that can be done, the bot can't
343 # even get to the point to be able to self-update. 379 # even get to the point to be able to self-update.
344 botobj = get_bot() 380 resp = botobj.remote.url_read_json(
345 resp = net.url_read_json( 381 '/swarming/api/v1/bot/handshake', data=botobj._attributes)
346 botobj.server + '/swarming/api/v1/bot/handshake',
347 data=botobj._attributes)
348 if not resp: 382 if not resp:
349 logging.error('Failed to contact for handshake') 383 logging.error('Failed to contact for handshake')
350 else: 384 else:
351 logging.info('Connected to %s', resp.get('server_version')) 385 logging.info('Connected to %s', resp.get('server_version'))
352 if resp.get('bot_version') != botobj._attributes['version']: 386 if resp.get('bot_version') != botobj._attributes['version']:
353 logging.warning( 387 logging.warning(
354 'Found out we\'ll need to update: server said %s; we\'re %s', 388 'Found out we\'ll need to update: server said %s; we\'re %s',
355 resp.get('bot_version'), botobj._attributes['version']) 389 resp.get('bot_version'), botobj._attributes['version'])
356 390
357 if arg_error: 391 if arg_error:
(...skipping 46 matching lines...) Expand 10 before | Expand all | Expand 10 after
404 return 0 438 return 0
405 439
406 440
407 def poll_server(botobj, quit_bit): 441 def poll_server(botobj, quit_bit):
408 """Polls the server to run one loop. 442 """Polls the server to run one loop.
409 443
410 Returns True if executed some action, False if server asked the bot to sleep. 444 Returns True if executed some action, False if server asked the bot to sleep.
411 """ 445 """
412 # Access to a protected member _XXX of a client class - pylint: disable=W0212 446 # Access to a protected member _XXX of a client class - pylint: disable=W0212
413 start = time.time() 447 start = time.time()
414 resp = net.url_read_json( 448 resp = botobj.remote.url_read_json(
415 botobj.server + '/swarming/api/v1/bot/poll', data=botobj._attributes) 449 '/swarming/api/v1/bot/poll', data=botobj._attributes)
416 if not resp: 450 if not resp:
417 return False 451 return False
418 logging.debug('Server response:\n%s', resp) 452 logging.debug('Server response:\n%s', resp)
419 453
420 cmd = resp['cmd'] 454 cmd = resp['cmd']
421 if cmd == 'sleep': 455 if cmd == 'sleep':
422 quit_bit.wait(resp['duration']) 456 quit_bit.wait(resp['duration'])
423 return False 457 return False
424 458
425 if cmd == 'terminate': 459 if cmd == 'terminate':
426 quit_bit.set() 460 quit_bit.set()
427 # This is similar to post_update() in task_runner.py. 461 # This is similar to post_update() in task_runner.py.
428 params = { 462 params = {
429 'cost_usd': 0, 463 'cost_usd': 0,
430 'duration': 0, 464 'duration': 0,
431 'exit_code': 0, 465 'exit_code': 0,
432 'hard_timeout': False, 466 'hard_timeout': False,
433 'id': botobj.id, 467 'id': botobj.id,
434 'io_timeout': False, 468 'io_timeout': False,
435 'output': '', 469 'output': '',
436 'output_chunk_start': 0, 470 'output_chunk_start': 0,
437 'task_id': resp['task_id'], 471 'task_id': resp['task_id'],
438 } 472 }
439 net.url_read_json( 473 botobj.remote.url_read_json(
440 botobj.server + '/swarming/api/v1/bot/task_update/%s' % resp['task_id'], 474 '/swarming/api/v1/bot/task_update/%s' % resp['task_id'],
441 data=params) 475 data=params)
442 return False 476 return False
443 477
444 if cmd == 'run': 478 if cmd == 'run':
445 if run_manifest(botobj, resp['manifest'], start): 479 if run_manifest(botobj, resp['manifest'], start):
446 # Completed a task successfully so update swarming_bot.zip if necessary. 480 # Completed a task successfully so update swarming_bot.zip if necessary.
447 update_lkgbc(botobj) 481 update_lkgbc(botobj)
448 # Clean up cache after a task 482 # Clean up cache after a task
449 clean_isolated_cache(botobj) 483 clean_isolated_cache(botobj)
450 # TODO(maruel): Handle the case where quit_bit.is_set() happens here. This 484 # TODO(maruel): Handle the case where quit_bit.is_set() happens here. This
(...skipping 38 matching lines...) Expand 10 before | Expand all | Expand 10 after
489 if not manifest['command']: 523 if not manifest['command']:
490 hard_timeout += manifest['io_timeout'] or 600 524 hard_timeout += manifest['io_timeout'] or 600
491 525
492 url = manifest.get('host', botobj.server) 526 url = manifest.get('host', botobj.server)
493 task_dimensions = manifest['dimensions'] 527 task_dimensions = manifest['dimensions']
494 task_result = {} 528 task_result = {}
495 529
496 failure = False 530 failure = False
497 internal_failure = False 531 internal_failure = False
498 msg = None 532 msg = None
533 auth_params_dumper = None
499 work_dir = os.path.join(botobj.base_dir, 'work') 534 work_dir = os.path.join(botobj.base_dir, 'work')
500 try: 535 try:
501 try: 536 try:
502 if os.path.isdir(work_dir): 537 if os.path.isdir(work_dir):
503 file_path.rmtree(work_dir) 538 file_path.rmtree(work_dir)
504 except OSError: 539 except OSError:
505 # If a previous task created an undeleteable file/directory inside 'work', 540 # If a previous task created an undeleteable file/directory inside 'work',
506 # make sure that following tasks are not affected. This is done by working 541 # make sure that following tasks are not affected. This is done by working
507 # around the undeleteable directory by creating a temporary directory 542 # around the undeleteable directory by creating a temporary directory
508 # instead. This is not normal behavior. The bot will report a failure on 543 # instead. This is not normal behavior. The bot will report a failure on
509 # start. 544 # start.
510 work_dir = tempfile.mkdtemp(dir=botobj.base_dir, prefix='work') 545 work_dir = tempfile.mkdtemp(dir=botobj.base_dir, prefix='work')
511 else: 546 else:
512 os.makedirs(work_dir) 547 os.makedirs(work_dir)
513 548
514 env = os.environ.copy() 549 env = os.environ.copy()
515 # Windows in particular does not tolerate unicode strings in environment 550 # Windows in particular does not tolerate unicode strings in environment
516 # variables. 551 # variables.
517 env['SWARMING_TASK_ID'] = task_id.encode('ascii') 552 env['SWARMING_TASK_ID'] = task_id.encode('ascii')
518 553
519 task_in_file = os.path.join(work_dir, 'task_runner_in.json') 554 task_in_file = os.path.join(work_dir, 'task_runner_in.json')
520 with open(task_in_file, 'wb') as f: 555 with open(task_in_file, 'wb') as f:
521 f.write(json.dumps(manifest)) 556 f.write(json.dumps(manifest))
522 call_hook(botobj, 'on_before_task') 557 call_hook(botobj, 'on_before_task')
523 task_result_file = os.path.join(work_dir, 'task_runner_out.json') 558 task_result_file = os.path.join(work_dir, 'task_runner_out.json')
524 if os.path.exists(task_result_file): 559 if os.path.exists(task_result_file):
525 os.remove(task_result_file) 560 os.remove(task_result_file)
561
562 # Start a thread that periodically puts authentication headers and other
563 # authentication related information to a file on disk. task_runner and its
564 # subprocesses read it from there before making authenticated HTTP calls.
565 auth_params_file = os.path.join(work_dir, 'bot_auth_params.json')
566 if botobj.remote.uses_auth:
567 env['SWARMING_AUTH_PARAMS'] = str(auth_params_file)
568 auth_params_dumper = file_refresher.FileRefresherThread(
569 auth_params_file, lambda: bot_auth.prepare_auth_params_json(botobj))
570 auth_params_dumper.start()
571 else:
572 env.pop('SWARMING_AUTH_PARAMS', None)
573 if os.path.exists(auth_params_file):
574 os.remove(auth_params_file)
575
526 command = [ 576 command = [
527 sys.executable, THIS_FILE, 'task_runner', 577 sys.executable, THIS_FILE, 'task_runner',
528 '--swarming-server', url, 578 '--swarming-server', url,
529 '--in-file', task_in_file, 579 '--in-file', task_in_file,
530 '--out-file', task_result_file, 580 '--out-file', task_result_file,
531 '--cost-usd-hour', str(botobj.state.get('cost_usd_hour') or 0.), 581 '--cost-usd-hour', str(botobj.state.get('cost_usd_hour') or 0.),
532 # Include the time taken to poll the task in the cost. 582 # Include the time taken to poll the task in the cost.
533 '--start', str(start), 583 '--start', str(start),
534 '--min-free-space', str(get_min_free_space()), 584 '--min-free-space', str(get_min_free_space()),
535 ] 585 ]
536 logging.debug('Running command: %s', command) 586 logging.debug('Running command: %s', command)
587
537 # Put the output file into the current working directory, which should be 588 # Put the output file into the current working directory, which should be
538 # the one containing swarming_bot.zip. 589 # the one containing swarming_bot.zip.
539 log_path = os.path.join(botobj.base_dir, 'logs', 'task_runner_stdout.log') 590 log_path = os.path.join(botobj.base_dir, 'logs', 'task_runner_stdout.log')
540 os_utilities.roll_log(log_path) 591 os_utilities.roll_log(log_path)
541 os_utilities.trim_rolled_log(log_path) 592 os_utilities.trim_rolled_log(log_path)
542 with open(log_path, 'a+b') as f: 593 with open(log_path, 'a+b') as f:
543 proc = subprocess42.Popen( 594 proc = subprocess42.Popen(
544 command, 595 command,
545 detached=True, 596 detached=True,
546 cwd=botobj.base_dir, 597 cwd=botobj.base_dir,
(...skipping 40 matching lines...) Expand 10 before | Expand all | Expand 10 after
587 failure = bool(task_result.get('exit_code')) if task_result else False 638 failure = bool(task_result.get('exit_code')) if task_result else False
588 return not internal_failure and not failure 639 return not internal_failure and not failure
589 except Exception as e: 640 except Exception as e:
590 # Failures include IOError when writing if the disk is full, OSError if 641 # Failures include IOError when writing if the disk is full, OSError if
591 # swarming_bot.zip doesn't exist anymore, etc. 642 # swarming_bot.zip doesn't exist anymore, etc.
592 logging.exception('run_manifest failed') 643 logging.exception('run_manifest failed')
593 msg = 'Internal exception occured: %s\n%s' % ( 644 msg = 'Internal exception occured: %s\n%s' % (
594 e, traceback.format_exc()[-2048:]) 645 e, traceback.format_exc()[-2048:])
595 internal_failure = True 646 internal_failure = True
596 finally: 647 finally:
648 if auth_params_dumper:
649 auth_params_dumper.stop()
597 if internal_failure: 650 if internal_failure:
598 post_error_task(botobj, msg, task_id) 651 post_error_task(botobj, msg, task_id)
599 call_hook( 652 call_hook(
600 botobj, 'on_after_task', failure, internal_failure, task_dimensions, 653 botobj, 'on_after_task', failure, internal_failure, task_dimensions,
601 task_result) 654 task_result)
602 if os.path.isdir(work_dir): 655 if os.path.isdir(work_dir):
603 try: 656 try:
604 file_path.rmtree(work_dir) 657 file_path.rmtree(work_dir)
605 except Exception as e: 658 except Exception as e:
606 botobj.post_error( 659 botobj.post_error(
(...skipping 10 matching lines...) Expand all
617 670
618 Does not return. 671 Does not return.
619 """ 672 """
620 # Alternate between .1.zip and .2.zip. 673 # Alternate between .1.zip and .2.zip.
621 new_zip = 'swarming_bot.1.zip' 674 new_zip = 'swarming_bot.1.zip'
622 if os.path.basename(THIS_FILE) == new_zip: 675 if os.path.basename(THIS_FILE) == new_zip:
623 new_zip = 'swarming_bot.2.zip' 676 new_zip = 'swarming_bot.2.zip'
624 new_zip = os.path.join(os.path.dirname(THIS_FILE), new_zip) 677 new_zip = os.path.join(os.path.dirname(THIS_FILE), new_zip)
625 678
626 # Download as a new file. 679 # Download as a new file.
627 url = botobj.server + '/swarming/api/v1/bot/bot_code/%s' % version 680 url_path = '/swarming/api/v1/bot/bot_code/%s' % version
628 if not net.url_retrieve(new_zip, url): 681 if not botobj.remote.url_retrieve(new_zip, url_path):
629 # It can happen when a server is rapidly updated multiple times in a row. 682 # It can happen when a server is rapidly updated multiple times in a row.
630 botobj.post_error( 683 botobj.post_error(
631 'Unable to download %s from %s; first tried version %s' % 684 'Unable to download %s from %s; first tried version %s' %
632 (new_zip, url, version)) 685 (new_zip, botobj.server + url_path, version))
633 # Poll again, this may work next time. To prevent busy-loop, sleep a little. 686 # Poll again, this may work next time. To prevent busy-loop, sleep a little.
634 time.sleep(2) 687 time.sleep(2)
635 return 688 return
636 689
637 s = os.stat(new_zip) 690 s = os.stat(new_zip)
638 logging.info('Restarting to %s; %d bytes.', new_zip, s.st_size) 691 logging.info('Restarting to %s; %d bytes.', new_zip, s.st_size)
639 sys.stdout.flush() 692 sys.stdout.flush()
640 sys.stderr.flush() 693 sys.stderr.flush()
641 694
642 proc = subprocess42.Popen( 695 proc = subprocess42.Popen(
(...skipping 94 matching lines...) Expand 10 before | Expand all | Expand 10 after
737 os.path.dirname(THIS_FILE), 'logs', 'bot_std%s.log' % t) 790 os.path.dirname(THIS_FILE), 'logs', 'bot_std%s.log' % t)
738 os_utilities.roll_log(log_path) 791 os_utilities.roll_log(log_path)
739 os_utilities.trim_rolled_log(log_path) 792 os_utilities.trim_rolled_log(log_path)
740 793
741 error = None 794 error = None
742 if len(args) != 0: 795 if len(args) != 0:
743 error = 'Unexpected arguments: %s' % args 796 error = 'Unexpected arguments: %s' % args
744 try: 797 try:
745 return run_bot(error) 798 return run_bot(error)
746 finally: 799 finally:
747 call_hook(bot.Bot(None, None, None, os.path.dirname(THIS_FILE), None), 800 call_hook(bot.Bot(None, None, None, None, os.path.dirname(THIS_FILE), None),
748 'on_bot_shutdown') 801 'on_bot_shutdown')
749 logging.info('main() returning') 802 logging.info('main() returning')
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698