Changeset 79:0752a1cca356 in ralphm-patches


Ignore:
Timestamp:
Jun 22, 2016, 4:43:00 PM (4 years ago)
Author:
Ralph Meijer <ralphm@…>
Branch:
default
Message:

Split out stanza module and response tracking patches, fix other patches.

Files:
2 added
6 edited

Legend:

Unmodified
Added
Removed
  • async-observer.patch

    r77 r79  
    11# HG changeset patch
    2 # User Ralph Meijer <ralphm@ik.nu>
    3 # Date 1427898739 -7200
    4 #      Wed Apr 01 16:32:19 2015 +0200
    5 # Node ID a7e41ac1ecd9b0df220142142eafd92aa07481c4
    6 # Parent  56607e5ddb53e3fafbecb41118bd220dea9310c7
    7 Add decorator for writing async stanza handlers.
     2# Parent 56607e5ddb53e3fafbecb41118bd220dea9310c7
    83
    9 diff --git a/wokkel/generic.py b/wokkel/generic.py
    10 --- a/wokkel/generic.py
    11 +++ b/wokkel/generic.py
    12 @@ -7,18 +7,22 @@
    13  Generic XMPP protocol helpers.
    14  """
    15  
    16 +__all__ = ['parseXml', 'FallbackHandler', 'VersionHandler', 'XmlPipe',
    17 +           'DeferredXmlStreamFactory', 'prepareIDNName',
    18 +           'Stanza', 'Request', 'ErrorStanza']
    19 +
    20  from zope.interface import implements
    21  
    22  from twisted.internet import defer, protocol
    23 -from twisted.python import reflect
    24  from twisted.python.deprecate import deprecated
    25  from twisted.python.versions import Version
    26 -from twisted.words.protocols.jabber import error, jid, xmlstream
    27 +from twisted.words.protocols.jabber import error, xmlstream
    28  from twisted.words.protocols.jabber.xmlstream import toResponse
    29  from twisted.words.xish import domish, utility
    30  from twisted.words.xish.xmlstream import BootstrapMixin
    31  
    32  from wokkel.iwokkel import IDisco
    33 +from wokkel.stanza import Stanza, ErrorStanza, Request
    34  from wokkel.subprotocols import XMPPHandler
    35  
    36  IQ_GET = '/iq[@type="get"]'
    37 @@ -47,24 +51,6 @@
    38  
    39  
    40  
    41 -def stripNamespace(rootElement):
    42 -    namespace = rootElement.uri
    43 -
    44 -    def strip(element):
    45 -        if element.uri == namespace:
    46 -            element.uri = None
    47 -            if element.defaultUri == namespace:
    48 -                element.defaultUri = None
    49 -            for child in element.elements():
    50 -                strip(child)
    51 -
    52 -    if namespace is not None:
    53 -        strip(rootElement)
    54 -
    55 -    return rootElement
    56 -
    57 -
    58 -
    59  class FallbackHandler(XMPPHandler):
    60      """
    61      XMPP subprotocol handler that catches unhandled iq requests.
    62 @@ -162,145 +148,6 @@
    63  
    64  
    65  
    66 -class Stanza(object):
    67 -    """
    68 -    Abstract representation of a stanza.
    69 -
    70 -    @ivar sender: The sending entity.
    71 -    @type sender: L{jid.JID}
    72 -    @ivar recipient: The receiving entity.
    73 -    @type recipient: L{jid.JID}
    74 -    """
    75 -
    76 -    recipient = None
    77 -    sender = None
    78 -    stanzaKind = None
    79 -    stanzaID = None
    80 -    stanzaType = None
    81 -
    82 -    def __init__(self, recipient=None, sender=None):
    83 -        self.recipient = recipient
    84 -        self.sender = sender
    85 -
    86 -
    87 -    @classmethod
    88 -    def fromElement(Class, element):
    89 -        """
    90 -        Create a stanza from a L{domish.Element}.
    91 -        """
    92 -        stanza = Class()
    93 -        stanza.parseElement(element)
    94 -        return stanza
    95 -
    96 -
    97 -    def parseElement(self, element):
    98 -        """
    99 -        Parse the stanza element.
    100 -
    101 -        This is called with the stanza's element when a L{Stanza} is
    102 -        created using L{fromElement}. It parses the stanza's core attributes
    103 -        (addressing, type and id), strips the namespace from the stanza
    104 -        element for easier transport across streams and passes on
    105 -        child elements for further parsing.
    106 -
    107 -        Child element parsers are defined by providing a C{childParsers}
    108 -        attribute on a subclass, as a mapping from (URI, name) to the name
    109 -        of the handler on C{self}. C{parseElement} will accumulate
    110 -        C{childParsers} from its class hierarchy, iterate over the child
    111 -        elements and pass it to matching handlers based on the child element's
    112 -        URI and name. The special key of C{None} can be used to pass all
    113 -        child elements to.
    114 -        """
    115 -        if element.hasAttribute('from'):
    116 -            self.sender = jid.internJID(element['from'])
    117 -        if element.hasAttribute('to'):
    118 -            self.recipient = jid.internJID(element['to'])
    119 -        self.stanzaType = element.getAttribute('type')
    120 -        self.stanzaID = element.getAttribute('id')
    121 -
    122 -        # Save element
    123 -        stripNamespace(element)
    124 -        self.element = element
    125 -
    126 -        # accumulate all childHandlers in the class hierarchy of Class
    127 -        handlers = {}
    128 -        reflect.accumulateClassDict(self.__class__, 'childParsers', handlers)
    129 -
    130 -        for child in element.elements():
    131 -            try:
    132 -                handler = handlers[child.uri, child.name]
    133 -            except KeyError:
    134 -                try:
    135 -                    handler = handlers[None]
    136 -                except KeyError:
    137 -                    continue
    138 -
    139 -            getattr(self, handler)(child)
    140 -
    141 -
    142 -    def toElement(self):
    143 -        element = domish.Element((None, self.stanzaKind))
    144 -        if self.sender is not None:
    145 -            element['from'] = self.sender.full()
    146 -        if self.recipient is not None:
    147 -            element['to'] = self.recipient.full()
    148 -        if self.stanzaType:
    149 -            element['type'] = self.stanzaType
    150 -        if self.stanzaID:
    151 -            element['id'] = self.stanzaID
    152 -        return element
    153 -
    154 -
    155 -
    156 -class ErrorStanza(Stanza):
    157 -
    158 -    def parseElement(self, element):
    159 -        Stanza.parseElement(self, element)
    160 -        self.exception = error.exceptionFromStanza(element)
    161 -
    162 -
    163 -
    164 -class Request(Stanza):
    165 -    """
    166 -    IQ request stanza.
    167 -
    168 -    This is a base class for IQ get or set stanzas, to be used with
    169 -    L{wokkel.subprotocols.StreamManager.request}.
    170 -    """
    171 -
    172 -    stanzaKind = 'iq'
    173 -    stanzaType = 'get'
    174 -    timeout = None
    175 -
    176 -    childParsers = {None: 'parseRequest'}
    177 -
    178 -    def __init__(self, recipient=None, sender=None, stanzaType=None):
    179 -        Stanza.__init__(self, recipient=recipient, sender=sender)
    180 -        if stanzaType is not None:
    181 -            self.stanzaType = stanzaType
    182 -
    183 -
    184 -    def parseRequest(self, element):
    185 -        """
    186 -        Called with the request's child element for parsing.
    187 -
    188 -        When a request instance is created using L{fromElement}, this method
    189 -        is called with the child element of the iq. Override this method for
    190 -        parsing the request's payload.
    191 -        """
    192 -
    193 -
    194 -    def toElement(self):
    195 -        element = Stanza.toElement(self)
    196 -
    197 -        if not self.stanzaID:
    198 -            element.addUniqueId()
    199 -            self.stanzaID = element['id']
    200 -
    201 -        return element
    202 -
    203 -
    204 -
    205  class DeferredXmlStreamFactory(BootstrapMixin, protocol.ClientFactory):
    206      protocol = xmlstream.XmlStream
    207  
    208 diff --git a/wokkel/stanza.py b/wokkel/stanza.py
    209 new file mode 100644
    210 --- /dev/null
    211 +++ b/wokkel/stanza.py
    212 @@ -0,0 +1,170 @@
    213 +# -*- test-case-name: wokkel.test.test_generic -*-
    214 +#
    215 +# Copyright (c) Ralph Meijer.
    216 +# See LICENSE for details.
    217 +
    218 +"""
    219 +XMPP Stanza helpers.
    220 +"""
    221 +
    222 +from twisted.python import reflect
    223 +from twisted.words.protocols.jabber import error, jid
    224 +from twisted.words.xish import domish
    225 +
    226 +def stripNamespace(rootElement):
    227 +    namespace = rootElement.uri
    228 +
    229 +    def strip(element):
    230 +        if element.uri == namespace:
    231 +            element.uri = None
    232 +            if element.defaultUri == namespace:
    233 +                element.defaultUri = None
    234 +            for child in element.elements():
    235 +                strip(child)
    236 +
    237 +    if namespace is not None:
    238 +        strip(rootElement)
    239 +
    240 +    return rootElement
    241 +
    242 +
    243 +
    244 +class Stanza(object):
    245 +    """
    246 +    Abstract representation of a stanza.
    247 +
    248 +    @ivar sender: The sending entity.
    249 +    @type sender: L{jid.JID}
    250 +    @ivar recipient: The receiving entity.
    251 +    @type recipient: L{jid.JID}
    252 +    """
    253 +
    254 +    recipient = None
    255 +    sender = None
    256 +    stanzaKind = None
    257 +    stanzaID = None
    258 +    stanzaType = None
    259 +
    260 +    def __init__(self, recipient=None, sender=None):
    261 +        self.recipient = recipient
    262 +        self.sender = sender
    263 +
    264 +
    265 +    @classmethod
    266 +    def fromElement(Class, element):
    267 +        """
    268 +        Create a stanza from a L{domish.Element}.
    269 +        """
    270 +        stanza = Class()
    271 +        stanza.parseElement(element)
    272 +        return stanza
    273 +
    274 +
    275 +    def parseElement(self, element):
    276 +        """
    277 +        Parse the stanza element.
    278 +
    279 +        This is called with the stanza's element when a L{Stanza} is
    280 +        created using L{fromElement}. It parses the stanza's core attributes
    281 +        (addressing, type and id), strips the namespace from the stanza
    282 +        element for easier transport across streams and passes on
    283 +        child elements for further parsing.
    284 +
    285 +        Child element parsers are defined by providing a C{childParsers}
    286 +        attribute on a subclass, as a mapping from (URI, name) to the name
    287 +        of the handler on C{self}. C{parseElement} will accumulate
    288 +        C{childParsers} from its class hierarchy, iterate over the child
    289 +        elements and pass it to matching handlers based on the child element's
    290 +        URI and name. The special key of C{None} can be used to pass all
    291 +        child elements to.
    292 +        """
    293 +        if element.hasAttribute('from'):
    294 +            self.sender = jid.internJID(element['from'])
    295 +        if element.hasAttribute('to'):
    296 +            self.recipient = jid.internJID(element['to'])
    297 +        self.stanzaType = element.getAttribute('type')
    298 +        self.stanzaID = element.getAttribute('id')
    299 +
    300 +        # Save element
    301 +        stripNamespace(element)
    302 +        self.element = element
    303 +
    304 +        # accumulate all childHandlers in the class hierarchy of Class
    305 +        handlers = {}
    306 +        reflect.accumulateClassDict(self.__class__, 'childParsers', handlers)
    307 +
    308 +        for child in element.elements():
    309 +            try:
    310 +                handler = handlers[child.uri, child.name]
    311 +            except KeyError:
    312 +                try:
    313 +                    handler = handlers[None]
    314 +                except KeyError:
    315 +                    continue
    316 +
    317 +            getattr(self, handler)(child)
    318 +
    319 +
    320 +    def toElement(self):
    321 +        element = domish.Element((None, self.stanzaKind))
    322 +        if self.sender is not None:
    323 +            element['from'] = self.sender.full()
    324 +        if self.recipient is not None:
    325 +            element['to'] = self.recipient.full()
    326 +        if self.stanzaType:
    327 +            element['type'] = self.stanzaType
    328 +        if self.stanzaID:
    329 +            element['id'] = self.stanzaID
    330 +        return element
    331 +
    332 +
    333 +
    334 +class ErrorStanza(Stanza):
    335 +
    336 +    def parseElement(self, element):
    337 +        Stanza.parseElement(self, element)
    338 +        self.exception = error.exceptionFromStanza(element)
    339 +
    340 +
    341 +
    342 +class Request(Stanza):
    343 +    """
    344 +    IQ request stanza.
    345 +
    346 +    This is a base class for IQ get or set stanzas, to be used with
    347 +    L{wokkel.subprotocols.StreamManager.request}.
    348 +    """
    349 +
    350 +    stanzaKind = 'iq'
    351 +    stanzaType = 'get'
    352 +    timeout = None
    353 +
    354 +    childParsers = {None: 'parseRequest'}
    355 +
    356 +    def __init__(self, recipient=None, sender=None, stanzaType=None):
    357 +        Stanza.__init__(self, recipient=recipient, sender=sender)
    358 +        if stanzaType is not None:
    359 +            self.stanzaType = stanzaType
    360 +
    361 +
    362 +    def parseRequest(self, element):
    363 +        """
    364 +        Called with the request's child element for parsing.
    365 +
    366 +        When a request instance is created using L{fromElement}, this method
    367 +        is called with the child element of the iq. Override this method for
    368 +        parsing the request's payload.
    369 +        """
    370 +
    371 +
    372 +    def toElement(self):
    373 +        element = Stanza.toElement(self)
    374 +
    375 +        if not self.stanzaID:
    376 +            element.addUniqueId()
    377 +            self.stanzaID = element['id']
    378 +
    379 +        return element
    380 +
    381 +
    382 +
    3834diff --git a/wokkel/subprotocols.py b/wokkel/subprotocols.py
    3845--- a/wokkel/subprotocols.py
     
    39314 
    39415 from twisted.internet import defer
    395 @@ -23,6 +25,8 @@
    396  from twisted.words.xish import xpath
    397  from twisted.words.xish.domish import IElement
    398  
    399 +from wokkel.stanza import Stanza
    400 +
    401  deprecatedModuleAttribute(
    402          Version("Wokkel", 0, 7, 0),
    403          "Use twisted.words.protocols.jabber.xmlstream.XMPPHandlerCollection "
    404 @@ -285,14 +289,15 @@
    405          """
    406          Handle iq response by firing associated deferred.
    407          """
    408 +        stanza = Stanza.fromElement(iq)
    409          try:
    410 -            d = self._iqDeferreds[iq["id"]]
    411 +            d = self._iqDeferreds[(stanza.sender, stanza.stanzaID)]
    412          except KeyError:
    413              return
    414  
    415 -        del self._iqDeferreds[iq["id"]]
    416 +        del self._iqDeferreds[(stanza.sender, stanza.stanzaID)]
    417          iq.handled = True
    418 -        if iq['type'] == 'error':
    419 +        if stanza.stanzaType == 'error':
    420              d.errback(error.exceptionFromStanza(iq))
    421          else:
    422              d.callback(iq)
    423 @@ -356,13 +361,13 @@
    424  
    425          # Set up iq response tracking
    426          d = defer.Deferred()
    427 -        self._iqDeferreds[element['id']] = d
    428 +        self._iqDeferreds[(request.recipient, request.stanzaID)] = d
    429  
    430          timeout = getattr(request, 'timeout', self.timeout)
    431  
    432          if timeout is not None:
    433              def onTimeout():
    434 -                del self._iqDeferreds[element['id']]
    435 +                del self._iqDeferreds[(request.recipient, request.stanzaID)]
    436                  d.errback(xmlstream.TimeoutError("IQ timed out"))
    437  
    438              call = self._reactor.callLater(timeout, onTimeout)
    439 @@ -379,6 +384,90 @@
     16@@ -379,6 +381,89 @@
    44017 
    44118 
     
    47047+    The return value of the wrapped observer is used to set the C{handled}
    47148+    attribute, so that handlers may choose to ignore processing the same
    472 +    stanza. This is expected to be of type C{boolean} or a deferred. In the
    473 +    latter case, the C{handled} attribute is set to C{True}.
     49+    stanza.
    47450+
    47551+    If an exception is raised, or the deferred has its errback called, the
     
    52096+            result.addErrback(log.err)
    52197+
    522 +        element.handled = bool(result)
     98+        element.handled = result
    52399+    return observe
    524100+
     
    528104     """
    529105     XMPP subprotocol mixin for handle incoming IQ stanzas.
    530 @@ -401,15 +490,15 @@
     106@@ -401,15 +486,15 @@
    531107 
    532108     A typical way to use this mixin, is to set up L{xpath} observers on the
     
    548124         ...          "/iq[@type='get' or @type='set']" + QUERY_ROSTER,
    549125         ...          self.handleRequest)
    550 @@ -425,6 +514,7 @@
     126@@ -425,6 +510,7 @@
    551127 
    552128     iqHandlers = None
     
    556132         """
    557133         Find a handler and wrap the call for sending a response stanza.
    558 @@ -435,25 +525,13 @@
     134@@ -435,25 +521,13 @@
    559135             if result:
    560136                 if IElement.providedBy(result):
     
    583159         for queryString, method in self.iqHandlers.iteritems():
    584160             if xpath.internQuery(queryString).matches(iq):
    585 @@ -461,14 +539,10 @@
     161@@ -461,14 +535,10 @@
    586162 
    587163         if handler:
     
    602178+        d.addErrback(trapNotImplemented)
    603179+        return d
    604 diff --git a/wokkel/test/test_stanza.py b/wokkel/test/test_stanza.py
    605 new file mode 100644
    606 --- /dev/null
    607 +++ b/wokkel/test/test_stanza.py
    608 @@ -0,0 +1,218 @@
    609 +# Copyright (c) Ralph Meijer.
    610 +# See LICENSE for details.
    611 +
    612 +from twisted.trial import unittest
    613 +from twisted.words.protocols.jabber.jid import JID
    614 +
    615 +from wokkel import generic
    616 +
    617 +NS_VERSION = 'jabber:iq:version'
    618 +
    619 +"""
    620 +Tests for L{wokkel.generic}.
    621 +"""
    622 +
    623 +
    624 +
    625 +class StanzaTest(unittest.TestCase):
    626 +    """
    627 +    Tests for L{generic.Stanza}.
    628 +    """
    629 +
    630 +    def test_fromElement(self):
    631 +        xml = """
    632 +        <message type='chat' from='other@example.org' to='user@example.org'/>
    633 +        """
    634 +
    635 +        stanza = generic.Stanza.fromElement(generic.parseXml(xml))
    636 +        self.assertEqual('chat', stanza.stanzaType)
    637 +        self.assertEqual(JID('other@example.org'), stanza.sender)
    638 +        self.assertEqual(JID('user@example.org'), stanza.recipient)
    639 +
    640 +
    641 +    def test_fromElementChildParser(self):
    642 +        """
    643 +        Child elements for which no parser is defined are ignored.
    644 +        """
    645 +        xml = """
    646 +        <message from='other@example.org' to='user@example.org'>
    647 +          <x xmlns='http://example.org/'/>
    648 +        </message>
    649 +        """
    650 +
    651 +        class Message(generic.Stanza):
    652 +            childParsers = {('http://example.org/', 'x'): '_childParser_x'}
    653 +            elements = []
    654 +
    655 +            def _childParser_x(self, element):
    656 +                self.elements.append(element)
    657 +
    658 +        message = Message.fromElement(generic.parseXml(xml))
    659 +        self.assertEqual(1, len(message.elements))
    660 +
    661 +
    662 +    def test_fromElementChildParserAll(self):
    663 +        """
    664 +        Child elements for which no parser is defined are ignored.
    665 +        """
    666 +        xml = """
    667 +        <message from='other@example.org' to='user@example.org'>
    668 +          <x xmlns='http://example.org/'/>
    669 +        </message>
    670 +        """
    671 +
    672 +        class Message(generic.Stanza):
    673 +            childParsers = {None: '_childParser'}
    674 +            elements = []
    675 +
    676 +            def _childParser(self, element):
    677 +                self.elements.append(element)
    678 +
    679 +        message = Message.fromElement(generic.parseXml(xml))
    680 +        self.assertEqual(1, len(message.elements))
    681 +
    682 +
    683 +    def test_fromElementChildParserUnknown(self):
    684 +        """
    685 +        Child elements for which no parser is defined are ignored.
    686 +        """
    687 +        xml = """
    688 +        <message from='other@example.org' to='user@example.org'>
    689 +          <x xmlns='http://example.org/'/>
    690 +        </message>
    691 +        """
    692 +        generic.Stanza.fromElement(generic.parseXml(xml))
    693 +
    694 +
    695 +
    696 +
    697 +class RequestTest(unittest.TestCase):
    698 +    """
    699 +    Tests for L{generic.Request}.
    700 +    """
    701 +
    702 +    def setUp(self):
    703 +        self.request = generic.Request()
    704 +
    705 +
    706 +    def test_requestParser(self):
    707 +        """
    708 +        The request's child element is passed to requestParser.
    709 +        """
    710 +        xml = """
    711 +        <iq type='get'>
    712 +          <query xmlns='jabber:iq:version'/>
    713 +        </iq>
    714 +        """
    715 +
    716 +        class VersionRequest(generic.Request):
    717 +            elements = []
    718 +
    719 +            def parseRequest(self, element):
    720 +                self.elements.append((element.uri, element.name))
    721 +
    722 +        request = VersionRequest.fromElement(generic.parseXml(xml))
    723 +        self.assertEqual([(NS_VERSION, 'query')], request.elements)
    724 +
    725 +
    726 +    def test_toElementStanzaKind(self):
    727 +        """
    728 +        A request is an iq stanza.
    729 +        """
    730 +        element = self.request.toElement()
    731 +        self.assertIdentical(None, element.uri)
    732 +        self.assertEquals('iq', element.name)
    733 +
    734 +
    735 +    def test_toElementStanzaType(self):
    736 +        """
    737 +        The request has type 'get'.
    738 +        """
    739 +        self.assertEquals('get', self.request.stanzaType)
    740 +        element = self.request.toElement()
    741 +        self.assertEquals('get', element.getAttribute('type'))
    742 +
    743 +
    744 +    def test_toElementStanzaTypeSet(self):
    745 +        """
    746 +        The request has type 'set'.
    747 +        """
    748 +        self.request.stanzaType = 'set'
    749 +        element = self.request.toElement()
    750 +        self.assertEquals('set', element.getAttribute('type'))
    751 +
    752 +
    753 +    def test_toElementStanzaID(self):
    754 +        """
    755 +        A request, when rendered, has an identifier.
    756 +        """
    757 +        element = self.request.toElement()
    758 +        self.assertNotIdentical(None, self.request.stanzaID)
    759 +        self.assertEquals(self.request.stanzaID, element.getAttribute('id'))
    760 +
    761 +
    762 +    def test_toElementRecipient(self):
    763 +        """
    764 +        A request without recipient, has no 'to' attribute.
    765 +        """
    766 +        self.request = generic.Request(recipient=JID('other@example.org'))
    767 +        self.assertEquals(JID('other@example.org'), self.request.recipient)
    768 +        element = self.request.toElement()
    769 +        self.assertEquals(u'other@example.org', element.getAttribute('to'))
    770 +
    771 +
    772 +    def test_toElementRecipientNone(self):
    773 +        """
    774 +        A request without recipient, has no 'to' attribute.
    775 +        """
    776 +        element = self.request.toElement()
    777 +        self.assertFalse(element.hasAttribute('to'))
    778 +
    779 +
    780 +    def test_toElementSender(self):
    781 +        """
    782 +        A request with sender, has a 'from' attribute.
    783 +        """
    784 +        self.request = generic.Request(sender=JID('user@example.org'))
    785 +        self.assertEquals(JID('user@example.org'), self.request.sender)
    786 +        element = self.request.toElement()
    787 +        self.assertEquals(u'user@example.org', element.getAttribute('from'))
    788 +
    789 +
    790 +    def test_toElementSenderNone(self):
    791 +        """
    792 +        A request without sender, has no 'from' attribute.
    793 +        """
    794 +        element = self.request.toElement()
    795 +        self.assertFalse(element.hasAttribute('from'))
    796 +
    797 +
    798 +    def test_timeoutDefault(self):
    799 +        """
    800 +        The default is no timeout.
    801 +        """
    802 +        self.assertIdentical(None, self.request.timeout)
    803 +
    804 +
    805 +    def test_stanzaTypeInit(self):
    806 +        """
    807 +        If stanzaType is passed in __init__, it overrides the class variable.
    808 +        """
    809 +
    810 +        class SetRequest(generic.Request):
    811 +            stanzaType = 'set'
    812 +
    813 +        request = SetRequest(stanzaType='get')
    814 +        self.assertEqual('get', request.stanzaType)
    815 +
    816 +
    817 +    def test_stanzaTypeClass(self):
    818 +        """
    819 +        If stanzaType is not passed in __init__, the class variable is used.
    820 +        """
    821 +
    822 +        class SetRequest(generic.Request):
    823 +            stanzaType = 'set'
    824 +
    825 +        request = SetRequest()
    826 +        self.assertEqual('set', request.stanzaType)
    827180diff --git a/wokkel/test/test_subprotocols.py b/wokkel/test/test_subprotocols.py
    828181--- a/wokkel/test/test_subprotocols.py
    829182+++ b/wokkel/test/test_subprotocols.py
    830 @@ -14,6 +14,7 @@
    831  from twisted.python import failure
    832  from twisted.words.xish import domish
    833  from twisted.words.protocols.jabber import error, ijabber, xmlstream
    834 +from twisted.words.protocols.jabber.jid import JID
    835  
    836  from wokkel import generic, subprotocols
    837  
    838 @@ -202,7 +203,9 @@
    839          self.transport = proto_helpers.StringTransport()
    840          self.xmlstream.transport = self.transport
    841  
    842 -        self.request = IQGetStanza()
    843 +        self.request = IQGetStanza(recipient=JID('other@example.org'),
    844 +                                   sender=JID('user@example.org'))
    845 +
    846  
    847      def _streamStarted(self):
    848          """
    849 @@ -562,7 +565,8 @@
    850          self._streamStarted()
    851  
    852          self.streamManager.request(self.request)
    853 -        expected = u"<iq type='get' id='%s'/>" % self.request.stanzaID
    854 +        expected = (u"<iq to='other@example.org' from='user@example.org'"
    855 +                       u" id='%s' type='get'/>" % self.request.stanzaID)
    856          self.assertEquals(expected, self.transport.value())
    857  
    858  
    859 @@ -575,7 +579,8 @@
    860          self.request.stanzaID = None
    861          self.streamManager.request(self.request)
    862          self.assertNotIdentical(None, self.request.stanzaID)
    863 -        expected = u"<iq type='get' id='%s'/>" % self.request.stanzaID
    864 +        expected = (u"<iq to='other@example.org' from='user@example.org'"
    865 +                       u" id='%s' type='get'/>" % self.request.stanzaID)
    866          self.assertEquals(expected, self.transport.value())
    867  
    868  
    869 @@ -587,7 +592,8 @@
    870          self.streamManager.addHandler(handler)
    871  
    872          self.streamManager.request(self.request)
    873 -        expected = u"<iq type='get' id='test'/>"
    874 +        expected = (u"<iq to='other@example.org' from='user@example.org'"
    875 +                       u" id='test' type='get'/>")
    876  
    877          xs = self.xmlstream
    878          self.assertEquals("", xs.transport.value())
    879 @@ -616,7 +622,8 @@
    880          d.addCallback(cb)
    881  
    882          xs = self.xmlstream
    883 -        xs.dataReceived("<iq type='result' id='test'/>")
    884 +        xs.dataReceived("<iq from='other@example.org' "
    885 +                            "type='result' id='test'/>")
    886          return d
    887  
    888  
    889 @@ -629,7 +636,8 @@
    890          self.assertFailure(d, error.StanzaError)
    891  
    892          xs = self.xmlstream
    893 -        xs.dataReceived("<iq type='error' id='test'/>")
    894 +        xs.dataReceived("<iq from='other@example.org' "
    895 +                            "type='error' id='test'/>")
    896          return d
    897  
    898  
    899 @@ -656,6 +664,40 @@
    900          self.assertFalse(getattr(dispatched[-1], 'handled', False))
    901  
    902  
    903 +    def test_requestUnrelatedSenderResponse(self):
    904 +        """
    905 +        Responses with same id, but different sender are ignored.
    906 +
    907 +        As stanza identifiers are unique per entity, iq responses must be
    908 +        tracked by both the stanza ID and the entity the request was sent to.
    909 +        If a response with the same id as the request is received, but from
    910 +        a different entity, it is to be ignored.
    911 +        """
    912 +        # Set up a fallback handler that checks the stanza's handled attribute.
    913 +        # If that is set to True, the iq tracker claims to have handled the
    914 +        # response.
    915 +        dispatched = []
    916 +        def cb(iq):
    917 +            dispatched.append(iq)
    918 +
    919 +        self._streamStarted()
    920 +        self.xmlstream.addObserver("/iq", cb, -1)
    921 +
    922 +        d = self.streamManager.request(self.request)
    923 +
    924 +        # Receive an untracked iq response
    925 +        self.xmlstream.dataReceived("<iq from='foo@example.org' "
    926 +                                        "type='result' id='test'/>")
    927 +        self.assertEquals(1, len(dispatched))
    928 +        self.assertFalse(getattr(dispatched[-1], 'handled', False))
    929 +
    930 +        # Receive expected response
    931 +        self.xmlstream.dataReceived("<iq from='other@example.org' "
    932 +                                        "type='result' id='test'/>")
    933 +
    934 +        return d
    935 +
    936 +
    937      def test_requestCleanup(self):
    938          """
    939          Test if the deferred associated with an iq request is removed
    940 @@ -665,7 +707,8 @@
    941          self._streamStarted()
    942          d = self.streamManager.request(self.request)
    943          xs = self.xmlstream
    944 -        xs.dataReceived("<iq type='result' id='test'/>")
    945 +        xs.dataReceived("<iq from='other@example.org' "
    946 +                            "type='result' id='test'/>")
    947          self.assertNotIn('test', self.streamManager._iqDeferreds)
    948          return d
    949  
    950 @@ -721,7 +764,8 @@
    951          self.request.timeout = 60
    952          d = self.streamManager.request(self.request)
    953          self.clock.callLater(1, self.xmlstream.dataReceived,
    954 -                             "<iq type='result' id='test'/>")
    955 +                             "<iq from='other@example.org' "
    956 +                                 "type='result' id='test'/>")
    957          self.clock.pump([1, 1])
    958          self.assertFalse(self.clock.calls)
    959          return d
    960 @@ -790,13 +834,205 @@
     183@@ -790,13 +790,175 @@
    961184         self.xmlstream.send(obj)
    962185 
     
    1023246+        self.assertTrue(element.handled)
    1024247+        self.assertFalse(self.output)
    1025 +
    1026 +
    1027 +    def test_syncTruthy(self):
    1028 +        """
    1029 +        A truthy value results in element marked as handled.
    1030 +        """
    1031 +        @subprotocols.asyncObserver
    1032 +        def observer(self, element):
    1033 +            return 3
    1034 +
    1035 +        element = domish.Element((None, u'message'))
    1036 +
    1037 +        observer(self, element)
    1038 +        self.assertTrue(element.handled)
    1039 +        self.assertIsInstance(element.handled, bool)
    1040 +
    1041 +
    1042 +    def test_syncNonTruthy(self):
    1043 +        """
    1044 +        A non-truthy value results in element marked as not handled.
    1045 +        """
    1046 +        @subprotocols.asyncObserver
    1047 +        def observer(self, element):
    1048 +            return None
    1049 +
    1050 +        element = domish.Element((None, u'message'))
    1051 +
    1052 +        observer(self, element)
    1053 +        self.assertFalse(element.handled)
    1054 +        self.assertIsInstance(element.handled, bool)
    1055248+
    1056249+
     
    1166359             called = False
    1167360 
    1168 @@ -810,11 +1046,11 @@
     361@@ -810,11 +972,11 @@
    1169362         handler.handleRequest(iq)
    1170363         self.assertTrue(handler.called)
     
    1180373             called = False
    1181374 
    1182 @@ -828,11 +1064,11 @@
     375@@ -828,11 +990,11 @@
    1183376         handler.handleRequest(iq)
    1184377         self.assertFalse(handler.called)
     
    1194387             def onGet(self, iq):
    1195388                 return None
    1196 @@ -847,11 +1083,11 @@
     389@@ -847,11 +1009,11 @@
    1197390         self.assertEquals('iq', response.name)
    1198391         self.assertEquals('result', response['type'])
     
    1208401             payload = domish.Element(('testns', 'foo'))
    1209402 
    1210 @@ -870,11 +1106,11 @@
     403@@ -870,11 +1032,11 @@
    1211404         payload = response.elements().next()
    1212405         self.assertEqual(handler.payload, payload)
     
    1222415             def onGet(self, iq):
    1223416                 return defer.succeed(None)
    1224 @@ -889,11 +1125,11 @@
     417@@ -889,11 +1051,11 @@
    1225418         self.assertEquals('iq', response.name)
    1226419         self.assertEquals('result', response['type'])
     
    1236429             def onGet(self, iq):
    1237430                 raise error.StanzaError('forbidden')
    1238 @@ -910,11 +1146,11 @@
     431@@ -910,11 +1072,11 @@
    1239432         e = error.exceptionFromStanza(response)
    1240433         self.assertEquals('forbidden', e.condition)
     
    1250443             pass
    1251444 
    1252 @@ -935,11 +1171,11 @@
     445@@ -935,11 +1097,11 @@
    1253446         self.assertEquals('internal-server-error', e.condition)
    1254447         self.assertEquals(1, len(self.flushLoggedErrors(TestError)))
     
    1264457             def onGet(self, iq):
    1265458                 raise NotImplementedError()
    1266 @@ -956,11 +1192,11 @@
     459@@ -956,11 +1118,11 @@
    1267460         e = error.exceptionFromStanza(response)
    1268461         self.assertEquals('feature-not-implemented', e.condition)
  • c2s_server_factory.patch

    r72 r79  
    11# HG changeset patch
    22# Parent 49294b2cf829414b42141731b5130d91474c0443
     3# Parent  97fdb86984fc983e78234f8c3d746da061b21f1b
    34Add factory for accepting client connections.
    45
     
    106107--- a/wokkel/generic.py
    107108+++ b/wokkel/generic.py
    108 @@ -628,3 +628,65 @@
     109@@ -480,3 +480,64 @@
    109110     standard full stop.
    110111     """
     
    135136+        Called when the stream has been initialized.
    136137+        """
    137 +        self.xmlstream.addObserver('/*[@xmlns="%s"]' %
    138 +                                       self.xmlstream.namespace,
    139 +                                   stripNamespace, priority=1)
    140138+        self.xmlstream.addObserver('/*', self.onStanza, priority=-1)
    141139+
     
    157155+
    158156+        if (element.name not in ('iq', 'message', 'presence') or
    159 +            element.uri is not None):
     157+            element.uri != self.xmlstream.namespace):
    160158+            return
    161159+
    162 +        if not element.getAttribute('to'):
     160+        stanza = Stanza.fromElement(element)
     161+
     162+        if not stanza.recipient:
    163163+            exc = error.StanzaError('service-unavailable')
    164 +            self.send(exc.toResponse(element))
     164+            self.send(exc.toResponse(stanza.element))
    165165+        else:
    166 +            self.xmlstream.avatar.send(element)
     166+            self.xmlstream.avatar.send(stanza.element)
    167167+
    168168+
     
    316316--- a/wokkel/test/test_generic.py
    317317+++ b/wokkel/test/test_generic.py
    318 @@ -681,3 +681,105 @@
     318@@ -728,3 +728,91 @@
    319319         name = u"example.com."
    320320         result = generic.prepareIDNName(name)
     
    351351+        self.protocol.connectionInitialized()
    352352+
    353 +        element = domish.Element((None, u'message'))
     353+        element = domish.Element((u'jabber:client', u'message'))
    354354+        element[u'to'] = u'other@example.org'
    355355+        self.stub.send(element)
     
    365365+        self.protocol.connectionInitialized()
    366366+
    367 +        element = domish.Element((None, u'message'))
     367+        element = domish.Element((u'jabber:client', u'message'))
    368368+        self.stub.send(element)
    369369+
    370370+        self.assertEqual(0, len(self.avatar.sent))
    371371+        self.assertEqual(1, len(self.stub.output))
    372 +
    373 +
    374 +    def test_onStanzaClientNamespace(self):
    375 +        """
    376 +        Stanzas with an explicit namespace are delivered.
    377 +        """
    378 +        self.protocol.connectionInitialized()
    379 +
    380 +        element = domish.Element(('jabber:client', u'message'))
    381 +        element[u'to'] = u'other@example.org'
    382 +        self.stub.send(element)
    383 +
    384 +        self.assertEqual(1, len(self.avatar.sent))
    385 +        self.assertEqual(0, len(self.stub.output))
    386372+
    387373+
  • listening-authenticator-stream-features.patch

    r72 r79  
    11# HG changeset patch
    22# Parent 840b96390047670c5209195300f902689c18b12f
     3# Parent  146ff13d66e54c535445adf4a19a3ef07a0a6f57
    34Add FeatureListeningAuthenticator.
    45
     
    910--- a/wokkel/generic.py
    1011+++ b/wokkel/generic.py
    11 @@ -10,7 +10,7 @@
     12@@ -14,6 +14,7 @@
    1213 from zope.interface import implements
    1314 
    1415 from twisted.internet import defer, protocol
    15 -from twisted.python import reflect
    16 +from twisted.python import log, reflect
     16+from twisted.python import log
    1717 from twisted.python.deprecate import deprecated
    1818 from twisted.python.versions import Version
    19  from twisted.words.protocols.jabber import error, jid, xmlstream
    20 @@ -18,7 +18,7 @@
     19 from twisted.words.protocols.jabber import error, xmlstream
     20@@ -21,7 +22,7 @@
    2121 from twisted.words.xish import domish, utility
    2222 from twisted.words.xish.xmlstream import BootstrapMixin
     
    2424-from wokkel.iwokkel import IDisco
    2525+from wokkel.iwokkel import IDisco, IReceivingInitializer
     26 from wokkel.stanza import Stanza, ErrorStanza, Request
    2627 from wokkel.subprotocols import XMPPHandler
    2728 
    28  IQ_GET = '/iq[@type="get"]'
    29 @@ -27,6 +27,8 @@
     29@@ -31,6 +32,8 @@
    3030 NS_VERSION = 'jabber:iq:version'
    3131 VERSION = IQ_GET + '/query[@xmlns="' + NS_VERSION + '"]'
     
    3636     """
    3737     Parse serialized XML into a DOM structure.
    38 @@ -332,6 +334,290 @@
     38@@ -180,6 +183,290 @@
    3939 
    4040 
     
    414414 
    415415 NS_VERSION = 'jabber:iq:version'
    416 @@ -276,6 +281,334 @@
     416@@ -300,6 +305,334 @@
    417417 
    418418 
  • series

    r78 r79  
    1717
    1818delay-keyerror.patch
     19
    1920async-observer.patch
     21stanza-module.patch
     22response-tracking.patch
     23
    2024message-stanza.patch
    2125server-stream-manager.patch
  • server-stream-manager.patch

    r72 r79  
    11# HG changeset patch
    22# Parent 3f3fe954b1975c2d9115e0fa8177ae7b28a708a8
     3# Parent  e198cebf36146a768eda902efa5ee741e947fda8
    34Generalize StreamManager and add ServerStreamManager.
    45
     
    3536--- a/wokkel/subprotocols.py
    3637+++ b/wokkel/subprotocols.py
    37 @@ -118,7 +118,7 @@
     38@@ -122,7 +122,7 @@
    3839 
    3940 
     
    4445     Business logic representing a managed XMPP connection.
    4546 
    46 @@ -129,43 +129,39 @@
     47@@ -133,43 +133,39 @@
    4748 
    4849     @ivar xmlstream: currently managed XML stream
     
    9596             from twisted.internet import reactor
    9697         self._reactor = reactor
    97 @@ -191,7 +187,7 @@
     98@@ -195,7 +191,7 @@
    9899             handler.connectionInitialized()
    99100 
     
    104105         Called when the transport connection has been established.
    105106 
    106 @@ -209,13 +205,17 @@
     107@@ -213,13 +209,17 @@
    107108             xs.rawDataInFn = logDataIn
    108109             xs.rawDataOutFn = logDataOut
     
    123124         Called when the stream has been initialized.
    124125 
    125 @@ -238,21 +238,8 @@
     126@@ -242,21 +242,8 @@
    126127             e.connectionInitialized()
    127128 
     
    146147         Called when the stream has been closed.
    147148 
    148 @@ -379,6 +366,60 @@
     149@@ -468,6 +455,60 @@
    149150 
    150151 
     
    233234--- a/wokkel/test/test_subprotocols.py
    234235+++ b/wokkel/test/test_subprotocols.py
    235 @@ -189,20 +189,7 @@
     236@@ -190,22 +190,7 @@
    236237 
    237238 
     
    250251-        self.xmlstream.transport = self.transport
    251252-
    252 -        self.request = IQGetStanza()
     253-        self.request = IQGetStanza(recipient=JID('other@example.org'),
     254-                                   sender=JID('user@example.org'))
     255-
    253256+class BaseStreamManagerTestsMixin(object):
    254257 
    255258     def _streamStarted(self):
    256259         """
    257 @@ -216,25 +203,14 @@
     260@@ -219,25 +204,14 @@
    258261         self.xmlstream.dispatch(self.xmlstream, "//event/stream/authd")
    259262 
     
    285288         Test that protocol handlers have their connectionMade method called
    286289         when the XML stream is connected.
    287 @@ -242,27 +218,27 @@
     290@@ -245,27 +219,27 @@
    288291         sm = self.streamManager
    289292         handler = DummyXMPPHandler()
     
    319322         Test raw data functions set when logTraffic is set to True.
    320323         """
    321 @@ -270,13 +246,13 @@
     324@@ -273,13 +247,13 @@
    322325         sm.logTraffic = True
    323326         handler = DummyXMPPHandler()
     
    336339         Test that protocol handlers have their connectionInitialized method
    337340         called when the XML stream is initialized.
    338 @@ -284,27 +260,27 @@
     341@@ -287,27 +261,27 @@
    339342         sm = self.streamManager
    340343         handler = DummyXMPPHandler()
     
    369372         A L{STREAM_END_EVENT} results in L{StreamManager} firing the handlers
    370373         L{connectionLost} methods, passing a L{failure.Failure} reason.
    371 @@ -313,7 +289,7 @@
     374@@ -316,7 +290,7 @@
    372375         handler = FailureReasonXMPPHandler()
    373376         handler.setHandlerParent(sm)
     
    378381 
    379382 
    380 @@ -335,8 +311,8 @@
     383@@ -338,8 +312,8 @@
    381384         Adding a handler when connected doesn't call connectionInitialized.
    382385         """
     
    389392         handler.setHandlerParent(sm)
    390393 
    391 @@ -358,10 +334,10 @@
     394@@ -361,10 +335,10 @@
    392395                 self.nestedHandler.setHandlerParent(self.parent)
    393396 
     
    402405         self.assertEquals(1, handler.doneMade)
    403406         self.assertEquals(0, handler.doneInitialized)
    404 @@ -383,9 +359,9 @@
     407@@ -386,9 +360,9 @@
    405408         called.
    406409         """
     
    415418         handler.setHandlerParent(sm)
    416419 
    417 @@ -407,11 +383,11 @@
     420@@ -410,11 +384,11 @@
    418421                 self.nestedHandler.setHandlerParent(self.parent)
    419422 
     
    430433         self.assertEquals(1, handler.doneMade)
    431434         self.assertEquals(1, handler.doneInitialized)
    432 @@ -435,12 +411,12 @@
     435@@ -438,12 +412,12 @@
    433436                 self.nestedHandler.setHandlerParent(self.parent)
    434437 
     
    447450         self.assertEquals(1, handler.doneMade)
    448451         self.assertEquals(1, handler.doneInitialized)
    449 @@ -470,16 +446,13 @@
     452@@ -473,16 +447,13 @@
    450453 
    451454         The data should be sent directly over the XML stream.
     
    466469 
    467470 
    468 @@ -490,12 +463,11 @@
     471@@ -493,12 +464,11 @@
    469472         The data should be cached until an XML stream has been established and
    470473         initialized.
     
    481484         sm.send("<presence/>")
    482485         self.assertEquals("", xs.transport.value())
    483 @@ -522,7 +494,7 @@
     486@@ -525,7 +495,7 @@
    484487         """
    485488         factory = xmlstream.XmlStreamFactory(xmlstream.Authenticator())
     
    490493         xs.connectionMade()
    491494         xs.dataReceived("<stream:stream xmlns='jabber:client' "
    492 @@ -545,7 +517,7 @@
     495@@ -548,7 +518,7 @@
    493496         handler = DummyXMPPHandler()
    494497         sm.addHandler(handler)
     
    499502         xs.transport = proto_helpers.StringTransport()
    500503         xs.connectionLost(None)
    501 @@ -677,6 +649,7 @@
     504@@ -720,6 +690,7 @@
    502505         """
    503506         d = self.streamManager.request(self.request)
     
    507510         self.assertFailure(d, ConnectionDone)
    508511         return d
    509 @@ -692,6 +665,7 @@
     512@@ -735,6 +706,7 @@
    510513             d = xmlstream.IQ(self.xmlstream).send()
    511514             d.addErrback(eb)
     
    515518         d.addErrback(eb)
    516519         self.xmlstream.connectionLost(failure.Failure(ConnectionDone()))
    517 @@ -736,6 +710,7 @@
     520@@ -780,6 +752,7 @@
    518521         self.request.timeout = 60
    519522         d = self.streamManager.request(self.request)
     
    523526         self.assertFailure(d, ConnectionDone)
    524527         self.assertFalse(self.clock.calls)
    525 @@ -778,6 +753,56 @@
     528@@ -822,6 +795,58 @@
    526529 
    527530 
     
    540543+        self.xmlstream.transport = self.transport
    541544+
    542 +        self.request = IQGetStanza()
     545+        self.request = IQGetStanza(recipient=JID('other@example.org'),
     546+                                   sender=JID('user@example.org'))
    543547+
    544548+
     
    573577+                                   self.streamManager.makeConnection)
    574578+
    575 +        self.request = IQGetStanza()
     579+        self.request = IQGetStanza(recipient=JID('other@example.org'),
     580+                                   sender=JID('user@example.org'))
    576581+
    577582+
  • session_manager.patch

    r75 r79  
    11# HG changeset patch
    22# Parent d800a4363f602f6ff344181f0d20b5809157b0b1
     3# Parent  1ce344d36e1b3989fd4bf438bfa633d3a7673560
    34
    45diff --git a/wokkel/ewokkel.py b/wokkel/ewokkel.py
     
    4243--- a/wokkel/generic.py
    4344+++ b/wokkel/generic.py
    44 @@ -7,6 +7,8 @@
    45  Generic XMPP protocol helpers.
    46  """
     45@@ -11,6 +11,8 @@
     46            'DeferredXmlStreamFactory', 'prepareIDNName',
     47            'Stanza', 'Request', 'ErrorStanza']
    4748 
    4849+import copy
     
    5152 
    5253 from twisted.internet import defer, protocol
    53 @@ -66,6 +68,24 @@
    54      return rootElement
     54@@ -53,6 +55,24 @@
     55     return results and results[0] or None
    5556 
    5657 
     
    7677 class FallbackHandler(XMPPHandler):
    7778     """
    78 @@ -168,10 +188,23 @@
    79      """
    80      Abstract representation of a stanza.
    81  
    82 +    @ivar recipient: The receiving entity.
    83 +    @type recipient: L{jid.JID}
    84 +
    85      @ivar sender: The sending entity.
    86      @type sender: L{jid.JID}
    87 -    @ivar recipient: The receiving entity.
    88 -    @type recipient: L{jid.JID}
    89 +
    90 +    @ivar stanzaKind: One of C{'message'}, C{'presence'}, C{'iq'}.
    91 +    @type stanzaKind: L{unicode}.
    92 +
    93 +    @ivar stanzaID: The optional stanza identifier.
    94 +    @type stanzaID: L{unicode}.
    95 +
    96 +    @ivar stanzaType: The optional stanza type.
    97 +    @type stanzaType: L{unicode}.
    98 +
    99 +    @ivar element: The serialized XML of this stanza.
    100 +    @type element: L{domish.Element}.
    101      """
    102  
    103      recipient = None
    104 @@ -179,6 +212,8 @@
    105      stanzaKind = None
    106      stanzaID = None
    107      stanzaType = None
    108 +    element = None
    109 +
    110  
    111      def __init__(self, recipient=None, sender=None):
    112          self.recipient = recipient
    113 @@ -217,6 +252,7 @@
    114              self.sender = jid.internJID(element['from'])
    115          if element.hasAttribute('to'):
    116              self.recipient = jid.internJID(element['to'])
    117 +        self.stanzaKind = element.name
    118          self.stanzaType = element.getAttribute('type')
    119          self.stanzaID = element.getAttribute('id')
    120  
    121 @@ -242,6 +278,7 @@
    122  
    123      def toElement(self):
    124          element = domish.Element((None, self.stanzaKind))
    125 +        self.element = element
    126          if self.sender is not None:
    127              element['from'] = self.sender.full()
    128          if self.recipient is not None:
    12979diff --git a/wokkel/iwokkel.py b/wokkel/iwokkel.py
    13080--- a/wokkel/iwokkel.py
Note: See TracChangeset for help on using the changeset viewer.