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