comparison src/sat.tac @ 331:0a8eb0461f31

core: main SAT class now moved in its own module core.sat_main
author Goffi <goffi@goffi.org>
date Mon, 23 May 2011 21:32:28 +0200
parents 608a4a2ba94e
children cf005701624b
comparison
equal deleted inserted replaced
330:608a4a2ba94e 331:0a8eb0461f31
17 17
18 You should have received a copy of the GNU General Public License 18 You should have received a copy of the GNU General Public License
19 along with this program. If not, see <http://www.gnu.org/licenses/>. 19 along with this program. If not, see <http://www.gnu.org/licenses/>.
20 """ 20 """
21 21
22 CONST = {
23 'client_name' : u'SàT (Salut à toi)',
24 'client_version' : u'0.1.1D', #Please add 'D' at the end for dev versions
25 'local_dir' : '~/.sat'
26 }
27
28 import gettext 22 import gettext
29 gettext.install('sat', "i18n", unicode=True) 23 gettext.install('sat', "i18n", unicode=True)
30 24
31 from twisted.application import internet, service 25 from twisted.application import service
32 from twisted.internet import glib2reactor, protocol, task, defer 26 from twisted.internet import glib2reactor
33 glib2reactor.install() 27 glib2reactor.install()
34 28
35 from twisted.words.protocols.jabber import jid, xmlstream 29 from sat.core.sat_main import SAT
36 from twisted.words.protocols.jabber import error as jab_error
37 from twisted.words.xish import domish
38
39 from twisted.internet import reactor
40 import pdb
41
42 from wokkel import client, disco, xmppim, generic, compat
43
44 from sat.bridge.DBus import DBusBridge
45 import logging
46 from logging import debug, info, error
47
48 import signal, sys
49 import os.path
50
51 from sat.core.xmpp import SatXMPPClient, SatMessageProtocol, SatRosterProtocol, SatPresenceProtocol, SatDiscoProtocol, SatFallbackHandler, RegisteringAuthenticator, SatVersionHandler
52 from sat.tools.memory import Memory
53 from sat.tools.xml_tools import tupleList2dataForm
54 from sat.tools.misc import TriggerManager
55 from glob import glob
56
57 try:
58 from twisted.words.protocols.xmlstream import XMPPHandler
59 except ImportError:
60 from wokkel.subprotocols import XMPPHandler
61
62
63 ### logging configuration FIXME: put this elsewhere ###
64 logging.basicConfig(level=logging.DEBUG,
65 format='%(message)s')
66 ###
67
68
69 sat_id = 0
70
71 def sat_next_id():
72 global sat_id
73 sat_id+=1
74 return "sat_id_"+str(sat_id)
75
76 class SAT(service.Service):
77
78 def get_next_id(self):
79 return sat_next_id()
80
81 def get_const(self, name):
82 """Return a constant"""
83 if not CONST.has_key(name):
84 error(_('Trying to access an undefined constant'))
85 raise Exception
86 return CONST[name]
87
88 def set_const(self, name, value):
89 """Save a constant"""
90 if CONST.has_key(name):
91 error(_('Trying to redefine a constant'))
92 raise Exception
93 CONST[name] = value
94
95 def __init__(self):
96 #TODO: standardize callback system
97
98 local_dir = os.path.expanduser(self.get_const('local_dir'))
99 if not os.path.exists(local_dir):
100 os.makedirs(local_dir)
101
102 self.__waiting_conf = {} #callback called when a confirmation is received
103 self.__progress_cb_map = {} #callback called when a progress is requested (key = progress id)
104 self.__general_cb_map = {} #callback called for general reasons (key = name)
105 self.__private_data = {} #used for internal callbacks (key = id)
106 self.trigger = TriggerManager() #trigger are user to change SàT behaviour
107 self.profiles = {}
108 self.plugins = {}
109 self.menus = {} #used to know which new menus are wanted by plugins
110
111 self.memory=Memory(self)
112
113 self.bridge=DBusBridge()
114 self.bridge.register("getVersion", lambda: self.get_const('client_version'))
115 self.bridge.register("getProfileName", self.memory.getProfileName)
116 self.bridge.register("getProfilesList", self.memory.getProfilesList)
117 self.bridge.register("createProfile", self.memory.createProfile)
118 self.bridge.register("deleteProfile", self.memory.deleteProfile)
119 self.bridge.register("registerNewAccount", self.registerNewAccount)
120 self.bridge.register("connect", self.connect)
121 self.bridge.register("disconnect", self.disconnect)
122 self.bridge.register("getContacts", self.memory.getContacts)
123 self.bridge.register("getPresenceStatus", self.memory.getPresenceStatus)
124 self.bridge.register("getWaitingSub", self.memory.getWaitingSub)
125 self.bridge.register("sendMessage", self.sendMessage)
126 self.bridge.register("setParam", self.setParam)
127 self.bridge.register("getParamA", self.memory.getParamA)
128 self.bridge.register("getParamsUI", self.memory.getParamsUI)
129 self.bridge.register("getParams", self.memory.getParams)
130 self.bridge.register("getParamsForCategory", self.memory.getParamsForCategory)
131 self.bridge.register("getParamsCategories", self.memory.getParamsCategories)
132 self.bridge.register("getHistory", self.memory.getHistory)
133 self.bridge.register("setPresence", self.setPresence)
134 self.bridge.register("subscription", self.subscription)
135 self.bridge.register("addContact", self.addContact)
136 self.bridge.register("delContact", self.delContact)
137 self.bridge.register("isConnected", self.isConnected)
138 self.bridge.register("launchAction", self.launchAction)
139 self.bridge.register("confirmationAnswer", self.confirmationAnswer)
140 self.bridge.register("getProgress", self.getProgress)
141 self.bridge.register("getMenus", self.getMenus)
142 self.bridge.register("getMenuHelp", self.getMenuHelp)
143 self.bridge.register("callMenu", self.callMenu)
144
145 self._import_plugins()
146
147
148 def _import_plugins(self):
149 """Import all plugins found in plugins directory"""
150 import sat.plugins
151 plugins_path = os.path.dirname(sat.plugins.__file__)
152 plug_lst = [os.path.splitext(plugin)[0] for plugin in map(os.path.basename,glob (os.path.join(plugins_path,"plugin*.py")))]
153 __plugins_to_import = {} #plugins will still have to import
154 for plug in plug_lst:
155 plugin_path = 'sat.plugins.'+plug
156 __import__(plugin_path)
157 mod = sys.modules[plugin_path]
158 plugin_info = mod.PLUGIN_INFO
159 __plugins_to_import[plugin_info['import_name']] = (plugin_path, mod, plugin_info)
160 while True:
161 self._import_plugins_from_dict(__plugins_to_import)
162 if not __plugins_to_import:
163 break
164
165 def _import_plugins_from_dict(self, plugins_to_import, import_name=None):
166 """Recursively import and their dependencies in the right order
167 @param plugins_to_import: dict where key=import_name and values= (plugin_path, module, plugin_info)"""
168 if self.plugins.has_key(import_name):
169 debug('Plugin [%s] already imported, passing' % import_name)
170 return
171 if not import_name:
172 import_name,(plugin_path, mod, plugin_info) = plugins_to_import.popitem()
173 else:
174 if not import_name in plugins_to_import:
175 raise ImportError(_('Dependency plugin not found: [%s]') % import_name)
176 plugin_path, mod, plugin_info = plugins_to_import.pop(import_name)
177 dependencies = plugin_info.setdefault("dependencies",[])
178 for dependency in dependencies:
179 if not self.plugins.has_key(dependency):
180 debug('Recursively import dependency of [%s]: [%s]' % (import_name, dependency))
181 self._import_plugins_from_dict(plugins_to_import, dependency)
182 info (_("importing plugin: %s"), plugin_info['name'])
183 self.plugins[import_name] = getattr(mod, plugin_info['main'])(self)
184 if plugin_info.has_key('handler') and plugin_info['handler'] == 'yes':
185 self.plugins[import_name].is_handler = True
186 else:
187 self.plugins[import_name].is_handler = False
188 #TODO: test xmppclient presence and register handler parent
189
190 def connect(self, profile_key = '@DEFAULT@'):
191 """Connect to jabber server"""
192
193 profile = self.memory.getProfileName(profile_key)
194 if not profile:
195 error (_('Trying to connect a non-exsitant profile'))
196 return
197
198 if (self.isConnected(profile)):
199 info(_("already connected !"))
200 return
201 current = self.profiles[profile] = SatXMPPClient(self, profile,
202 jid.JID(self.memory.getParamA("JabberID", "Connection", profile_key = profile), profile),
203 self.memory.getParamA("Password", "Connection", profile_key = profile),
204 self.memory.getParamA("Server", "Connection", profile_key = profile), 5222)
205
206 current.messageProt = SatMessageProtocol(self)
207 current.messageProt.setHandlerParent(current)
208
209 current.roster = SatRosterProtocol(self)
210 current.roster.setHandlerParent(current)
211
212 current.presence = SatPresenceProtocol(self)
213 current.presence.setHandlerParent(current)
214
215 current.fallBack = SatFallbackHandler(self)
216 current.fallBack.setHandlerParent(current)
217
218 current.versionHandler = SatVersionHandler(self.get_const('client_name'),
219 self.get_const('client_version'))
220 current.versionHandler.setHandlerParent(current)
221
222 debug (_("setting plugins parents"))
223
224 for plugin in self.plugins.iteritems():
225 if plugin[1].is_handler:
226 plugin[1].getHandler(profile).setHandlerParent(current)
227
228 current.startService()
229
230 def disconnect(self, profile_key='@DEFAULT@'):
231 """disconnect from jabber server"""
232 if (not self.isConnected(profile_key)):
233 info(_("not connected !"))
234 return
235 profile = self.memory.getProfileName(profile_key)
236 info(_("Disconnecting..."))
237 self.profiles[profile].stopService()
238
239 def startService(self):
240 info("Salut à toi ô mon frère !")
241 #TODO: manage autoconnect
242 #self.connect()
243
244 def stopService(self):
245 self.memory.save()
246 info("Salut aussi à Rantanplan")
247
248 def run(self):
249 debug(_("running app"))
250 reactor.run()
251
252 def stop(self):
253 debug(_("stopping app"))
254 reactor.stop()
255
256 ## Misc methods ##
257
258 def getJidNStream(self, profile_key):
259 """Convenient method to get jid and stream from profile key
260 @return: tuple (jid, xmlstream) from profile, can be None"""
261 profile = self.memory.getProfileName(profile_key)
262 if not profile or not self.profiles[profile].isConnected():
263 return (None, None)
264 return (self.profiles[profile].jid, self.profiles[profile].xmlstream)
265
266 def getClient(self, profile_key):
267 """Convenient method to get client from profile key
268 @return: client or None if it doesn't exist"""
269 profile = self.memory.getProfileName(profile_key)
270 if not profile:
271 return None
272 return self.profiles[profile]
273
274 def registerNewAccount(self, login, password, server, port = 5222, id = None):
275 """Connect to a server and create a new account using in-band registration"""
276
277 next_id = id or sat_next_id() #the id is used to send server's answer
278 serverRegistrer = xmlstream.XmlStreamFactory(RegisteringAuthenticator(self, server, login, password, next_id))
279 connector = reactor.connectTCP(server, port, serverRegistrer)
280 serverRegistrer.clientConnectionLost = lambda conn, reason: connector.disconnect()
281
282 return next_id
283
284 def registerNewAccountCB(self, id, data, profile):
285 user = jid.parse(self.memory.getParamA("JabberID", "Connection", profile_key=profile))[0]
286 password = self.memory.getParamA("Password", "Connection", profile_key=profile)
287 server = self.memory.getParamA("Server", "Connection", profile_key=profile)
288
289 if not user or not password or not server:
290 info (_('No user or server given'))
291 #TODO: a proper error message must be sent to frontend
292 self.actionResult(id, "ERROR", {'message':_("No user, password or server given, can't register new account.")})
293 return
294
295 confirm_id = sat_next_id()
296 self.__private_data[confirm_id]=(id,profile)
297
298 self.askConfirmation(confirm_id, "YES/NO",
299 {"message":_("Are you sure to register new account [%(user)s] to server %(server)s ?") % {'user':user, 'server':server, 'profile':profile}},
300 self.regisConfirmCB)
301 print ("===============+++++++++++ REGISTER NEW ACCOUNT++++++++++++++============")
302 print "id=",id
303 print "data=",data
304
305 def regisConfirmCB(self, id, accepted, data):
306 print _("register Confirmation CB ! (%s)") % str(accepted)
307 action_id,profile = self.__private_data[id]
308 del self.__private_data[id]
309 if accepted:
310 user = jid.parse(self.memory.getParamA("JabberID", "Connection", profile_key=profile))[0]
311 password = self.memory.getParamA("Password", "Connection", profile_key=profile)
312 server = self.memory.getParamA("Server", "Connection", profile_key=profile)
313 self.registerNewAccount(user, password, server, id=action_id)
314 else:
315 self.actionResult(action_id, "SUPPRESS", {})
316
317 def submitForm(self, action, target, fields, profile_key='@DEFAULT@'):
318 """submit a form
319 @param target: target jid where we are submitting
320 @param fields: list of tuples (name, value)
321 @return: tuple: (id, deferred)
322 """
323
324 profile = self.memory.getProfileName(profile_key)
325 assert(profile)
326 to_jid = jid.JID(target)
327
328 iq = compat.IQ(self.profiles[profile].xmlstream, 'set')
329 iq["to"] = target
330 iq["from"] = self.profiles[profile].jid.full()
331 query = iq.addElement(('jabber:iq:register', 'query'))
332 if action=='SUBMIT':
333 form = tupleList2dataForm(fields)
334 query.addChild(form.toElement())
335 elif action=='CANCEL':
336 query.addElement('remove')
337 else:
338 error (_("FIXME FIXME FIXME: Unmanaged action (%s) in submitForm") % action)
339 raise NotImplementedError
340
341 deferred = iq.send(target)
342 return (iq['id'], deferred)
343
344 ## Client management ##
345
346 def setParam(self, name, value, category, profile_key='@DEFAULT@'):
347 """set wanted paramater and notice observers"""
348 info (_("setting param: %(name)s=%(value)s in category %(category)s") % {'name':name, 'value':value, 'category':category})
349 self.memory.setParam(name, value, category, profile_key)
350
351 def isConnected(self, profile_key='@DEFAULT@'):
352 """Return connection status of profile
353 @param profile_key: key_word or profile name to determine profile name
354 @return True if connected
355 """
356 profile = self.memory.getProfileName(profile_key)
357 if not profile:
358 error (_('asking connection status for a non-existant profile'))
359 return
360 if not self.profiles.has_key(profile):
361 return False
362 return self.profiles[profile].isConnected()
363
364 def launchAction(self, type, data, profile_key='@DEFAULT@'):
365 """Launch a specific action asked by client
366 @param type: action type (button)
367 @param data: needed data to launch the action
368
369 @return: action id for result, or empty string in case or error
370 """
371 profile = self.memory.getProfileName(profile_key)
372 if not profile:
373 error (_('trying to launch action with a non-existant profile'))
374 raise Exception #TODO: raise a proper exception
375 if type=="button":
376 try:
377 cb_name = data['callback_id']
378 except KeyError:
379 error (_("Incomplete data"))
380 return ""
381 id = sat_next_id()
382 self.callGeneralCB(cb_name, id, data, profile = profile)
383 return id
384 else:
385 error (_("Unknown action type"))
386 return ""
387
388
389 ## jabber methods ##
390
391 def sendMessage(self, to, msg, subject=None, type='chat', profile_key='@DEFAULT@'):
392 #FIXME: check validity of recipient
393 profile = self.memory.getProfileName(profile_key)
394 assert(profile)
395 current_jid = self.profiles[profile].jid
396 debug(_("Sending jabber message to %s..."), to)
397 message = domish.Element(('jabber:client','message'))
398 message["to"] = jid.JID(to).full()
399 message["from"] = current_jid.full()
400 message["type"] = type
401 if subject:
402 message.addElement("subject", "jabber:client", subject)
403 message.addElement("body", "jabber:client", msg)
404 self.profiles[profile].xmlstream.send(message)
405 self.memory.addToHistory(current_jid, current_jid, jid.JID(to), message["type"], unicode(msg))
406 if type!="groupchat":
407 self.bridge.newMessage(message['from'], unicode(msg), mess_type=type, to_jid=message['to'], profile=profile) #We send back the message, so all clients are aware of it
408
409
410 def setPresence(self, to="", show="", priority = 0, statuses={}, profile_key='@DEFAULT@'):
411 """Send our presence information"""
412 profile = self.memory.getProfileName(profile_key)
413 assert(profile)
414 to_jid = jid.JID(to) if to else None
415 self.profiles[profile].presence.available(to_jid, show, statuses, priority)
416 #XXX: FIXME: temporary fix to work around openfire 3.7.0 bug (presence is not broadcasted to generating resource)
417 if statuses.has_key(''):
418 statuses['default'] = statuses['']
419 del statuses['']
420 self.bridge.presenceUpdate(self.profiles[profile].jid.full(), show,
421 int(priority), statuses, profile)
422
423
424 def subscription(self, subs_type, raw_jid, profile_key='@DEFAULT@'):
425 """Called to manage subscription
426 @param subs_type: subsciption type (cf RFC 3921)
427 @param raw_jid: unicode entity's jid
428 @param profile_key: profile"""
429 profile = self.memory.getProfileName(profile_key)
430 assert(profile)
431 to_jid = jid.JID(raw_jid)
432 debug (_('subsciption request [%(subs_type)s] for %(jid)s') % {'subs_type':subs_type, 'jid':to_jid.full()})
433 if subs_type=="subscribe":
434 self.profiles[profile].presence.subscribe(to_jid)
435 elif subs_type=="subscribed":
436 self.profiles[profile].presence.subscribed(to_jid)
437 contact = self.memory.getContact(to_jid)
438 if not contact or not bool(contact['to']): #we automatically subscribe to 'to' presence
439 debug(_('sending automatic "to" subscription request'))
440 self.subscription('subscribe', to_jid.userhost())
441 elif subs_type=="unsubscribe":
442 self.profiles[profile].presence.unsubscribe(to_jid)
443 elif subs_type=="unsubscribed":
444 self.profiles[profile].presence.unsubscribed(to_jid)
445
446
447 def addContact(self, to, profile_key='@DEFAULT@'):
448 """Add a contact in roster list"""
449 profile = self.memory.getProfileName(profile_key)
450 assert(profile)
451 to_jid=jid.JID(to)
452 #self.profiles[profile].roster.addItem(to_jid) XXX: disabled (cf http://wokkel.ik.nu/ticket/56))
453 self.profiles[profile].presence.subscribe(to_jid)
454
455 def delContact(self, to, profile_key='@DEFAULT@'):
456 """Remove contact from roster list"""
457 profile = self.memory.getProfileName(profile_key)
458 assert(profile)
459 to_jid=jid.JID(to)
460 self.profiles[profile].roster.removeItem(to_jid)
461 self.profiles[profile].presence.unsubscribe(to_jid)
462 self.bridge.contactDeleted(to, profile)
463
464
465 ## callbacks ##
466
467 def serverDisco(self, disco, profile):
468 """xep-0030 Discovery Protocol."""
469 for feature in disco.features:
470 debug (_("Feature found: %s"),feature)
471 self.memory.addServerFeature(feature, profile)
472 for cat, type in disco.identities:
473 debug (_("Identity found: [%(category)s/%(type)s] %(identity)s") % {'category':cat, 'type':type, 'identity':disco.identities[(cat,type)]})
474
475 def serverDiscoItems(self, disco_result, disco_client, profile, initialized):
476 """xep-0030 Discovery Protocol.
477 @param disco_result: result of the disco item querry
478 @param disco_client: SatDiscoProtocol instance
479 @param profile: profile of the user
480 @param initialized: deferred which must be chained when everything is done"""
481 def _check_entity_cb(result, entity, profile):
482 for category, type in result.identities:
483 debug (_('Identity added: (%(category)s,%(type)s) ==> %(entity)s [%(profile)s]') % {
484 'category':category, 'type':type, 'entity':entity, 'profile':profile})
485 self.memory.addServerIdentity(category, type, entity, profile)
486
487 defer_list = []
488 for item in disco_result._items:
489 defer_list.append(disco_client.requestInfo(item.entity).addCallback(_check_entity_cb, item.entity, profile))
490 defer.DeferredList(defer_list).chainDeferred(initialized)
491
492
493 ## Generic HMI ##
494
495 def actionResult(self, id, type, data):
496 """Send the result of an action
497 @param id: same id used with action
498 @param type: result type ("PARAM", "SUCCESS", "ERROR", "XMLUI")
499 @param data: dictionary
500 """
501 self.bridge.actionResult(type, id, data)
502
503 def actionResultExt(self, id, type, data):
504 """Send the result of an action, extended version
505 @param id: same id used with action
506 @param type: result type /!\ only "DICT_DICT" for this method
507 @param data: dictionary of dictionaries
508 """
509 if type != "DICT_DICT":
510 error(_("type for actionResultExt must be DICT_DICT, fixing it"))
511 type = "DICT_DICT"
512 self.bridge.actionResultExt(type, id, data)
513
514
515
516 def askConfirmation(self, id, type, data, cb):
517 """Add a confirmation callback
518 @param id: id used to get answer
519 @param type: confirmation type ("YES/NO", "FILE_TRANSFERT")
520 @param data: data (depend of confirmation type)
521 @param cb: callback called with the answer
522 """
523 if self.__waiting_conf.has_key(id):
524 error (_("Attempt to register two callbacks for the same confirmation"))
525 else:
526 self.__waiting_conf[id] = cb
527 self.bridge.askConfirmation(type, id, data)
528
529
530 def confirmationAnswer(self, id, accepted, data):
531 """Called by frontends to answer confirmation requests"""
532 debug (_("Received confirmation answer for id [%(id)s]: %(success)s") % {'id': id, 'success':_("accepted") if accepted else _("refused")})
533 if not self.__waiting_conf.has_key(id):
534 error (_("Received an unknown confirmation"))
535 else:
536 cb = self.__waiting_conf[id]
537 del self.__waiting_conf[id]
538 cb(id, accepted, data)
539
540 def registerProgressCB(self, id, CB):
541 """Register a callback called when progress is requested for id"""
542 self.__progress_cb_map[id] = CB
543
544 def removeProgressCB(self, id):
545 """Remove a progress callback"""
546 if not self.__progress_cb_map.has_key(id):
547 error (_("Trying to remove an unknow progress callback"))
548 else:
549 del self.__progress_cb_map[id]
550
551 def getProgress(self, id):
552 """Return a dict with progress information
553 data['position'] : current possition
554 data['size'] : end_position
555 """
556 data = {}
557 try:
558 self.__progress_cb_map[id](data)
559 except KeyError:
560 pass
561 #debug("Requested progress for unknown id")
562 return data
563
564 def registerGeneralCB(self, name, CB):
565 """Register a callback called for general reason"""
566 self.__general_cb_map[name] = CB
567
568 def removeGeneralCB(self, name):
569 """Remove a general callback"""
570 if not self.__general_cb_map.has_key(name):
571 error (_("Trying to remove an unknow general callback"))
572 else:
573 del self.__general_cb_map[name]
574
575 def callGeneralCB(self, name, *args, **kwargs):
576 """Call general function back"""
577 try:
578 return self.__general_cb_map[name](*args, **kwargs)
579 except KeyError:
580 error(_("Trying to call unknown function (%s)") % name)
581 return None
582
583 #Menus management
584
585 def importMenu(self, category, name, callback, help_string = "", type = "NORMAL"):
586 """register a new menu for frontends
587 @param category: category of the menu
588 @param name: menu item entry
589 @param callback: method to be called when menuitem is selected"""
590 if self.menus.has_key((category,name)):
591 error ("Want to register a menu which already existe")
592 return
593 self.menus[(category,name,type)] = {'callback':callback, 'help_string':help_string, 'type':type}
594
595 def getMenus(self):
596 """Return all menus registered"""
597 return self.menus.keys()
598
599 def getMenuHelp(self, category, name, type="NORMAL"):
600 """return the help string of the menu"""
601 try:
602 return self.menus[(category,name,type)]['help_string']
603 except KeyError:
604 error (_("Trying to access an unknown menu"))
605 return ""
606
607 def callMenu(self, category, name, type="NORMAL", profile_key='@DEFAULT@'):
608 """return the id of the action"""
609 profile = self.memory.getProfileName(profile_key)
610 if not profile_key:
611 error (_('Non-exsitant profile'))
612 return ""
613 if self.menus.has_key((category,name,type)):
614 id = self.get_next_id()
615 self.menus[(category,name,type)]['callback'](id, profile)
616 return id
617 else:
618 error (_("Trying to access an unknown menu (%(category)s/%(name)s/%(type)s)")%{'category':category, 'name':name,'type':type})
619 return ""
620
621
622 30
623 application = service.Application('SàT') 31 application = service.Application('SàT')
624 service = SAT() 32 service = SAT()
625 service.setServiceParent(application) 33 service.setServiceParent(application)