[Checkins] SVN: zc.twist/trunk/src/zc/twist/ Initial checkin

Gary Poster gary at zope.com
Tue Aug 15 16:31:29 EDT 2006


Log message for revision 69534:
  Initial checkin
  

Changed:
  A   zc.twist/trunk/src/zc/twist/README.txt
  A   zc.twist/trunk/src/zc/twist/__init__.py
  A   zc.twist/trunk/src/zc/twist/tests.py

-=-
Added: zc.twist/trunk/src/zc/twist/README.txt
===================================================================
--- zc.twist/trunk/src/zc/twist/README.txt	2006-08-15 20:30:24 UTC (rev 69533)
+++ zc.twist/trunk/src/zc/twist/README.txt	2006-08-15 20:31:28 UTC (rev 69534)
@@ -0,0 +1,443 @@
+===================================================
+Twist: Talking to the ZODB in Twisted Reactor Calls
+===================================================
+
+The twist package contains a few functions and classes, but primarily a
+helper for having a deferred call on a callable persistent object, or on
+a method on a persistent object.  This lets you have a Twisted reactor
+call or a Twisted deferred callback affect the ZODB.  Everything can be
+done within the main thread, so it can be full-bore Twisted usage,
+without threads.  There are a few important "gotchas": see the Gotchas_
+section below for details.
+
+The main API is `Partial`.  You can pass it a callable persistent object,
+a method of a persistent object, or a normal non-persistent callable,
+and any arguments or keyword arguments of the same sort.  DO NOT
+use non-persistent data structures (such as lists) of persistent objects
+with a database connection as arguments.  This is your responsibility.
+
+If nothing is persistent, the partial will not bother to get a connection,
+and will behave normally.
+
+    >>> from zc.twist import Partial
+    >>> def demo():
+    ...     return 42
+    ...
+    >>> Partial(demo)()
+    42
+
+Now let's imagine a demo object that is persistent and part of a
+database connection.  It has a `count` attribute that starts at 0, a
+`__call__` method that increments count by an `amount` that defaults to
+1, and an `decrement` method that reduces count by an `amount` that
+defaults to 1 [#set_up]_.  Everything returns the current value of count.
+
+    >>> demo.count
+    0
+    >>> demo()
+    1
+    >>> demo(2)
+    3
+    >>> demo.decrement()
+    2
+    >>> demo.decrement(2)
+    0
+    >>> import transaction
+    >>> transaction.commit()
+
+Now we can make some deferred calls with these examples.  We will use
+`transaction.begin()` to sync our connection with what happened in the
+deferred call.  Note that we need to have some adapters set up for this
+to work.  The twist module includes implementations of them that we
+will also assume have been installed [#adapters]_.
+
+    >>> call = Partial(demo)
+    >>> demo.count # hasn't been called yet
+    0
+    >>> deferred = call()
+    >>> demo.count # we haven't synced yet
+    0
+    >>> t = transaction.begin() # sync the connection
+    >>> demo.count # ah-ha!
+    1
+
+We can use the deferred returned from the call to do somethin with the
+return value.  In this case, the deferred is already completed, so
+adding a callback gets instant execution.
+
+    >>> def show_value(res):
+    ...     print res
+    ...
+    >>> ignore = deferred.addCallback(show_value)
+    1
+
+We can also pass the method.
+
+    >>> call = Partial(demo.decrement)
+    >>> deferred = call()
+    >>> demo.count
+    1
+    >>> t = transaction.begin()
+    >>> demo.count
+    0
+
+Arguments are passed through.
+
+    >>> call = Partial(demo)
+    >>> deferred = call(2)
+    >>> t = transaction.begin()
+    >>> demo.count
+    2
+    >>> call = Partial(demo.decrement)
+    >>> deferred = call(amount=2)
+    >>> t = transaction.begin()
+    >>> demo.count
+    0
+
+They can also be set during instantiation.
+
+    >>> call = Partial(demo, 3)
+    >>> deferred = call()
+    >>> t = transaction.begin()
+    >>> demo.count
+    3
+    >>> call = Partial(demo.decrement, amount=3)
+    >>> deferred = call()
+    >>> t = transaction.begin()
+    >>> demo.count
+    0
+
+Arguments themselves can be persistent objects.  Let's assume a new demo2
+object as well.
+
+    >>> demo2.count
+    0
+    >>> def mass_increment(d1, d2, value=1):
+    ...     d1(value)
+    ...     d2(value)
+    ...
+    >>> call = Partial(mass_increment, demo, demo2, value=4)
+    >>> deferred = call()
+    >>> t = transaction.begin()
+    >>> demo.count
+    4
+    >>> demo2.count
+    4
+    >>> demo.count = demo2.count = 0 # cleanup
+    >>> transaction.commit()
+
+ConflictErrors make it retry.  
+
+In order to have a chance to simulate a ConflictError, this time imagine
+we have a runner that can switch execution from the call to our code
+using `pause`, `retry` and `resume` (this is just for tests--remember,
+calls used in non-threaded Twisted should be non-blocking!)
+[#conflict_error_setup]_.
+
+    >>> demo.count
+    0
+    >>> call = Partial(demo)
+    >>> runner = Runner(call) # it starts paused in the middle of an attempt
+    >>> call.attempt_count
+    1
+    >>> demo.count = 5 # now we will make a conflicting transaction...
+    >>> transaction.commit()
+    >>> runner.retry()
+    >>> call.attempt_count # so it has to retry
+    2
+    >>> t = transaction.begin()
+    >>> demo.count # our value hasn't changed...
+    5
+    >>> runner.resume() # but now call will be successful on the second attempt
+    >>> call.attempt_count
+    2
+    >>> t = transaction.begin()
+    >>> demo.count
+    6
+
+After five retries (currently hard-coded), the retry fails, raising the
+last ConflictError.  This is returned to the deferred.  The failure put
+on the deferred will have a sanitized traceback.  Here, imagine we have
+a deferred (named `deferred`) created from such a an event
+[#conflict_error_failure]_.
+
+    >>> res = None
+    >>> def get_result(r):
+    ...     global res
+    ...     res = r # we return None to quiet Twisted down on the command line
+    ...
+    >>> d = deferred.addErrback(get_result)
+    >>> print res.getTraceback() # doctest: +ELLIPSIS +NORMALIZE_WHITESPACE
+    Traceback (most recent call last):
+    ...
+    ZODB.POSException.ConflictError: database conflict error...
+
+Other errors are returned to the deferred as well, as sanitized failures
+[#use_original_demo]_.
+
+    >>> call = Partial(demo)
+    >>> d = call('I do not add well with integers')
+    >>> d = d.addErrback(get_result)
+    >>> print res.getTraceback() # doctest: +ELLIPSIS +NORMALIZE_WHITESPACE
+    Traceback (most recent call last):
+    ...
+    ...TypeError: unsupported operand type(s) for +=: 'int' and 'str'
+
+The call tries to be a good connection citizen, waiting for a connection
+if the pool is at its maximum size.  This code relies on the twisted
+reactor; we'll use a `time_flies` function, which takes seconds to move
+ahead, to simulate time passing in the reactor
+[#relies_on_twisted_reactor]_.
+
+    >>> db.setPoolSize(1)
+    >>> db.getPoolSize()
+    1
+    >>> demo.count = 0
+    >>> transaction.commit()
+    >>> call = Partial(demo)
+    >>> res = None
+    >>> deferred = call()
+    >>> d = deferred.addCallback(get_result)
+    >>> call.attempt_count
+    0
+    >>> time_flies(.1) >= 1 # returns number of connection attempts
+    True
+    >>> call.attempt_count
+    0
+    >>> res # None
+    >>> db.setPoolSize(2)
+    >>> db.getPoolSize()
+    2
+    >>> time_flies(.2) >= 1
+    True
+    >>> call.attempt_count > 0
+    True
+    >>> res
+    1
+    >>> t = transaction.begin()
+    >>> demo.count
+    1
+
+If it takes more than a second or two, it will eventually just decide to grab
+one.  This behavior may change.
+
+    >>> db.setPoolSize(1)
+    >>> db.getPoolSize()
+    1
+    >>> call = Partial(demo)
+    >>> res = None
+    >>> deferred = call()
+    >>> d = deferred.addCallback(get_result)
+    >>> call.attempt_count
+    0
+    >>> time_flies(.1) >= 1
+    True
+    >>> call.attempt_count
+    0
+    >>> res # None
+    >>> time_flies(1.9) >= 2 # for a total of at least 3
+    True
+    >>> res
+    2
+    >>> t = transaction.begin()
+    >>> demo.count
+    2
+
+Without a running reactor, this functionality will not work
+[#teardown_monkeypatch]_.  Also, it relies on an undocumented, protected
+attribute on the ZODB.DB, so is fragile across ZODB versions.
+
+Gotchas
+-------
+
+For a certain class of jobs, you won't have to think much about using
+the twist Partial.  For instance, if you are putting a result gathered by
+work done by deferreds into the ZODB, and that's it, everything should be
+pretty simple.  However, unfortunately, you have to think a bit harder for
+other common use cases.
+
+* As already mentioned, do not use arguments that are non-persistent
+  collections (or even persistent objects without a connection) that hold
+  any persistent objects with connections.
+
+* Using persistent objects with connections but that have not been
+  committed to the database will cause problems when used (as callable
+  or argument), perhaps intermittently (if a commit happens before the
+  partial is called, it will work).  Don't do this.
+
+* Do not return values that are persistent objects tied to a connection.
+
+* If you plan on firing off another reactor call on the basis of your
+  work in the callable, realize that the work hasn't really "happened"
+  until you commit the transaction.  The partial typically handles commits
+  for you, committing if you return any result and aborting if you raise
+  an error. But if you want to send off a reactor call on the basis of a
+  successful transaction, you'll want to (a) do the work, then (b)
+  commit, then (c) send off the reactor call.  If the commit fails,
+  you'll get the standard abort and retry.
+
+* If you want to handle your own transactions, do not use the thread
+  transaction manager that you get from importing transaction.  This
+  will cause intermittent, hard-to-debug, unexpected problems.  Instead,
+  adapt any persistent object you get to
+  transaction.interfaces.ITransactionManager, and use that manager for
+  commits and aborts.
+
+=========
+Footnotes
+=========
+
+.. [#set_up] We'll actually create the state that the text describes here.
+
+    >>> import persistent
+    >>> class Demo(persistent.Persistent):
+    ...     count = 0
+    ...     def __call__(self, amount=1):
+    ...         self.count += amount
+    ...         return self.count
+    ...     def decrement(self, amount=1):
+    ...         self.count -= amount
+    ...         return self.count
+    ...
+    >>> from ZODB.tests.util import DB
+    >>> db = DB()
+    >>> conn = db.open()
+    >>> root = conn.root()
+    >>> demo = root['demo'] = Demo()
+    >>> demo2 = root['demo2'] = Demo()
+    >>> import transaction
+    >>> transaction.commit()
+
+.. [#adapters] You must have two adapter registrations: IConnection to
+    ITransactionManager, and IPersistent to IConnection.  We will also
+    register IPersistent to ITransactionManager because the adapter is
+    designed for it.
+
+    >>> from zc.twist import transactionManager, connection
+    >>> import zope.component
+    >>> zope.component.provideAdapter(transactionManager)
+    >>> zope.component.provideAdapter(connection)
+    >>> import ZODB.interfaces
+    >>> zope.component.provideAdapter(
+    ...     transactionManager, adapts=(ZODB.interfaces.IConnection,))
+
+    This quickly tests the adapters:
+
+    >>> ZODB.interfaces.IConnection(demo) is conn
+    True
+    >>> import transaction.interfaces
+    >>> transaction.interfaces.ITransactionManager(demo) is transaction.manager
+    True
+    >>> transaction.interfaces.ITransactionManager(conn) is transaction.manager
+    True
+
+.. [#conflict_error_setup] We also use this runner in the footnote below.
+
+    >>> import threading
+    >>> _main = threading.Lock()
+    >>> _thread = threading.Lock()
+    >>> def safe_release(lock):
+    ...     while not lock.locked():
+    ...         pass
+    ...     lock.release()
+    ...
+    >>> class AltDemo(persistent.Persistent):
+    ...     count = 0
+    ...     def __call__(self, amount=1):
+    ...         self.count += amount
+    ...         safe_release(_main)
+    ...         _thread.acquire()
+    ...         return self.count
+    ...
+    >>> demo = root['altdemo'] = AltDemo()
+    >>> transaction.commit()
+    >>> class Runner(object):
+    ...     def __init__(self, call):
+    ...         self.call = call
+    ...         self.thread = threading.Thread(target=self.run)
+    ...         _thread.acquire()
+    ...         _main.acquire()
+    ...         self.thread.start()
+    ...         _main.acquire()
+    ...     def run(self):
+    ...         self.result = self.call()
+    ...         assert _main.locked()
+    ...         safe_release(_main)
+    ...     def retry(self):
+    ...         assert _thread.locked()
+    ...         safe_release(_thread)
+    ...         _main.acquire()
+    ...     def resume(self, retry=True):
+    ...         if retry:
+    ...             while self.thread.isAlive():
+    ...                 self.retry()
+    ...         else:
+    ...             while self.thread.isAlive():
+    ...                 pass
+    ...         assert _thread.locked()
+    ...         assert _main.locked()
+    ...         safe_release(_thread)
+    ...         safe_release(_main)
+    ...         assert not self.thread.isAlive()
+    ...         assert not _thread.locked()
+    ...         assert not _main.locked()
+
+.. [#conflict_error_failure] Here we create five consecutive conflict errors,
+    which causes the call to give up.
+
+    >>> call = Partial(demo)
+    >>> runner = Runner(call)
+    >>> for i in range(5):
+    ...     demo.count = i
+    ...     transaction.commit()
+    ...     runner.retry()
+    ...
+    >>> runner.resume(retry=False)
+    >>> _thread.locked()
+    False
+    >>> _main.locked()
+    False
+    >>> demo.count
+    4
+    >>> call.attempt_count
+    5
+    >>> runner.thread.isAlive()
+    False
+    >>> deferred = runner.result
+
+.. [#use_original_demo] The second demo has too much thread code in it:
+    we'll use the old demo for the rest of the discussion.
+
+    >>> demo = root['demo']
+
+.. [#relies_on_twisted_reactor] We monkeypatch twisted.internet.reactor
+    (and revert it in another footnote below).
+
+    >>> import twisted.internet.reactor
+    >>> oldCallLater = twisted.internet.reactor.callLater
+    >>> import bisect
+    >>> class FauxReactor(object):
+    ...     def __init__(self):
+    ...         self.time = 0
+    ...         self.calls = []
+    ...     def callLater(self, delay, callable, *args, **kw):
+    ...         res = (delay + self.time, callable, args, kw)
+    ...         bisect.insort(self.calls, res)
+    ...         # normally we're supposed to return something but not needed
+    ...     def time_flies(self, time):
+    ...         end = self.time + time
+    ...         ct = 0
+    ...         while self.calls and self.calls[0][0] <= end:
+    ...             self.time, callable, args, kw = self.calls.pop(0)
+    ...             callable(*args, **kw) # normally this would get try...except
+    ...             ct += 1
+    ...         self.time = end
+    ...         return ct
+    ...
+    >>> faux = FauxReactor()
+    >>> twisted.internet.reactor.callLater = faux.callLater
+    >>> time_flies = faux.time_flies
+
+.. [#teardown_monkeypatch]
+
+    >>> twisted.internet.reactor.callLater = oldCallLater


Property changes on: zc.twist/trunk/src/zc/twist/README.txt
___________________________________________________________________
Name: svn:eol-style
   + native

Added: zc.twist/trunk/src/zc/twist/__init__.py
===================================================================
--- zc.twist/trunk/src/zc/twist/__init__.py	2006-08-15 20:30:24 UTC (rev 69533)
+++ zc.twist/trunk/src/zc/twist/__init__.py	2006-08-15 20:31:28 UTC (rev 69534)
@@ -0,0 +1,240 @@
+import random
+import types
+
+import ZODB.interfaces
+import ZODB.POSException
+import transaction
+import transaction.interfaces
+import persistent
+import persistent.interfaces
+
+import twisted.internet.defer
+import twisted.internet.reactor
+
+import zope.component
+import zope.interface
+
+EXPLOSIVE_ERRORS = [SystemExit, KeyboardInterrupt,
+                    ZODB.POSException.POSError]
+
+# this is currently internal, though maybe we'll expose it later
+class IDeferredReference(zope.interface.Interface):
+    def __call__(self, connection):
+        """return the actual object to be used."""
+
+    db = zope.interface.Attribute("""
+        The associated database, or None""")
+
+class DeferredReferenceToPersistent(object):
+    zope.interface.implements(IDeferredReference)
+
+    name = None
+
+    def __init__(self, obj):
+        if isinstance(obj, types.MethodType):
+            self.name = obj.__name__
+            obj = obj.im_self
+        conn = ZODB.interfaces.IConnection(obj)
+        self.db = conn.db()
+        self.id = obj._p_oid
+
+    def __call__(self, conn):
+        if conn.db().database_name != self.db.database_name:
+            conn = conn.get_connection(self.db.database_name)
+        obj = conn.get(self.id)
+        if self.name is not None:
+            obj = getattr(obj, self.name)
+        return obj
+
+def Reference(obj):
+    if isinstance(obj, types.MethodType):
+        if (persistent.interfaces.IPersistent.providedBy(obj.im_self) and
+            obj.im_self._p_jar is not None):
+            return DeferredReferenceToPersistent(obj)
+        else:
+            return obj
+    if (persistent.interfaces.IPersistent.providedBy(obj)
+        and obj._p_jar is not None):
+        return DeferredReferenceToPersistent(obj)
+    return obj
+
+def availableConnectionCount(db, version=''):
+    # we're entering into protected name land :-(  It would be nice to
+    # have APIs to get the current pool size, and available pool size in
+    # addition to the target pool size
+    try:
+        pools = db._pools
+    except AttributeError:
+        return True # TODO: log this
+    else:
+        pool = pools.get(version)
+        if pool is None:
+            return True
+        size = db.getPoolSize()
+        all = len(pool.all)
+        available = len(pool.available) + (size - all)
+        return available
+
+def get_connection(db, mvcc=True, version='', synch=True,
+                   deferred=None, backoff=None):
+    if deferred is None:
+        deferred = twisted.internet.defer.Deferred()
+    if backoff is None:
+        backoff = random.random() / 10 # max of 1/10 of a second
+    else:
+        backoff *= 2
+    # if this is taking too long (i.e., the cumulative backoff is taking
+    # about a second) then we'll just take one.  This might be a bad idea:
+    # we'll have to see in practice.  Otherwise, if the backoff isn't too
+    # long and we don't have a connection within our limit, try again
+    # later.
+    if backoff < .5 and not availableConnectionCount(db):
+        twisted.internet.reactor.callLater(
+            backoff, get_connection, db, mvcc, version, synch,
+            deferred, backoff)
+        return deferred
+    deferred.callback(
+        db.open(version=version, mvcc=mvcc,
+                transaction_manager=transaction.TransactionManager(),
+                synch=synch))
+    return deferred
+
+def sanitize(failure):
+    # failures may have some bad things in the traceback frames.  This
+    # converts everything to strings
+    state = failure.__getstate__()
+    failure.__dict__.update(state)
+    return failure
+
+class Partial(object):
+
+    attempt_count = 0
+    mvcc = True
+    version = ''
+    synch = True
+
+    def __init__(self, call, *args, **kwargs):
+        self.call = Reference(call)
+        self.args = list(Reference(a) for a in args)
+        self.kwargs = dict((k, Reference(v)) for k, v in kwargs.iteritems())
+
+    def __call__(self, *args, **kwargs):
+        self.args.extend(args)
+        self.kwargs.update(kwargs)
+        db = None
+        for src in ((self.call,), self.args, self.kwargs.itervalues()):
+            for item in src:
+                if IDeferredReference.providedBy(item) and item.db is not None:
+                    db = item.db
+                    break
+            else:
+                continue
+            break
+        else:
+            call, args, kwargs = self._resolve(None)
+            return call(*args, **kwargs)
+        self.attempt_count = 0
+        d = twisted.internet.defer.Deferred()
+        get_connection(db, self.mvcc, self.version, self.synch
+                      ).addCallback(self._call, d)
+        return d
+
+    def _resolve(self, conn):
+        if IDeferredReference.providedBy(self.call):
+            call = self.call(conn)
+        else:
+            call = self.call
+        args = []
+        for a in self.args:
+            if IDeferredReference.providedBy(a):
+                a = a(conn)
+            args.append(a)
+        kwargs = {}
+        for k, v in self.kwargs.items():
+            if IDeferredReference.providedBy(v):
+                v = v(conn)
+            kwargs[k] = v
+        return call, args, kwargs
+
+    def _call(self, conn, d):
+        self.attempt_count += 1
+        tm = transaction.interfaces.ITransactionManager(conn)
+        tm.begin() # syncs
+        try:
+            call, args, kwargs = self._resolve(conn)
+            res = call(*args, **kwargs)
+            tm.commit()
+        except ZODB.POSException.TransactionError:
+            tm.abort()
+            db = conn.db()
+            conn.close()
+            if self.attempt_count >= 5: # TODO configurable
+                res = sanitize(twisted.python.failure.Failure())
+                d.errback(res)
+            else:
+                get_connection(db).addCallback(self._call, d)
+        except EXPLOSIVE_ERRORS:
+            tm.abort()
+            conn.close()
+            res = sanitize(twisted.python.failure.Failure())
+            d.errback(res)
+            raise
+        except:
+            tm.abort()
+            conn.close()
+            res = sanitize(twisted.python.failure.Failure())
+            d.errback(res)
+        else:
+            conn.close()
+            if isinstance(res, twisted.python.failure.Failure):
+                d.errback(sanitize(res))
+            elif isinstance(res, twisted.internet.defer.Deferred):
+                res.chainDeferred(d)
+            else: # the caller must not return any persistent objects!
+                d.callback(res)
+
+# also register this for adapting from IConnection
+ at zope.component.adapter(persistent.interfaces.IPersistent)
+ at zope.interface.implementer(transaction.interfaces.ITransactionManager)
+def transactionManager(obj):
+    conn = ZODB.interfaces.IConnection(obj) # typically this will be
+    # zope.app.keyreference.persistent.connectionOfPersistent
+    try:
+        return conn.transaction_manager
+    except AttributeError:
+        return conn._txn_mgr
+        # or else we give up; who knows.  transaction_manager is the more
+        # recent spelling.
+
+# very slightly modified from
+# zope.app.keyreference.persistent.connectionOfPersistent; included to
+# reduce dependencies
+ at zope.component.adapter(persistent.interfaces.IPersistent)
+ at zope.interface.implementer(ZODB.interfaces.IConnection)
+def connection(ob):
+    """An adapter which gets a ZODB connection of a persistent object.
+
+    We are assuming the object has a parent if it has been created in
+    this transaction.
+
+    Returns None if it is impossible to get a connection.
+    """
+    cur = ob
+    while getattr(cur, '_p_jar', None) is None:
+        cur = getattr(cur, '__parent__', None)
+        if cur is None:
+            return None
+    return cur._p_jar
+
+# The Twisted Failure __getstate__, which we use in our sanitize function
+# arguably out of paranoia, does a repr of globals and locals.  If the repr
+# raises an error, they handle it gracefully.  However, if the repr has side
+# effects, they can't know.  xmlrpclib unfortunately has this problem as of
+# this writing.  This is a monkey patch to turn off this behavior, graciously
+# provided by Florent Guillaume of Nuxeo.
+# XXX see if this can be submitted somewhere as a bug/patch for xmlrpclib
+import xmlrpclib
+def xmlrpc_method_repr(self):
+    return '<xmlrpc._Method %s>' % self._Method__name
+xmlrpclib._Method.__repr__ = xmlrpc_method_repr
+del xmlrpclib


Property changes on: zc.twist/trunk/src/zc/twist/__init__.py
___________________________________________________________________
Name: svn:eol-style
   + native

Added: zc.twist/trunk/src/zc/twist/tests.py
===================================================================
--- zc.twist/trunk/src/zc/twist/tests.py	2006-08-15 20:30:24 UTC (rev 69533)
+++ zc.twist/trunk/src/zc/twist/tests.py	2006-08-15 20:31:28 UTC (rev 69534)
@@ -0,0 +1,24 @@
+import unittest
+
+from zope.testing import doctest, module
+import zope.component.testing
+
+def modSetUp(test):
+    zope.component.testing.setUp(test)
+    module.setUp(test, 'zc.twist.README')
+
+def modTearDown(test):
+    module.tearDown(test)
+    zope.component.testing.tearDown(test)
+
+def test_suite():
+    return unittest.TestSuite((
+        doctest.DocFileSuite(
+            'README.txt',
+            setUp=modSetUp, tearDown=modTearDown,
+            optionflags=doctest.INTERPRET_FOOTNOTES),
+        ))
+
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='test_suite')


Property changes on: zc.twist/trunk/src/zc/twist/tests.py
___________________________________________________________________
Name: svn:eol-style
   + native



More information about the Checkins mailing list