addon-sdk/source/python-lib/mozrunner/__init__.py

branch
TOR_BUG_3246
changeset 7
129ffea94266
equal deleted inserted replaced
-1:000000000000 0:c208ee2b5100
1 # This Source Code Form is subject to the terms of the Mozilla Public
2 # License, v. 2.0. If a copy of the MPL was not distributed with this
3 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5 import os
6 import sys
7 import copy
8 import tempfile
9 import signal
10 import commands
11 import zipfile
12 import optparse
13 import killableprocess
14 import subprocess
15 import platform
16 import shutil
17 from StringIO import StringIO
18 from xml.dom import minidom
19
20 from distutils import dir_util
21 from time import sleep
22
23 # conditional (version-dependent) imports
24 try:
25 import simplejson
26 except ImportError:
27 import json as simplejson
28
29 import logging
30 logger = logging.getLogger(__name__)
31
32 # Use dir_util for copy/rm operations because shutil is all kinds of broken
33 copytree = dir_util.copy_tree
34 rmtree = dir_util.remove_tree
35
36 def findInPath(fileName, path=os.environ['PATH']):
37 dirs = path.split(os.pathsep)
38 for dir in dirs:
39 if os.path.isfile(os.path.join(dir, fileName)):
40 return os.path.join(dir, fileName)
41 if os.name == 'nt' or sys.platform == 'cygwin':
42 if os.path.isfile(os.path.join(dir, fileName + ".exe")):
43 return os.path.join(dir, fileName + ".exe")
44 return None
45
46 stdout = sys.stdout
47 stderr = sys.stderr
48 stdin = sys.stdin
49
50 def run_command(cmd, env=None, **kwargs):
51 """Run the given command in killable process."""
52 killable_kwargs = {'stdout':stdout ,'stderr':stderr, 'stdin':stdin}
53 killable_kwargs.update(kwargs)
54
55 if sys.platform != "win32":
56 return killableprocess.Popen(cmd, preexec_fn=lambda : os.setpgid(0, 0),
57 env=env, **killable_kwargs)
58 else:
59 return killableprocess.Popen(cmd, env=env, **killable_kwargs)
60
61 def getoutput(l):
62 tmp = tempfile.mktemp()
63 x = open(tmp, 'w')
64 subprocess.call(l, stdout=x, stderr=x)
65 x.close(); x = open(tmp, 'r')
66 r = x.read() ; x.close()
67 os.remove(tmp)
68 return r
69
70 def get_pids(name, minimun_pid=0):
71 """Get all the pids matching name, exclude any pids below minimum_pid."""
72 if os.name == 'nt' or sys.platform == 'cygwin':
73 import wpk
74
75 pids = wpk.get_pids(name)
76
77 else:
78 data = getoutput(['ps', 'ax']).splitlines()
79 pids = [int(line.split()[0]) for line in data if line.find(name) is not -1]
80
81 matching_pids = [m for m in pids if m > minimun_pid]
82 return matching_pids
83
84 def makedirs(name):
85
86 head, tail = os.path.split(name)
87 if not tail:
88 head, tail = os.path.split(head)
89 if head and tail and not os.path.exists(head):
90 try:
91 makedirs(head)
92 except OSError, e:
93 pass
94 if tail == os.curdir: # xxx/newdir/. exists if xxx/newdir exists
95 return
96 try:
97 os.mkdir(name)
98 except:
99 pass
100
101 # addon_details() copied from mozprofile
102 def addon_details(install_rdf_fh):
103 """
104 returns a dictionary of details about the addon
105 - addon_path : path to the addon directory
106 Returns:
107 {'id': u'rainbow@colors.org', # id of the addon
108 'version': u'1.4', # version of the addon
109 'name': u'Rainbow', # name of the addon
110 'unpack': # whether to unpack the addon
111 """
112
113 details = {
114 'id': None,
115 'unpack': False,
116 'name': None,
117 'version': None
118 }
119
120 def get_namespace_id(doc, url):
121 attributes = doc.documentElement.attributes
122 namespace = ""
123 for i in range(attributes.length):
124 if attributes.item(i).value == url:
125 if ":" in attributes.item(i).name:
126 # If the namespace is not the default one remove 'xlmns:'
127 namespace = attributes.item(i).name.split(':')[1] + ":"
128 break
129 return namespace
130
131 def get_text(element):
132 """Retrieve the text value of a given node"""
133 rc = []
134 for node in element.childNodes:
135 if node.nodeType == node.TEXT_NODE:
136 rc.append(node.data)
137 return ''.join(rc).strip()
138
139 doc = minidom.parse(install_rdf_fh)
140
141 # Get the namespaces abbreviations
142 em = get_namespace_id(doc, "http://www.mozilla.org/2004/em-rdf#")
143 rdf = get_namespace_id(doc, "http://www.w3.org/1999/02/22-rdf-syntax-ns#")
144
145 description = doc.getElementsByTagName(rdf + "Description").item(0)
146 for node in description.childNodes:
147 # Remove the namespace prefix from the tag for comparison
148 entry = node.nodeName.replace(em, "")
149 if entry in details.keys():
150 details.update({ entry: get_text(node) })
151
152 # turn unpack into a true/false value
153 if isinstance(details['unpack'], basestring):
154 details['unpack'] = details['unpack'].lower() == 'true'
155
156 return details
157
158 class Profile(object):
159 """Handles all operations regarding profile. Created new profiles, installs extensions,
160 sets preferences and handles cleanup."""
161
162 def __init__(self, binary=None, profile=None, addons=None,
163 preferences=None):
164
165 self.binary = binary
166
167 self.create_new = not(bool(profile))
168 if profile:
169 self.profile = profile
170 else:
171 self.profile = self.create_new_profile(self.binary)
172
173 self.addons_installed = []
174 self.addons = addons or []
175
176 ### set preferences from class preferences
177 preferences = preferences or {}
178 if hasattr(self.__class__, 'preferences'):
179 self.preferences = self.__class__.preferences.copy()
180 else:
181 self.preferences = {}
182 self.preferences.update(preferences)
183
184 for addon in self.addons:
185 self.install_addon(addon)
186
187 self.set_preferences(self.preferences)
188
189 def create_new_profile(self, binary):
190 """Create a new clean profile in tmp which is a simple empty folder"""
191 profile = tempfile.mkdtemp(suffix='.mozrunner')
192 return profile
193
194 def unpack_addon(self, xpi_zipfile, addon_path):
195 for name in xpi_zipfile.namelist():
196 if name.endswith('/'):
197 makedirs(os.path.join(addon_path, name))
198 else:
199 if not os.path.isdir(os.path.dirname(os.path.join(addon_path, name))):
200 makedirs(os.path.dirname(os.path.join(addon_path, name)))
201 data = xpi_zipfile.read(name)
202 f = open(os.path.join(addon_path, name), 'wb')
203 f.write(data) ; f.close()
204 zi = xpi_zipfile.getinfo(name)
205 os.chmod(os.path.join(addon_path,name), (zi.external_attr>>16))
206
207 def install_addon(self, path):
208 """Installs the given addon or directory of addons in the profile."""
209
210 extensions_path = os.path.join(self.profile, 'extensions')
211 if not os.path.exists(extensions_path):
212 os.makedirs(extensions_path)
213
214 addons = [path]
215 if not path.endswith('.xpi') and not os.path.exists(os.path.join(path, 'install.rdf')):
216 addons = [os.path.join(path, x) for x in os.listdir(path)]
217
218 for addon in addons:
219 if addon.endswith('.xpi'):
220 xpi_zipfile = zipfile.ZipFile(addon, "r")
221 details = addon_details(StringIO(xpi_zipfile.read('install.rdf')))
222 addon_path = os.path.join(extensions_path, details["id"])
223 if details.get("unpack", True):
224 self.unpack_addon(xpi_zipfile, addon_path)
225 self.addons_installed.append(addon_path)
226 else:
227 shutil.copy(addon, addon_path + '.xpi')
228 else:
229 # it's already unpacked, but we need to extract the id so we
230 # can copy it
231 details = addon_details(open(os.path.join(addon, "install.rdf"), "rb"))
232 addon_path = os.path.join(extensions_path, details["id"])
233 shutil.copytree(addon, addon_path, symlinks=True)
234
235 def set_preferences(self, preferences):
236 """Adds preferences dict to profile preferences"""
237 prefs_file = os.path.join(self.profile, 'user.js')
238 # Ensure that the file exists first otherwise create an empty file
239 if os.path.isfile(prefs_file):
240 f = open(prefs_file, 'a+')
241 else:
242 f = open(prefs_file, 'w')
243
244 f.write('\n#MozRunner Prefs Start\n')
245
246 pref_lines = ['user_pref(%s, %s);' %
247 (simplejson.dumps(k), simplejson.dumps(v) ) for k, v in
248 preferences.items()]
249 for line in pref_lines:
250 f.write(line+'\n')
251 f.write('#MozRunner Prefs End\n')
252 f.flush() ; f.close()
253
254 def pop_preferences(self):
255 """
256 pop the last set of preferences added
257 returns True if popped
258 """
259
260 # our magic markers
261 delimeters = ('#MozRunner Prefs Start', '#MozRunner Prefs End')
262
263 lines = file(os.path.join(self.profile, 'user.js')).read().splitlines()
264 def last_index(_list, value):
265 """
266 returns the last index of an item;
267 this should actually be part of python code but it isn't
268 """
269 for index in reversed(range(len(_list))):
270 if _list[index] == value:
271 return index
272 s = last_index(lines, delimeters[0])
273 e = last_index(lines, delimeters[1])
274
275 # ensure both markers are found
276 if s is None:
277 assert e is None, '%s found without %s' % (delimeters[1], delimeters[0])
278 return False # no preferences found
279 elif e is None:
280 assert e is None, '%s found without %s' % (delimeters[0], delimeters[1])
281
282 # ensure the markers are in the proper order
283 assert e > s, '%s found at %s, while %s found at %s' (delimeter[1], e, delimeter[0], s)
284
285 # write the prefs
286 cleaned_prefs = '\n'.join(lines[:s] + lines[e+1:])
287 f = file(os.path.join(self.profile, 'user.js'), 'w')
288 f.write(cleaned_prefs)
289 f.close()
290 return True
291
292 def clean_preferences(self):
293 """Removed preferences added by mozrunner."""
294 while True:
295 if not self.pop_preferences():
296 break
297
298 def clean_addons(self):
299 """Cleans up addons in the profile."""
300 for addon in self.addons_installed:
301 if os.path.isdir(addon):
302 rmtree(addon)
303
304 def cleanup(self):
305 """Cleanup operations on the profile."""
306 def oncleanup_error(function, path, excinfo):
307 #TODO: How should we handle this?
308 print "Error Cleaning up: " + str(excinfo[1])
309 if self.create_new:
310 shutil.rmtree(self.profile, False, oncleanup_error)
311 else:
312 self.clean_preferences()
313 self.clean_addons()
314
315 class FirefoxProfile(Profile):
316 """Specialized Profile subclass for Firefox"""
317 preferences = {# Don't automatically update the application
318 'app.update.enabled' : False,
319 # Don't restore the last open set of tabs if the browser has crashed
320 'browser.sessionstore.resume_from_crash': False,
321 # Don't check for the default web browser
322 'browser.shell.checkDefaultBrowser' : False,
323 # Don't warn on exit when multiple tabs are open
324 'browser.tabs.warnOnClose' : False,
325 # Don't warn when exiting the browser
326 'browser.warnOnQuit': False,
327 # Only install add-ons from the profile and the app folder
328 'extensions.enabledScopes' : 5,
329 # Don't automatically update add-ons
330 'extensions.update.enabled' : False,
331 # Don't open a dialog to show available add-on updates
332 'extensions.update.notifyUser' : False,
333 }
334
335 # The possible names of application bundles on Mac OS X, in order of
336 # preference from most to least preferred.
337 # Note: Nightly is obsolete, as it has been renamed to FirefoxNightly,
338 # but it will still be present if users update an older nightly build
339 # via the app update service.
340 bundle_names = ['Firefox', 'FirefoxNightly', 'Nightly']
341
342 # The possible names of binaries, in order of preference from most to least
343 # preferred.
344 @property
345 def names(self):
346 if sys.platform == 'darwin':
347 return ['firefox', 'nightly', 'shiretoko']
348 if (sys.platform == 'linux2') or (sys.platform in ('sunos5', 'solaris')):
349 return ['firefox', 'mozilla-firefox', 'iceweasel']
350 if os.name == 'nt' or sys.platform == 'cygwin':
351 return ['firefox']
352
353 class ThunderbirdProfile(Profile):
354 preferences = {'extensions.update.enabled' : False,
355 'extensions.update.notifyUser' : False,
356 'browser.shell.checkDefaultBrowser' : False,
357 'browser.tabs.warnOnClose' : False,
358 'browser.warnOnQuit': False,
359 'browser.sessionstore.resume_from_crash': False,
360 }
361
362 # The possible names of application bundles on Mac OS X, in order of
363 # preference from most to least preferred.
364 bundle_names = ["Thunderbird", "Shredder"]
365
366 # The possible names of binaries, in order of preference from most to least
367 # preferred.
368 names = ["thunderbird", "shredder"]
369
370
371 class Runner(object):
372 """Handles all running operations. Finds bins, runs and kills the process."""
373
374 def __init__(self, binary=None, profile=None, cmdargs=[], env=None,
375 kp_kwargs={}):
376 if binary is None:
377 self.binary = self.find_binary()
378 elif sys.platform == 'darwin' and binary.find('Contents/MacOS/') == -1:
379 self.binary = os.path.join(binary, 'Contents/MacOS/%s-bin' % self.names[0])
380 else:
381 self.binary = binary
382
383 if not os.path.exists(self.binary):
384 raise Exception("Binary path does not exist "+self.binary)
385
386 if sys.platform == 'linux2' and self.binary.endswith('-bin'):
387 dirname = os.path.dirname(self.binary)
388 if os.environ.get('LD_LIBRARY_PATH', None):
389 os.environ['LD_LIBRARY_PATH'] = '%s:%s' % (os.environ['LD_LIBRARY_PATH'], dirname)
390 else:
391 os.environ['LD_LIBRARY_PATH'] = dirname
392
393 # Disable the crash reporter by default
394 os.environ['MOZ_CRASHREPORTER_NO_REPORT'] = '1'
395
396 self.profile = profile
397
398 self.cmdargs = cmdargs
399 if env is None:
400 self.env = copy.copy(os.environ)
401 self.env.update({'MOZ_NO_REMOTE':"1",})
402 else:
403 self.env = env
404 self.kp_kwargs = kp_kwargs or {}
405
406 def find_binary(self):
407 """Finds the binary for self.names if one was not provided."""
408 binary = None
409 if sys.platform in ('linux2', 'sunos5', 'solaris') \
410 or sys.platform.startswith('freebsd'):
411 for name in reversed(self.names):
412 binary = findInPath(name)
413 elif os.name == 'nt' or sys.platform == 'cygwin':
414
415 # find the default executable from the windows registry
416 try:
417 import _winreg
418 except ImportError:
419 pass
420 else:
421 sam_flags = [0]
422 # KEY_WOW64_32KEY etc only appeared in 2.6+, but that's OK as
423 # only 2.6+ has functioning 64bit builds.
424 if hasattr(_winreg, "KEY_WOW64_32KEY"):
425 if "64 bit" in sys.version:
426 # a 64bit Python should also look in the 32bit registry
427 sam_flags.append(_winreg.KEY_WOW64_32KEY)
428 else:
429 # possibly a 32bit Python on 64bit Windows, so look in
430 # the 64bit registry incase there is a 64bit app.
431 sam_flags.append(_winreg.KEY_WOW64_64KEY)
432 for sam_flag in sam_flags:
433 try:
434 # assumes self.app_name is defined, as it should be for
435 # implementors
436 keyname = r"Software\Mozilla\Mozilla %s" % self.app_name
437 sam = _winreg.KEY_READ | sam_flag
438 app_key = _winreg.OpenKey(_winreg.HKEY_LOCAL_MACHINE, keyname, 0, sam)
439 version, _type = _winreg.QueryValueEx(app_key, "CurrentVersion")
440 version_key = _winreg.OpenKey(app_key, version + r"\Main")
441 path, _ = _winreg.QueryValueEx(version_key, "PathToExe")
442 return path
443 except _winreg.error:
444 pass
445
446 # search for the binary in the path
447 for name in reversed(self.names):
448 binary = findInPath(name)
449 if sys.platform == 'cygwin':
450 program_files = os.environ['PROGRAMFILES']
451 else:
452 program_files = os.environ['ProgramFiles']
453
454 if binary is None:
455 for bin in [(program_files, 'Mozilla Firefox', 'firefox.exe'),
456 (os.environ.get("ProgramFiles(x86)"),'Mozilla Firefox', 'firefox.exe'),
457 (program_files, 'Nightly', 'firefox.exe'),
458 (os.environ.get("ProgramFiles(x86)"),'Nightly', 'firefox.exe'),
459 (program_files, 'Aurora', 'firefox.exe'),
460 (os.environ.get("ProgramFiles(x86)"),'Aurora', 'firefox.exe')
461 ]:
462 path = os.path.join(*bin)
463 if os.path.isfile(path):
464 binary = path
465 break
466 elif sys.platform == 'darwin':
467 for bundle_name in self.bundle_names:
468 # Look for the application bundle in the user's home directory
469 # or the system-wide /Applications directory. If we don't find
470 # it in one of those locations, we move on to the next possible
471 # bundle name.
472 appdir = os.path.join("~/Applications/%s.app" % bundle_name)
473 if not os.path.isdir(appdir):
474 appdir = "/Applications/%s.app" % bundle_name
475 if not os.path.isdir(appdir):
476 continue
477
478 # Look for a binary with any of the possible binary names
479 # inside the application bundle.
480 for binname in self.names:
481 binpath = os.path.join(appdir,
482 "Contents/MacOS/%s-bin" % binname)
483 if (os.path.isfile(binpath)):
484 binary = binpath
485 break
486
487 if binary:
488 break
489
490 if binary is None:
491 raise Exception('Mozrunner could not locate your binary, you will need to set it.')
492 return binary
493
494 @property
495 def command(self):
496 """Returns the command list to run."""
497 cmd = [self.binary, '-profile', self.profile.profile]
498 # On i386 OS X machines, i386+x86_64 universal binaries need to be told
499 # to run as i386 binaries. If we're not running a i386+x86_64 universal
500 # binary, then this command modification is harmless.
501 if sys.platform == 'darwin':
502 if hasattr(platform, 'architecture') and platform.architecture()[0] == '32bit':
503 cmd = ['arch', '-i386'] + cmd
504 return cmd
505
506 def get_repositoryInfo(self):
507 """Read repository information from application.ini and platform.ini."""
508 import ConfigParser
509
510 config = ConfigParser.RawConfigParser()
511 dirname = os.path.dirname(self.binary)
512 repository = { }
513
514 for entry in [['application', 'App'], ['platform', 'Build']]:
515 (file, section) = entry
516 config.read(os.path.join(dirname, '%s.ini' % file))
517
518 for entry in [['SourceRepository', 'repository'], ['SourceStamp', 'changeset']]:
519 (key, id) = entry
520
521 try:
522 repository['%s_%s' % (file, id)] = config.get(section, key);
523 except:
524 repository['%s_%s' % (file, id)] = None
525
526 return repository
527
528 def start(self):
529 """Run self.command in the proper environment."""
530 if self.profile is None:
531 self.profile = self.profile_class()
532 self.process_handler = run_command(self.command+self.cmdargs, self.env, **self.kp_kwargs)
533
534 def wait(self, timeout=None):
535 """Wait for the browser to exit."""
536 self.process_handler.wait(timeout=timeout)
537
538 if sys.platform != 'win32':
539 for name in self.names:
540 for pid in get_pids(name, self.process_handler.pid):
541 self.process_handler.pid = pid
542 self.process_handler.wait(timeout=timeout)
543
544 def kill(self, kill_signal=signal.SIGTERM):
545 """Kill the browser"""
546 if sys.platform != 'win32':
547 self.process_handler.kill()
548 for name in self.names:
549 for pid in get_pids(name, self.process_handler.pid):
550 self.process_handler.pid = pid
551 self.process_handler.kill()
552 else:
553 try:
554 self.process_handler.kill(group=True)
555 # On windows, it sometimes behooves one to wait for dust to settle
556 # after killing processes. Let's try that.
557 # TODO: Bug 640047 is invesitgating the correct way to handle this case
558 self.process_handler.wait(timeout=10)
559 except Exception, e:
560 logger.error('Cannot kill process, '+type(e).__name__+' '+e.message)
561
562 def stop(self):
563 self.kill()
564
565 class FirefoxRunner(Runner):
566 """Specialized Runner subclass for running Firefox."""
567
568 app_name = 'Firefox'
569 profile_class = FirefoxProfile
570
571 # The possible names of application bundles on Mac OS X, in order of
572 # preference from most to least preferred.
573 # Note: Nightly is obsolete, as it has been renamed to FirefoxNightly,
574 # but it will still be present if users update an older nightly build
575 # only via the app update service.
576 bundle_names = ['Firefox', 'FirefoxNightly', 'Nightly']
577
578 @property
579 def names(self):
580 if sys.platform == 'darwin':
581 return ['firefox', 'nightly', 'shiretoko']
582 if sys.platform in ('linux2', 'sunos5', 'solaris') \
583 or sys.platform.startswith('freebsd'):
584 return ['firefox', 'mozilla-firefox', 'iceweasel']
585 if os.name == 'nt' or sys.platform == 'cygwin':
586 return ['firefox']
587
588 class ThunderbirdRunner(Runner):
589 """Specialized Runner subclass for running Thunderbird"""
590
591 app_name = 'Thunderbird'
592 profile_class = ThunderbirdProfile
593
594 # The possible names of application bundles on Mac OS X, in order of
595 # preference from most to least preferred.
596 bundle_names = ["Thunderbird", "Shredder"]
597
598 # The possible names of binaries, in order of preference from most to least
599 # preferred.
600 names = ["thunderbird", "shredder"]
601
602 class CLI(object):
603 """Command line interface."""
604
605 runner_class = FirefoxRunner
606 profile_class = FirefoxProfile
607 module = "mozrunner"
608
609 parser_options = {("-b", "--binary",): dict(dest="binary", help="Binary path.",
610 metavar=None, default=None),
611 ('-p', "--profile",): dict(dest="profile", help="Profile path.",
612 metavar=None, default=None),
613 ('-a', "--addons",): dict(dest="addons",
614 help="Addons paths to install.",
615 metavar=None, default=None),
616 ("--info",): dict(dest="info", default=False,
617 action="store_true",
618 help="Print module information")
619 }
620
621 def __init__(self):
622 """ Setup command line parser and parse arguments """
623 self.metadata = self.get_metadata_from_egg()
624 self.parser = optparse.OptionParser(version="%prog " + self.metadata["Version"])
625 for names, opts in self.parser_options.items():
626 self.parser.add_option(*names, **opts)
627 (self.options, self.args) = self.parser.parse_args()
628
629 if self.options.info:
630 self.print_metadata()
631 sys.exit(0)
632
633 # XXX should use action='append' instead of rolling our own
634 try:
635 self.addons = self.options.addons.split(',')
636 except:
637 self.addons = []
638
639 def get_metadata_from_egg(self):
640 import pkg_resources
641 ret = {}
642 dist = pkg_resources.get_distribution(self.module)
643 if dist.has_metadata("PKG-INFO"):
644 for line in dist.get_metadata_lines("PKG-INFO"):
645 key, value = line.split(':', 1)
646 ret[key] = value
647 if dist.has_metadata("requires.txt"):
648 ret["Dependencies"] = "\n" + dist.get_metadata("requires.txt")
649 return ret
650
651 def print_metadata(self, data=("Name", "Version", "Summary", "Home-page",
652 "Author", "Author-email", "License", "Platform", "Dependencies")):
653 for key in data:
654 if key in self.metadata:
655 print key + ": " + self.metadata[key]
656
657 def create_runner(self):
658 """ Get the runner object """
659 runner = self.get_runner(binary=self.options.binary)
660 profile = self.get_profile(binary=runner.binary,
661 profile=self.options.profile,
662 addons=self.addons)
663 runner.profile = profile
664 return runner
665
666 def get_runner(self, binary=None, profile=None):
667 """Returns the runner instance for the given command line binary argument
668 the profile instance returned from self.get_profile()."""
669 return self.runner_class(binary, profile)
670
671 def get_profile(self, binary=None, profile=None, addons=None, preferences=None):
672 """Returns the profile instance for the given command line arguments."""
673 addons = addons or []
674 preferences = preferences or {}
675 return self.profile_class(binary, profile, addons, preferences)
676
677 def run(self):
678 runner = self.create_runner()
679 self.start(runner)
680 runner.profile.cleanup()
681
682 def start(self, runner):
683 """Starts the runner and waits for Firefox to exitor Keyboard Interrupt.
684 Shoule be overwritten to provide custom running of the runner instance."""
685 runner.start()
686 print 'Started:', ' '.join(runner.command)
687 try:
688 runner.wait()
689 except KeyboardInterrupt:
690 runner.stop()
691
692
693 def cli():
694 CLI().run()

mercurial