4 DialCentral - Front end for Google's Grand Central service.
5 Copyright (C) 2008 Mark Bergman bergman AT merctech DOT com
7 This library is free software; you can redistribute it and/or
8 modify it under the terms of the GNU Lesser General Public
9 License as published by the Free Software Foundation; either
10 version 2.1 of the License, or (at your option) any later version.
12 This library is distributed in the hope that it will be useful,
13 but WITHOUT ANY WARRANTY; without even the implied warranty of
14 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15 Lesser General Public License for more details.
17 You should have received a copy of the GNU Lesser General Public
18 License along with this library; if not, write to the Free Software
19 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
23 from __future__ import with_statement
42 def getmtime_nothrow(path):
44 return os.path.getmtime(path)
49 def display_error_message(msg):
50 error_dialog = gtk.MessageDialog(None, 0, gtk.MESSAGE_ERROR, gtk.BUTTONS_CLOSE, msg)
52 def close(dialog, response):
54 error_dialog.connect("response", close)
58 class Dialcentral(object):
61 os.path.join(os.path.dirname(__file__), "dialcentral.glade"),
62 os.path.join(os.path.dirname(__file__), "../lib/dialcentral.glade"),
63 '/usr/lib/dialcentral/dialcentral.glade',
74 BACKENDS = (NULL_BACKEND, GV_BACKEND)
77 self._initDone = False
78 self._connection = None
80 self._clipboard = gtk.clipboard_get()
82 self._credentials = ("", "")
83 self._selectedBackendId = self.NULL_BACKEND
84 self._defaultBackendId = self.GV_BACKEND
85 self._phoneBackends = None
87 self._accountViews = None
88 self._messagesViews = None
89 self._recentViews = None
90 self._contactsViews = None
91 self._alarmHandler = None
92 self._ledHandler = None
93 self._originalCurrentLabels = []
95 for path in self._glade_files:
96 if os.path.isfile(path):
97 self._widgetTree = gtk.glade.XML(path)
100 display_error_message("Cannot find dialcentral.glade")
104 self._window = self._widgetTree.get_widget("mainWindow")
105 self._notebook = self._widgetTree.get_widget("notebook")
106 self._errorDisplay = gtk_toolbox.ErrorDisplay(self._widgetTree)
107 self._credentialsDialog = gtk_toolbox.LoginWindow(self._widgetTree)
109 self._isFullScreen = False
110 self._app = hildonize.get_app_class()()
111 self._window = hildonize.hildonize_window(self._app, self._window)
112 hildonize.hildonize_text_entry(self._widgetTree.get_widget("usernameentry"))
113 hildonize.hildonize_password_entry(self._widgetTree.get_widget("passwordentry"))
114 hildonize.hildonize_combo_entry(self._widgetTree.get_widget("callbackcombo").get_child())
116 for scrollingWidget in (
117 'recent_scrolledwindow',
118 'message_scrolledwindow',
119 'contacts_scrolledwindow',
121 hildonize.hildonize_scrollwindow(self._widgetTree.get_widget(scrollingWidget))
122 for scrollingWidget in (
123 "phoneSelectionMessage_scrolledwindow",
124 "phonetypes_scrolledwindow",
125 "smsMessage_scrolledwindow",
126 "smsMessage_scrolledEntry",
128 hildonize.hildonize_scrollwindow_with_viewport(self._widgetTree.get_widget(scrollingWidget))
130 replacementButtons = [gtk.Button("Test")]
131 menu = hildonize.hildonize_menu(
133 self._widgetTree.get_widget("dialpad_menubar"),
137 if hildonize.IS_HILDON_SUPPORTED:
138 self._window.connect("key-press-event", self._on_key_press)
139 self._window.connect("window-state-event", self._on_window_state_change)
141 logging.warning("No hildonization support")
143 hildonize.set_application_title(self._window, "%s" % constants.__pretty_app_name__)
145 self._window.connect("destroy", self._on_close)
146 self._window.set_default_size(800, 300)
147 self._window.show_all()
149 self._loginSink = gtk_toolbox.threaded_stage(
152 gtk_toolbox.null_sink(),
156 backgroundSetup = threading.Thread(target=self._idle_setup)
157 backgroundSetup.setDaemon(True)
158 backgroundSetup.start()
160 def _idle_setup(self):
162 If something can be done after the UI loads, push it here so it's not blocking the UI
164 # Barebones UI handlers
169 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
170 with gtk_toolbox.gtk_lock():
171 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
172 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
173 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
174 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
175 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
177 self._dialpads[self._selectedBackendId].enable()
178 self._accountViews[self._selectedBackendId].enable()
179 self._recentViews[self._selectedBackendId].enable()
180 self._messagesViews[self._selectedBackendId].enable()
181 self._contactsViews[self._selectedBackendId].enable()
183 with gtk_toolbox.gtk_lock():
184 self._errorDisplay.push_exception()
186 # Setup maemo specifics
190 except (ImportError, OSError):
194 self._osso = osso.Context(constants.__app_name__, constants.__version__, False)
195 device = osso.DeviceState(self._osso)
196 device.set_device_state_callback(self._on_device_state_change, 0)
198 logging.warning("No device state support")
202 self._alarmHandler = alarm_handler.AlarmHandler()
203 except (ImportError, OSError):
206 with gtk_toolbox.gtk_lock():
207 self._errorDisplay.push_exception()
209 logging.warning("No notification support")
210 if hildonize.IS_HILDON_SUPPORTED:
213 self._ledHandler = led_handler.LedHandler()
215 logging.exception('LED Handling failed: "%s"' % str(e))
216 self._ledHandler = None
218 self._ledHandler = None
222 except (ImportError, OSError):
224 self._connection = None
225 if conic is not None:
226 self._connection = conic.Connection()
227 self._connection.connect("connection-event", self._on_connection_change, constants.__app_magic__)
228 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
230 logging.warning("No connection support")
232 with gtk_toolbox.gtk_lock():
233 self._errorDisplay.push_exception()
235 # Setup costly backends
242 os.makedirs(constants._data_path_)
246 gvCookiePath = os.path.join(constants._data_path_, "gv_cookies.txt")
248 self._phoneBackends.update({
249 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
251 with gtk_toolbox.gtk_lock():
252 unifiedDialpad = gv_views.Dialpad(self._widgetTree, self._errorDisplay)
253 self._dialpads.update({
254 self.GV_BACKEND: unifiedDialpad,
256 self._accountViews.update({
257 self.GV_BACKEND: gv_views.AccountInfo(
258 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._alarmHandler, self._errorDisplay
261 self._accountViews[self.GV_BACKEND].save_everything = self._save_settings
262 self._recentViews.update({
263 self.GV_BACKEND: gv_views.RecentCallsView(
264 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
267 self._messagesViews.update({
268 self.GV_BACKEND: gv_views.MessagesView(
269 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
272 self._contactsViews.update({
273 self.GV_BACKEND: gv_views.ContactsView(
274 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
278 fsContactsPath = os.path.join(constants._data_path_, "contacts")
279 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
281 self._dialpads[self.GV_BACKEND].number_selected = self._select_action
282 self._recentViews[self.GV_BACKEND].number_selected = self._select_action
283 self._messagesViews[self.GV_BACKEND].number_selected = self._select_action
284 self._contactsViews[self.GV_BACKEND].number_selected = self._select_action
287 self._phoneBackends[self.GV_BACKEND],
290 mergedBook = gv_views.MergedAddressBook(addressBooks, gv_views.MergedAddressBook.advanced_lastname_sorter)
291 self._contactsViews[self.GV_BACKEND].append(mergedBook)
292 self._contactsViews[self.GV_BACKEND].extend(addressBooks)
293 self._contactsViews[self.GV_BACKEND].open_addressbook(*self._contactsViews[self.GV_BACKEND].get_addressbooks().next()[0][0:2])
296 "on_paste": self._on_paste,
297 "on_refresh": self._on_menu_refresh,
298 "on_rotate": self._on_menu_rotate,
299 "on_clearcookies_clicked": self._on_clearcookies_clicked,
300 "on_about_activate": self._on_about_activate,
302 if hildonize.GTK_MENU_USED:
303 self._widgetTree.signal_autoconnect(callbackMapping)
304 self._notebook.connect("switch-page", self._on_notebook_switch_page)
305 self._widgetTree.get_widget("clearcookies").connect("clicked", self._on_clearcookies_clicked)
307 with gtk_toolbox.gtk_lock():
308 self._originalCurrentLabels = [
309 self._notebook.get_tab_label(self._notebook.get_nth_page(pageIndex)).get_text()
310 for pageIndex in xrange(self._notebook.get_n_pages())
312 self._notebookTapHandler = gtk_toolbox.TapOrHold(self._notebook)
313 self._notebookTapHandler.enable()
314 self._notebookTapHandler.on_tap = self._reset_tab_refresh
315 self._notebookTapHandler.on_hold = self._on_tab_refresh
316 self._notebookTapHandler.on_holding = self._set_tab_refresh
317 self._notebookTapHandler.on_cancel = self._reset_tab_refresh
319 self._initDone = True
321 config = ConfigParser.SafeConfigParser()
322 config.read(constants._user_settings_)
323 with gtk_toolbox.gtk_lock():
324 self.load_settings(config)
326 with gtk_toolbox.gtk_lock():
327 self._errorDisplay.push_exception()
329 self._spawn_attempt_login(2)
331 def attempt_login(self, numOfAttempts = 10, force = False):
333 @todo Handle user notification better like attempting to login and failed login
335 @note This must be run outside of the UI lock
338 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
339 assert self._initDone, "Attempting login before app is fully loaded"
341 serviceId = self.NULL_BACKEND
345 self.refresh_session()
346 serviceId = self._defaultBackendId
349 logging.exception('Session refresh failed with the following message "%s"' % str(e))
352 loggedIn, serviceId = self._login_by_user(numOfAttempts)
354 with gtk_toolbox.gtk_lock():
355 self._change_loggedin_status(serviceId)
357 with gtk_toolbox.gtk_lock():
358 self._errorDisplay.push_exception()
360 def _spawn_attempt_login(self, *args):
361 self._loginSink.send(args)
363 def refresh_session(self):
365 @note Thread agnostic
367 assert self._initDone, "Attempting login before app is fully loaded"
371 loggedIn = self._login_by_cookie()
373 loggedIn = self._login_by_settings()
376 raise RuntimeError("Login Failed")
378 def _login_by_cookie(self):
380 @note Thread agnostic
382 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
384 logging.info("Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId])
387 def _login_by_settings(self):
389 @note Thread agnostic
391 username, password = self._credentials
392 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
394 self._credentials = username, password
395 logging.info("Logged into %r through settings" % self._phoneBackends[self._defaultBackendId])
398 def _login_by_user(self, numOfAttempts):
400 @note This must be run outside of the UI lock
402 loggedIn, (username, password) = False, self._credentials
403 tmpServiceId = self.GV_BACKEND
404 for attemptCount in xrange(numOfAttempts):
407 with gtk_toolbox.gtk_lock():
408 credentials = self._credentialsDialog.request_credentials(
409 defaultCredentials = self._credentials
411 username, password = credentials
412 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
415 serviceId = tmpServiceId
416 self._credentials = username, password
417 logging.info("Logged into %r through user request" % self._phoneBackends[serviceId])
419 serviceId = self.NULL_BACKEND
421 return loggedIn, serviceId
423 def _select_action(self, action, number, message):
424 self.refresh_session()
425 if action == "select":
426 self._dialpads[self._selectedBackendId].set_number(number)
427 self._notebook.set_current_page(self.KEYPAD_TAB)
428 elif action == "dial":
429 self._on_dial_clicked(number)
430 elif action == "sms":
431 self._on_sms_clicked(number, message)
433 assert False, "Unknown action: %s" % action
435 def _change_loggedin_status(self, newStatus):
436 oldStatus = self._selectedBackendId
437 if oldStatus == newStatus:
440 self._dialpads[oldStatus].disable()
441 self._accountViews[oldStatus].disable()
442 self._recentViews[oldStatus].disable()
443 self._messagesViews[oldStatus].disable()
444 self._contactsViews[oldStatus].disable()
446 self._dialpads[newStatus].enable()
447 self._accountViews[newStatus].enable()
448 self._recentViews[newStatus].enable()
449 self._messagesViews[newStatus].enable()
450 self._contactsViews[newStatus].enable()
452 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
453 self._phoneBackends[self._selectedBackendId].set_sane_callback()
454 self._accountViews[self._selectedBackendId].update()
456 self._selectedBackendId = newStatus
458 def load_settings(self, config):
463 self._defaultBackendId = config.getint(constants.__pretty_app_name__, "active")
465 config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
466 for i in xrange(len(self._credentials))
469 base64.b64decode(blob)
472 self._credentials = tuple(creds)
474 if self._alarmHandler is not None:
475 self._alarmHandler.load_settings(config, "alarm")
476 except ConfigParser.NoOptionError, e:
478 "Settings file %s is missing section %s" % (
479 constants._user_settings_,
483 except ConfigParser.NoSectionError, e:
485 "Settings file %s is missing section %s" % (
486 constants._user_settings_,
491 for backendId, view in itertools.chain(
492 self._dialpads.iteritems(),
493 self._accountViews.iteritems(),
494 self._messagesViews.iteritems(),
495 self._recentViews.iteritems(),
496 self._contactsViews.iteritems(),
498 sectionName = "%s - %s" % (backendId, view.name())
500 view.load_settings(config, sectionName)
501 except ConfigParser.NoOptionError, e:
503 "Settings file %s is missing section %s" % (
504 constants._user_settings_,
508 except ConfigParser.NoSectionError, e:
510 "Settings file %s is missing section %s" % (
511 constants._user_settings_,
516 # @todo down here till this issue is fixed
518 previousOrientation = config.getint(constants.__pretty_app_name__, "orientation")
519 if previousOrientation == gtk.ORIENTATION_HORIZONTAL:
520 hildonize.window_to_landscape(self._window)
521 elif previousOrientation == gtk.ORIENTATION_VERTICAL:
522 hildonize.window_to_portrait(self._window)
523 except ConfigParser.NoOptionError, e:
525 "Settings file %s is missing section %s" % (
526 constants._user_settings_,
530 except ConfigParser.NoSectionError, e:
532 "Settings file %s is missing section %s" % (
533 constants._user_settings_,
538 def save_settings(self, config):
540 @note Thread Agnostic
542 config.add_section(constants.__pretty_app_name__)
543 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
544 config.set(constants.__pretty_app_name__, "orientation", str(int(gtk_toolbox.get_screen_orientation())))
545 for i, value in enumerate(self._credentials):
546 blob = base64.b64encode(value)
547 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
548 config.add_section("alarm")
549 if self._alarmHandler is not None:
550 self._alarmHandler.save_settings(config, "alarm")
552 for backendId, view in itertools.chain(
553 self._dialpads.iteritems(),
554 self._accountViews.iteritems(),
555 self._messagesViews.iteritems(),
556 self._recentViews.iteritems(),
557 self._contactsViews.iteritems(),
559 sectionName = "%s - %s" % (backendId, view.name())
560 config.add_section(sectionName)
561 view.save_settings(config, sectionName)
563 def _save_settings(self):
565 @note Thread Agnostic
567 config = ConfigParser.SafeConfigParser()
568 self.save_settings(config)
569 with open(constants._user_settings_, "wb") as configFile:
570 config.write(configFile)
572 def _refresh_active_tab(self):
573 pageIndex = self._notebook.get_current_page()
574 if pageIndex == self.CONTACTS_TAB:
575 self._contactsViews[self._selectedBackendId].update(force=True)
576 elif pageIndex == self.RECENT_TAB:
577 self._recentViews[self._selectedBackendId].update(force=True)
578 elif pageIndex == self.MESSAGES_TAB:
579 self._messagesViews[self._selectedBackendId].update(force=True)
581 if pageIndex in (self.RECENT_TAB, self.MESSAGES_TAB):
582 if self._ledHandler is not None:
583 self._ledHandler.off()
585 def _on_close(self, *args, **kwds):
587 if self._osso is not None:
591 self._save_settings()
595 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
597 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
598 For system_inactivity, we have no background tasks to pause
600 @note Hildon specific
604 for backendId in self.BACKENDS:
605 self._phoneBackends[backendId].clear_caches()
606 self._contactsViews[self._selectedBackendId].clear_caches()
609 if save_unsaved_data or shutdown:
610 self._save_settings()
612 self._errorDisplay.push_exception()
614 def _on_connection_change(self, connection, event, magicIdentifier):
616 @note Hildon specific
621 status = event.get_status()
622 error = event.get_error()
623 iap_id = event.get_iap_id()
624 bearer = event.get_bearer_type()
626 if status == conic.STATUS_CONNECTED:
628 self._spawn_attempt_login(2)
629 elif status == conic.STATUS_DISCONNECTED:
631 self._defaultBackendId = self._selectedBackendId
632 self._change_loggedin_status(self.NULL_BACKEND)
634 self._errorDisplay.push_exception()
636 def _on_window_state_change(self, widget, event, *args):
638 @note Hildon specific
641 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
642 self._isFullScreen = True
644 self._isFullScreen = False
646 self._errorDisplay.push_exception()
648 def _on_key_press(self, widget, event, *args):
650 @note Hildon specific
653 if event.keyval == gtk.keysyms.F6:
654 if self._isFullScreen:
655 self._window.unfullscreen()
657 self._window.fullscreen()
659 self._errorDisplay.push_exception()
661 def _on_clearcookies_clicked(self, *args):
663 self._phoneBackends[self._selectedBackendId].logout()
664 self._accountViews[self._selectedBackendId].clear()
665 self._recentViews[self._selectedBackendId].clear()
666 self._messagesViews[self._selectedBackendId].clear()
667 self._contactsViews[self._selectedBackendId].clear()
668 self._change_loggedin_status(self.NULL_BACKEND)
670 self._spawn_attempt_login(2, True)
672 self._errorDisplay.push_exception()
674 def _on_notebook_switch_page(self, notebook, page, pageIndex):
676 self._reset_tab_refresh()
678 didRecentUpdate = False
679 didMessagesUpdate = False
681 if pageIndex == self.RECENT_TAB:
682 didRecentUpdate = self._recentViews[self._selectedBackendId].update()
683 elif pageIndex == self.MESSAGES_TAB:
684 didMessagesUpdate = self._messagesViews[self._selectedBackendId].update()
685 elif pageIndex == self.CONTACTS_TAB:
686 self._contactsViews[self._selectedBackendId].update()
687 elif pageIndex == self.ACCOUNT_TAB:
688 self._accountViews[self._selectedBackendId].update()
690 if didRecentUpdate or didMessagesUpdate:
691 if self._ledHandler is not None:
692 self._ledHandler.off()
694 self._errorDisplay.push_exception()
696 def _set_tab_refresh(self, *args):
698 pageIndex = self._notebook.get_current_page()
699 child = self._notebook.get_nth_page(pageIndex)
700 self._notebook.get_tab_label(child).set_text("Refresh?")
702 self._errorDisplay.push_exception()
705 def _reset_tab_refresh(self, *args):
707 pageIndex = self._notebook.get_current_page()
708 child = self._notebook.get_nth_page(pageIndex)
709 self._notebook.get_tab_label(child).set_text(self._originalCurrentLabels[pageIndex])
711 self._errorDisplay.push_exception()
714 def _on_tab_refresh(self, *args):
716 self._refresh_active_tab()
717 self._reset_tab_refresh()
719 self._errorDisplay.push_exception()
722 def _on_sms_clicked(self, number, message):
724 assert number, "No number specified"
725 assert message, "Empty message"
727 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
730 self._errorDisplay.push_exception()
734 self._errorDisplay.push_message(
735 "Backend link with grandcentral is not working, please try again"
741 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
744 self._errorDisplay.push_exception()
747 self._dialpads[self._selectedBackendId].clear()
749 self._errorDisplay.push_exception()
751 def _on_dial_clicked(self, number):
753 assert number, "No number to call"
755 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
758 self._errorDisplay.push_exception()
762 self._errorDisplay.push_message(
763 "Backend link with grandcentral is not working, please try again"
769 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != "", "No callback number specified"
770 self._phoneBackends[self._selectedBackendId].dial(number)
773 self._errorDisplay.push_exception()
776 self._dialpads[self._selectedBackendId].clear()
778 self._errorDisplay.push_exception()
780 def _on_menu_refresh(self, *args):
782 self._refresh_active_tab()
784 self._errorDisplay.push_exception()
786 def _on_menu_rotate(self, *args):
788 orientation = gtk_toolbox.get_screen_orientation()
789 if orientation == gtk.ORIENTATION_HORIZONTAL:
790 hildonize.window_to_portrait(self._window)
791 elif orientation == gtk.ORIENTATION_VERTICAL:
792 hildonize.window_to_landscape(self._window)
794 self._errorDisplay.push_exception()
796 def _on_paste(self, *args):
798 contents = self._clipboard.wait_for_text()
799 if contents is not None:
800 self._dialpads[self._selectedBackendId].set_number(contents)
802 self._errorDisplay.push_exception()
804 def _on_about_activate(self, *args):
806 dlg = gtk.AboutDialog()
807 dlg.set_name(constants.__pretty_app_name__)
808 dlg.set_version("%s-%d" % (constants.__version__, constants.__build__))
809 dlg.set_copyright("Copyright 2008 - LGPL")
810 dlg.set_comments("Dialcentral is a touch screen enhanced interface to your GoogleVoice/Grandcentral account. This application is not affiliated with Google in any way")
811 dlg.set_website("http://gc-dialer.garage.maemo.org/")
812 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
816 self._errorDisplay.push_exception()
822 failureCount, testCount = doctest.testmod()
824 print "Tests Successful"
831 _lock_file = os.path.join(constants._data_path_, ".lock")
832 #with gtk_toolbox.flock(_lock_file, 0):
833 gtk.gdk.threads_init()
835 if hildonize.IS_HILDON_SUPPORTED:
836 gtk.set_application_name(constants.__pretty_app_name__)
837 handle = Dialcentral()
841 class DummyOptions(object):
847 if __name__ == "__main__":
848 logging.basicConfig(level=logging.DEBUG)
850 if len(sys.argv) > 1:
856 if optparse is not None:
857 parser = optparse.OptionParser()
858 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
859 (commandOptions, commandArgs) = parser.parse_args()
861 commandOptions = DummyOptions()
864 if commandOptions.test: