Coverage for C:\leo.repo\leo-editor\leo\core\leoPlugins.py: 22%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#@+leo-ver=5-thin
2#@+node:ekr.20031218072017.3439: * @file leoPlugins.py
3"""Classes relating to Leo's plugin architecture."""
4import sys
5from typing import List
6from leo.core import leoGlobals as g
7# Define modules that may be enabled by default
8# but that mignt not load because imports may fail.
9optional_modules = [
10 'leo.plugins.livecode',
11 'leo.plugins.cursesGui2',
12]
13#@+others
14#@+node:ekr.20100908125007.6041: ** Top-level functions (leoPlugins.py)
15def init():
16 """Init g.app.pluginsController."""
17 g.app.pluginsController = LeoPluginsController()
19def registerHandler(tags, fn):
20 """A wrapper so plugins can still call leoPlugins.registerHandler."""
21 return g.app.pluginsController.registerHandler(tags, fn)
22#@+node:ville.20090222141717.2: ** TryNext (Exception)
23class TryNext(Exception):
24 """Try next hook exception.
26 Raise this in your hook function to indicate that the next hook handler
27 should be used to handle the operation. If you pass arguments to the
28 constructor those arguments will be used by the next hook instead of the
29 original ones.
30 """
32 def __init__(self, *args, **kwargs):
33 super().__init__()
34 self.args = args
35 self.kwargs = kwargs
36#@+node:ekr.20100908125007.6033: ** class CommandChainDispatcher
37class CommandChainDispatcher:
38 """ Dispatch calls to a chain of commands until some func can handle it
40 Usage: instantiate, execute "add" to add commands (with optional
41 priority), execute normally via f() calling mechanism.
43 """
45 def __init__(self, commands=None):
46 if commands is None:
47 self.chain = []
48 else:
49 self.chain = commands
51 def __call__(self, *args, **kw):
52 """ Command chain is called just like normal func.
54 This will call all funcs in chain with the same args as were given to this
55 function, and return the result of first func that didn't raise
56 TryNext """
57 for prio, cmd in self.chain:
58 #print "prio",prio,"cmd",cmd #dbg
59 try:
60 ret = cmd(*args, **kw)
61 return ret
62 except TryNext as exc:
63 if exc.args or exc.kwargs:
64 args = exc.args
65 kw = exc.kwargs
66 # if no function will accept it, raise TryNext up to the caller
67 raise TryNext
69 def __str__(self):
70 return str(self.chain)
72 def add(self, func, priority=0):
73 """ Add a func to the cmd chain with given priority """
74 self.chain.append((priority, func),)
75 self.chain.sort(key=lambda z: z[0])
77 def __iter__(self):
78 """ Return all objects in chain.
80 Handy if the objects are not callable.
81 """
82 return iter(self.chain)
83#@+node:ekr.20100908125007.6009: ** class BaseLeoPlugin
84class BaseLeoPlugin:
85 #@+<<docstring>>
86 #@+node:ekr.20100908125007.6010: *3* <<docstring>>
87 """A Convenience class to simplify plugin authoring
89 .. contents::
91 Usage
92 =====
94 Initialization
95 --------------
97 - import the base class::
99 from leoPlugins from leo.core import leoBasePlugin
101 - create a class which inherits from leoBasePlugin::
103 class myPlugin(leoBasePlugin):
105 - in the __init__ method of the class, call the parent constructor::
107 def __init__(self, tag, keywords):
108 super().__init__(tag, keywords)
110 - put the actual plugin code into a method; for this example, the work
111 is done by myPlugin.handler()
113 - put the class in a file which lives in the <LeoDir>/plugins directory
114 for this example it is named myPlugin.py
116 - add code to register the plugin::
118 leoPlugins.registerHandler("after-create-leo-frame", Hello)
120 Configuration
121 -------------
123 BaseLeoPlugins has 3 *methods* for setting commands
125 - setCommand::
127 def setCommand(self, commandName, handler,
128 shortcut = None, pane = 'all', verbose = True):
130 - setMenuItem::
132 def setMenuItem(self, menu, commandName = None, handler = None):
134 - setButton::
136 def setButton(self, buttonText = None, commandName = None, color = None):
138 *variables*
140 :commandName: the string typed into minibuffer to execute the ``handler``
142 :handler: the method in the class which actually does the work
144 :shortcut: the key combination to activate the command
146 :menu: a string designating on of the menus ('File', Edit', 'Outline', ...)
148 :buttonText: the text to put on the button if one is being created.
150 Example
151 =======
153 Contents of file ``<LeoDir>/plugins/hello.py``::
155 class Hello(BaseLeoPlugin):
156 def __init__(self, tag, keywords):
158 # call parent __init__
159 super().__init__(tag, keywords)
161 # if the plugin object defines only one command,
162 # just give it a name. You can then create a button and menu entry
163 self.setCommand('Hello', self.hello)
164 self.setButton()
165 self.setMenuItem('Cmds')
167 # create a command with a shortcut
168 self.setCommand('Hola', self.hola, 'Alt-Ctrl-H')
170 # create a button using different text than commandName
171 self.setButton('Hello in Spanish')
173 # create a menu item with default text
174 self.setMenuItem('Cmds')
176 # define a command using setMenuItem
177 self.setMenuItem('Cmds', 'Ciao baby', self.ciao)
179 def hello(self, event):
180 g.pr("hello from node %s" % self.c.p.h)
182 def hola(self, event):
183 g.pr("hola from node %s" % self.c.p.h)
185 def ciao(self, event):
186 g.pr("ciao baby (%s)" % self.c.p.h)
188 leoPlugins.registerHandler("after-create-leo-frame", Hello)
190 """
191 #@-<<docstring>>
192 #@+others
193 #@+node:ekr.20100908125007.6012: *3* __init__ (BaseLeoPlugin)
194 def __init__(self, tag, keywords):
195 """Set self.c to be the ``commander`` of the active node
196 """
197 self.c = keywords['c']
198 self.commandNames = []
199 #@+node:ekr.20100908125007.6013: *3* setCommand
200 def setCommand(self, commandName, handler,
201 shortcut='', pane='all', verbose=True):
202 """Associate a command name with handler code,
203 optionally defining a keystroke shortcut
204 """
205 self.commandNames.append(commandName)
206 self.commandName = commandName
207 self.shortcut = shortcut
208 self.handler = handler
209 self.c.k.registerCommand(commandName, handler,
210 pane=pane, shortcut=shortcut, verbose=verbose)
211 #@+node:ekr.20100908125007.6014: *3* setMenuItem
212 def setMenuItem(self, menu, commandName=None, handler=None):
213 """Create a menu item in 'menu' using text 'commandName' calling handler 'handler'
214 if commandName and handler are none, use the most recently defined values
215 """
216 # setMenuItem can create a command, or use a previously defined one.
217 if commandName is None:
218 commandName = self.commandName
219 # make sure commandName is in the list of commandNames
220 else:
221 if commandName not in self.commandNames:
222 self.commandNames.append(commandName)
223 if handler is None:
224 handler = self.handler
225 table = ((commandName, None, handler),)
226 self.c.frame.menu.createMenuItemsFromTable(menu, table)
227 #@+node:ekr.20100908125007.6015: *3* setButton
228 def setButton(self, buttonText=None, commandName=None, color=None):
229 """Associate an existing command with a 'button'
230 """
231 if buttonText is None:
232 buttonText = self.commandName
233 if commandName is None:
234 commandName = self.commandName
235 else:
236 if commandName not in self.commandNames:
237 raise NameError(f"setButton error, {commandName} is not a commandName")
238 if color is None:
239 color = 'grey'
240 script = f"c.k.simulateCommand('{self.commandName}')"
241 g.app.gui.makeScriptButton(
242 self.c,
243 args=None,
244 script=script,
245 buttonText=buttonText, bg=color)
246 #@-others
247#@+node:ekr.20100908125007.6007: ** class LeoPluginsController
248class LeoPluginsController:
249 """The global plugins controller, g.app.pluginsController"""
250 #@+others
251 #@+node:ekr.20100909065501.5954: *3* plugins.Birth
252 #@+node:ekr.20100908125007.6034: *4* plugins.ctor & reloadSettings
253 def __init__(self):
255 # Keys are tags, values are lists of bunches.
256 self.handlers = {}
257 # Keys are regularized module names, values are the names of .leo files
258 # containing @enabled-plugins nodes that caused the plugin to be loaded
259 self.loadedModulesFilesDict = {}
260 # Keys are regularized module names, values are modules.
261 self.loadedModules = {}
262 # The stack of module names. The top is the module being loaded.
263 self.loadingModuleNameStack = []
264 self.signonModule = None # A hack for plugin_signon.
265 # Settings. Set these here in case finishCreate is never called.
266 self.warn_on_failure = True
267 g.act_on_node = CommandChainDispatcher()
268 g.visit_tree_item = CommandChainDispatcher()
269 g.tree_popup_handlers = []
270 #@+node:ekr.20100909065501.5974: *4* plugins.finishCreate & reloadSettings
271 def finishCreate(self):
272 self.reloadSettings()
274 def reloadSettings(self):
275 self.warn_on_failure = g.app.config.getBool(
276 'warn_when_plugins_fail_to_load', default=True)
277 #@+node:ekr.20100909065501.5952: *3* plugins.Event handlers
278 #@+node:ekr.20161029060545.1: *4* plugins.on_idle
279 def on_idle(self):
280 """Call all idle-time hooks."""
281 if g.app.idle_time_hooks_enabled:
282 for frame in g.app.windowList:
283 c = frame.c
284 # Do NOT compute c.currentPosition.
285 # This would be a MAJOR leak of positions.
286 g.doHook("idle", c=c)
287 #@+node:ekr.20100908125007.6017: *4* plugins.doHandlersForTag & helper
288 def doHandlersForTag(self, tag, keywords):
289 """
290 Execute all handlers for a given tag, in alphabetical order.
291 The caller, doHook, catches all exceptions.
292 """
293 if g.app.killed:
294 return None
295 #
296 # Execute hooks in some random order.
297 # Return if one of them returns a non-None result.
298 for bunch in self.handlers.get(tag, []):
299 val = self.callTagHandler(bunch, tag, keywords)
300 if val is not None:
301 return val
302 if 'all' in self.handlers:
303 bunches = self.handlers.get('all')
304 for bunch in bunches:
305 self.callTagHandler(bunch, tag, keywords)
306 return None
307 #@+node:ekr.20100908125007.6016: *5* plugins.callTagHandler
308 def callTagHandler(self, bunch, tag, keywords):
309 """Call the event handler."""
310 handler, moduleName = bunch.fn, bunch.moduleName
311 # Make sure the new commander exists.
312 for key in ('c', 'new_c'):
313 c = keywords.get(key)
314 if c:
315 # Make sure c exists and has a frame.
316 if not c.exists or not hasattr(c, 'frame'):
317 # g.pr('skipping tag %s: c does not exist or does not have a frame.' % tag)
318 return None
319 # Calls to registerHandler from inside the handler belong to moduleName.
320 self.loadingModuleNameStack.append(moduleName)
321 try:
322 result = handler(tag, keywords)
323 except Exception:
324 g.es(f"hook failed: {tag}, {handler}, {moduleName}")
325 g.es_exception()
326 result = None
327 self.loadingModuleNameStack.pop()
328 return result
329 #@+node:ekr.20100908125007.6018: *4* plugins.doPlugins (g.app.hookFunction)
330 def doPlugins(self, tag, keywords):
331 """The default g.app.hookFunction."""
332 if g.app.killed:
333 return None
334 if tag in ('start1', 'open0'):
335 self.loadHandlers(tag, keywords)
336 return self.doHandlersForTag(tag, keywords)
337 #@+node:ekr.20100909065501.5950: *3* plugins.Information
338 #@+node:ekr.20100908125007.6019: *4* plugins.getHandlersForTag
339 def getHandlersForTag(self, tags):
340 if isinstance(tags, (list, tuple)):
341 result = []
342 for tag in tags:
343 aList = self.getHandlersForOneTag(tag)
344 result.extend(aList)
345 return result
346 return self.getHandlersForOneTag(tags)
348 def getHandlersForOneTag(self, tag):
349 return self.handlers.get(tag, [])
350 #@+node:ekr.20100910075900.10204: *4* plugins.getLoadedPlugins
351 def getLoadedPlugins(self):
352 return list(self.loadedModules.keys())
353 #@+node:ekr.20100908125007.6020: *4* plugins.getPluginModule
354 def getPluginModule(self, moduleName):
355 return self.loadedModules.get(moduleName)
356 #@+node:ekr.20100908125007.6021: *4* plugins.isLoaded
357 def isLoaded(self, fn):
358 return self.regularizeName(fn) in self.loadedModules
359 #@+node:ekr.20100908125007.6025: *4* plugins.printHandlers
360 def printHandlers(self, c):
361 """Print the handlers for each plugin."""
362 tabName = 'Plugins'
363 c.frame.log.selectTab(tabName)
364 g.es_print('all plugin handlers...\n', tabName=tabName)
365 data = []
366 # keys are module names: values are lists of tags.
367 modules_d: dict[str, List[str]] = {}
368 for tag in self.handlers:
369 bunches = self.handlers.get(tag)
370 for bunch in bunches:
371 fn = bunch.fn
372 name = bunch.moduleName
373 tags = modules_d.get(name, [])
374 tags.append(tag)
375 key = f"{name}.{fn.__name__}"
376 modules_d[key] = tags
377 n = 4
378 for module in sorted(modules_d):
379 tags = modules_d.get(module)
380 for tag in tags:
381 n = max(n, len(tag))
382 data.append((tag, module),)
383 lines = sorted(list(set(
384 ["%*s %s\n" % (-n, s1, s2) for (s1, s2) in data])))
385 g.es_print('', ''.join(lines), tabName=tabName)
386 #@+node:ekr.20100908125007.6026: *4* plugins.printPlugins
387 def printPlugins(self, c):
388 """Print all enabled plugins."""
389 tabName = 'Plugins'
390 c.frame.log.selectTab(tabName)
391 data = []
392 data.append('enabled plugins...\n')
393 for z in sorted(self.loadedModules):
394 data.append(z)
395 lines = [f"{z}\n" for z in data]
396 g.es('', ''.join(lines), tabName=tabName)
397 #@+node:ekr.20100908125007.6027: *4* plugins.printPluginsInfo
398 def printPluginsInfo(self, c):
399 """
400 Print the file name responsible for loading a plugin.
402 This is the first .leo file containing an @enabled-plugins node
403 that enables the plugin.
404 """
405 d = self.loadedModulesFilesDict
406 tabName = 'Plugins'
407 c.frame.log.selectTab(tabName)
408 data = []
409 n = 4
410 for moduleName in d:
411 fileName = d.get(moduleName)
412 n = max(n, len(moduleName))
413 data.append((moduleName, fileName),)
414 lines = ["%*s %s\n" % (-n, s1, s2) for (s1, s2) in data]
415 g.es('', ''.join(lines), tabName=tabName)
416 #@+node:ekr.20100909065501.5949: *4* plugins.regularizeName
417 def regularizeName(self, moduleOrFileName):
418 """
419 Return the module name used as a key to this modules dictionaries.
421 We *must* allow .py suffixes, for compatibility with @enabled-plugins nodes.
422 """
423 if not moduleOrFileName.endswith('.py'):
424 # A module name. Return it unchanged.
425 return moduleOrFileName
426 #
427 # 1880: The legacy code implictly assumed that os.path.dirname(fn) was empty!
428 # The new code explicitly ignores any directories in the path.
429 fn = g.os_path_basename(moduleOrFileName)
430 return "leo.plugins." + fn[:-3]
431 #@+node:ekr.20100909065501.5953: *3* plugins.Load & unload
432 #@+node:ekr.20100908125007.6022: *4* plugins.loadHandlers
433 def loadHandlers(self, tag, keys):
434 """
435 Load all enabled plugins.
437 Using a module name (without the trailing .py) allows a plugin to
438 be loaded from outside the leo/plugins directory.
439 """
441 def pr(*args, **keys):
442 if not g.unitTesting:
443 g.es_print(*args, **keys)
445 s = g.app.config.getEnabledPlugins()
446 if not s:
447 return
448 if tag == 'open0' and not g.app.silentMode and not g.app.batchMode:
449 if 0:
450 s2 = f"@enabled-plugins found in {g.app.config.enabledPluginsFileName}"
451 g.blue(s2)
452 for plugin in s.splitlines():
453 if plugin.strip() and not plugin.lstrip().startswith('#'):
454 self.loadOnePlugin(plugin.strip(), tag=tag)
455 #@+node:ekr.20100908125007.6024: *4* plugins.loadOnePlugin & helper functions
456 def loadOnePlugin(self, moduleOrFileName, tag='open0', verbose=False):
457 """
458 Load one plugin from a file name or module.
459 Use extensive tracing if --trace-plugins is in effect.
461 Using a module name allows plugins to be loaded from outside the leo/plugins directory.
462 """
463 global optional_modules
464 trace = 'plugins' in g.app.debug
466 def report(message):
467 if trace and not g.unitTesting:
468 g.es_print(f"loadOnePlugin: {message}")
470 # Define local helper functions.
471 #@+others
472 #@+node:ekr.20180528160855.1: *5* function:callInitFunction
473 def callInitFunction(result):
474 """True to call the top-level init function."""
475 try:
476 # Indicate success only if init_result is True.
477 # Careful: this may throw an exception.
478 init_result = result.init()
479 if init_result not in (True, False):
480 report(f"{moduleName}.init() did not return a bool")
481 if init_result:
482 self.loadedModules[moduleName] = result
483 self.loadedModulesFilesDict[moduleName] = (
484 g.app.config.enabledPluginsFileName
485 )
486 else:
487 report(f"{moduleName}.init() returned False")
488 result = None
489 except Exception:
490 report(f"exception loading plugin: {moduleName}")
491 g.es_exception()
492 result = None
493 return result
494 #@+node:ekr.20180528162604.1: *5* function:finishImport
495 def finishImport(result):
496 """Handle last-minute checks."""
497 if tag == 'unit-test-load':
498 return result # Keep the result, but do no more.
499 if hasattr(result, 'init'):
500 return callInitFunction(result)
501 #
502 # No top-level init function.
503 if g.unitTesting:
504 # Do *not* load the module.
505 self.loadedModules[moduleName] = None
506 return None
507 # Guess that the module was loaded correctly.
508 report(f"fyi: no top-level init() function in {moduleName}")
509 self.loadedModules[moduleName] = result
510 return result
511 #@+node:ekr.20180528160744.1: *5* function:loadOnePluginHelper
512 def loadOnePluginHelper(moduleName):
513 result = None
514 try:
515 __import__(moduleName)
516 # Look up through sys.modules, __import__ returns toplevel package
517 result = sys.modules[moduleName]
518 except g.UiTypeException:
519 report(f"plugin {moduleName} does not support {g.app.gui.guiName()} gui")
520 except ImportError:
521 report(f"error importing plugin: {moduleName}")
522 # except ModuleNotFoundError:
523 # report('module not found: %s' % moduleName)
524 except SyntaxError:
525 report(f"syntax error importing plugin: {moduleName}")
526 except Exception:
527 report(f"exception importing plugin: {moduleName}")
528 g.es_exception()
529 return result
530 #@+node:ekr.20180528162300.1: *5* function:reportFailedImport
531 def reportFailedImport():
532 """Report a failed import."""
533 if g.app.batchMode or g.app.inBridge or g.unitTesting:
534 return
535 if (
536 self.warn_on_failure and
537 tag == 'open0' and
538 not g.app.gui.guiName().startswith('curses') and
539 moduleName not in optional_modules
540 ):
541 report(f"can not load enabled plugin: {moduleName}")
542 #@-others
543 if not g.app.enablePlugins:
544 report(f"plugins disabled: {moduleOrFileName}")
545 return None
546 if moduleOrFileName.startswith('@'):
547 report(f"ignoring Leo directive: {moduleOrFileName}")
548 return None
549 # Return None, not False, to keep pylint happy.
550 # Allow Leo directives in @enabled-plugins nodes.
551 moduleName = self.regularizeName(moduleOrFileName)
552 if self.isLoaded(moduleName):
553 module = self.loadedModules.get(moduleName)
554 return module
555 assert g.app.loadDir
556 moduleName = g.toUnicode(moduleName)
557 #
558 # Try to load the plugin.
559 try:
560 self.loadingModuleNameStack.append(moduleName)
561 result = loadOnePluginHelper(moduleName)
562 finally:
563 self.loadingModuleNameStack.pop()
564 if not result:
565 if trace:
566 reportFailedImport()
567 return None
568 #
569 # Last-minute checks.
570 try:
571 self.loadingModuleNameStack.append(moduleName)
572 result = finishImport(result)
573 finally:
574 self.loadingModuleNameStack.pop()
575 if result:
576 # #1688: Plugins can update globalDirectiveList.
577 # Recalculate g.directives_pat.
578 g.update_directives_pat()
579 report(f"loaded: {moduleName}")
580 self.signonModule = result # for self.plugin_signon.
581 return result
582 #@+node:ekr.20031218072017.1318: *4* plugins.plugin_signon
583 def plugin_signon(self, module_name, verbose=False):
584 """Print the plugin signon."""
585 # This is called from as the result of the imports
586 # in self.loadOnePlugin
587 m = self.signonModule
588 if verbose:
589 g.es(f"...{m.__name__}.py v{m.__version__}: {g.plugin_date(m)}")
590 g.pr(m.__name__, m.__version__)
591 self.signonModule = None # Prevent double signons.
592 #@+node:ekr.20100908125007.6030: *4* plugins.unloadOnePlugin
593 def unloadOnePlugin(self, moduleOrFileName, verbose=False):
594 moduleName = self.regularizeName(moduleOrFileName)
595 if self.isLoaded(moduleName):
596 if verbose:
597 g.pr('unloading', moduleName)
598 del self.loadedModules[moduleName]
599 for tag in self.handlers:
600 bunches = self.handlers.get(tag)
601 bunches = [bunch for bunch in bunches if bunch.moduleName != moduleName]
602 self.handlers[tag] = bunches
603 #@+node:ekr.20100909065501.5951: *3* plugins.Registration
604 #@+node:ekr.20100908125007.6028: *4* plugins.registerExclusiveHandler
605 def registerExclusiveHandler(self, tags, fn):
606 """ Register one or more exclusive handlers"""
607 if isinstance(tags, (list, tuple)):
608 for tag in tags:
609 self.registerOneExclusiveHandler(tag, fn)
610 else:
611 self.registerOneExclusiveHandler(tags, fn)
613 def registerOneExclusiveHandler(self, tag, fn):
614 """Register one exclusive handler"""
615 try:
616 moduleName = self.loadingModuleNameStack[-1]
617 except IndexError:
618 moduleName = '<no module>'
619 # print(f"{g.unitTesting:6} {moduleName:15} {tag:25} {fn.__name__}")
620 if g.unitTesting:
621 return
622 if tag in self.handlers:
623 g.es(f"*** Two exclusive handlers for '{tag}'")
624 else:
625 bunch = g.Bunch(fn=fn, moduleName=moduleName, tag='handler')
626 aList = self.handlers.get(tag, [])
627 aList.append(bunch)
628 self.handlers[tag] = aList
629 #@+node:ekr.20100908125007.6029: *4* plugins.registerHandler & registerOneHandler
630 def registerHandler(self, tags, fn):
631 """ Register one or more handlers"""
632 if isinstance(tags, (list, tuple)):
633 for tag in tags:
634 self.registerOneHandler(tag, fn)
635 else:
636 self.registerOneHandler(tags, fn)
638 def registerOneHandler(self, tag, fn):
639 """Register one handler"""
640 try:
641 moduleName = self.loadingModuleNameStack[-1]
642 except IndexError:
643 moduleName = '<no module>'
644 # print(f"{g.unitTesting:6} {moduleName:15} {tag:25} {fn.__name__}")
645 items = self.handlers.get(tag, [])
646 functions = [z.fn for z in items]
647 if fn not in functions: # Vitalije
648 bunch = g.Bunch(fn=fn, moduleName=moduleName, tag='handler')
649 items.append(bunch)
650 self.handlers[tag] = items
651 #@+node:ekr.20100908125007.6031: *4* plugins.unregisterHandler
652 def unregisterHandler(self, tags, fn):
653 if isinstance(tags, (list, tuple)):
654 for tag in tags:
655 self.unregisterOneHandler(tag, fn)
656 else:
657 self.unregisterOneHandler(tags, fn)
659 def unregisterOneHandler(self, tag, fn):
660 bunches = self.handlers.get(tag)
661 bunches = [bunch for bunch in bunches if bunch and bunch.fn != fn]
662 self.handlers[tag] = bunches
663 #@-others
664#@-others
665#@@language python
666#@@tabwidth -4
667#@@pagewidth 70
669#@-leo