Merge lp:~nataliabidart/ubuntu-sso-client/run-stuff-from-mainloop into lp:ubuntu-sso-client

Proposed by Natalia Bidart
Status: Merged
Approved by: Natalia Bidart
Approved revision: 862
Merged at revision: 852
Proposed branch: lp:~nataliabidart/ubuntu-sso-client/run-stuff-from-mainloop
Merge into: lp:ubuntu-sso-client
Diff against target: 602 lines (+523/-4)
10 files modified
setup.py (+2/-0)
ubuntu_sso/logger.py (+1/-1)
ubuntu_sso/utils/__init__.py (+3/-3)
ubuntu_sso/utils/runner/__init__.py (+99/-0)
ubuntu_sso/utils/runner/glib.py (+75/-0)
ubuntu_sso/utils/runner/qt.py (+59/-0)
ubuntu_sso/utils/runner/tests/__init__.py (+17/-0)
ubuntu_sso/utils/runner/tests/test_qt.py (+93/-0)
ubuntu_sso/utils/runner/tests/test_runner.py (+81/-0)
ubuntu_sso/utils/runner/tx.py (+93/-0)
To merge this branch: bzr merge lp:~nataliabidart/ubuntu-sso-client/run-stuff-from-mainloop
Reviewer Review Type Date Requested Status
Manuel de la Peña (community) Approve
Roberto Alsina (community) Approve
Review via email: mp+89956@code.launchpad.net

Commit message

- Provide a helper to spawn programs from the main loop that is being used by the SSO Service (LP: #920949).

Description of the change

The spawnner for twisted is provided so we can have a valid test suite running inside trial, it shouldn't be used IRL.

To post a comment you must log in.
Revision history for this message
Roberto Alsina (ralsina) wrote :

+1 I like it!

review: Approve
861. By Natalia Bidart

Merged trunk in.

862. By Natalia Bidart

Handle programs that already have the EXE_EXT.

Revision history for this message
Manuel de la Peña (mandel) wrote :

Nice work, I have tried to be evil in the review and I could't!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'setup.py'
2--- setup.py 2012-02-08 02:34:29 +0000
3+++ setup.py 2012-02-08 18:36:30 +0000
4@@ -391,6 +391,8 @@
5 'ubuntu_sso.qt.ui',
6 'ubuntu_sso.utils',
7 'ubuntu_sso.utils.tests',
8+ 'ubuntu_sso.utils.runner',
9+ 'ubuntu_sso.utils.runner.tests',
10 'ubuntu_sso.utils.webclient',
11 'ubuntu_sso.utils.webclient.tests',
12 'ubuntu_sso.xdg_base_directory',
13
14=== modified file 'ubuntu_sso/logger.py'
15--- ubuntu_sso/logger.py 2012-02-06 19:57:46 +0000
16+++ ubuntu_sso/logger.py 2012-02-08 18:36:30 +0000
17@@ -35,7 +35,7 @@
18 if not os.path.exists(unicode_path(LOGFOLDER)):
19 os.makedirs(unicode_path(LOGFOLDER))
20
21-if os.environ.get('DEBUG'):
22+if os.environ.get('U1_DEBUG'):
23 LOG_LEVEL = logging.DEBUG
24 else:
25 # Only log this level and above
26
27=== modified file 'ubuntu_sso/utils/__init__.py'
28--- ubuntu_sso/utils/__init__.py 2012-02-07 14:45:29 +0000
29+++ ubuntu_sso/utils/__init__.py 2012-02-08 18:36:30 +0000
30@@ -1,8 +1,6 @@
31 # -*- coding: utf-8 -*-
32-
33-# Author: Alejandro J. Cura <alecu@canonical.com>
34 #
35-# Copyright 2010, 2011 Canonical Ltd.
36+# Copyright 2010-2012 Canonical Ltd.
37 #
38 # This program is free software: you can redistribute it and/or modify it
39 # under the terms of the GNU General Public License version 3, as published
40@@ -26,6 +24,8 @@
41 from urlparse import urlparse
42
43 from ubuntu_sso.logger import setup_logging
44+
45+
46 logger = setup_logging("ubuntu_sso.utils")
47
48
49
50=== added directory 'ubuntu_sso/utils/runner'
51=== added file 'ubuntu_sso/utils/runner/__init__.py'
52--- ubuntu_sso/utils/runner/__init__.py 1970-01-01 00:00:00 +0000
53+++ ubuntu_sso/utils/runner/__init__.py 2012-02-08 18:36:30 +0000
54@@ -0,0 +1,99 @@
55+# -*- coding: utf-8 -*-
56+#
57+# Copyright 2012 Canonical Ltd.
58+#
59+# This program is free software: you can redistribute it and/or modify it
60+# under the terms of the GNU General Public License version 3, as published
61+# by the Free Software Foundation.
62+#
63+# This program is distributed in the hope that it will be useful, but
64+# WITHOUT ANY WARRANTY; without even the implied warranties of
65+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
66+# PURPOSE. See the GNU General Public License for more details.
67+#
68+# You should have received a copy of the GNU General Public License along
69+# with this program. If not, see <http://www.gnu.org/licenses/>.
70+
71+"""Utility to spawn another program from a mainloop."""
72+
73+import sys
74+
75+from twisted.internet import defer
76+
77+from ubuntu_sso.logger import setup_logging
78+
79+
80+logger = setup_logging("ubuntu_sso.utils.runner")
81+
82+
83+class SpawnError(Exception):
84+ """Generic error when spawning processes."""
85+
86+
87+class FailedToStartError(SpawnError):
88+ """The process could not be spawned."""
89+
90+
91+def is_qt4_main_loop_installed():
92+ """Check if the Qt4 main loop is installed."""
93+ result = False
94+ try:
95+ from PyQt4.QtCore import QCoreApplication
96+ result = QCoreApplication.instance() is not None
97+ except ImportError:
98+ pass
99+
100+ return result
101+
102+
103+def is_twisted_reactor_installed():
104+ """Check if the Twisted reactor is installed."""
105+ result = 'twisted.internet.reactor' in sys.modules
106+ return result
107+
108+
109+def spawn_program(args):
110+ """Spawn the program specified by 'args'.
111+
112+ - 'args' should be a sequence of program arguments, the program to execute
113+ is normally the first item in 'args'.
114+
115+ Return a deferred that will be fired when the execution of 'args' finishes,
116+ passing as the deferred result code the program return code.
117+
118+ On error, the returned deferred will be errback'd.
119+
120+ """
121+ logger.debug('spawn_program: requested to spawn %r.', repr(args))
122+ d = defer.Deferred()
123+
124+ if is_twisted_reactor_installed():
125+ from ubuntu_sso.utils.runner import tx
126+ source = tx
127+ elif 'PyQt4' in sys.modules and is_qt4_main_loop_installed():
128+ from ubuntu_sso.utils.runner import qt
129+ source = qt
130+ else:
131+ from ubuntu_sso.utils.runner import glib
132+ source = glib
133+
134+ logger.debug('Spawn source is %r.', source)
135+
136+ def reply_handler(status):
137+ """Callback the returned deferred."""
138+ logger.debug('The program %r finished with status %r.', args, status)
139+ d.callback(status)
140+
141+ def error_handler(msg, failed_to_start=False):
142+ """Errback the returned deferred."""
143+ if failed_to_start:
144+ msg = 'Process %r could not be started (%r).' % (args, msg)
145+ exc = FailedToStartError(msg)
146+ else:
147+ exc = SpawnError('Unspecified error (%r).' % msg)
148+
149+ logger.error('The program %r could not be run: %r', args, exc)
150+ d.errback(exc)
151+
152+ source.spawn_program(args, reply_handler, error_handler)
153+ return d
154
155=== added file 'ubuntu_sso/utils/runner/glib.py'
156--- ubuntu_sso/utils/runner/glib.py 1970-01-01 00:00:00 +0000
157+++ ubuntu_sso/utils/runner/glib.py 2012-02-08 18:36:30 +0000
158@@ -0,0 +1,75 @@
159+# -*- coding: utf-8 -*-
160+#
161+# Copyright 2012 Canonical Ltd.
162+#
163+# This program is free software: you can redistribute it and/or modify it
164+# under the terms of the GNU General Public License version 3, as published
165+# by the Free Software Foundation.
166+#
167+# This program is distributed in the hope that it will be useful, but
168+# WITHOUT ANY WARRANTY; without even the implied warranties of
169+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
170+# PURPOSE. See the GNU General Public License for more details.
171+#
172+# You should have received a copy of the GNU General Public License along
173+# with this program. If not, see <http://www.gnu.org/licenses/>.
174+
175+"""Utility to spawn another program from a GLib mainloop."""
176+
177+# pylint: disable=E0611,F0401
178+try:
179+ from shlex import quote
180+except ImportError:
181+ from pipes import quote
182+
183+from gi.repository import GLib
184+# pylint: enable=E0611,F0401
185+
186+from ubuntu_sso.logger import setup_logging
187+
188+logger = setup_logging("ubuntu_sso.utils.runner.glib")
189+
190+
191+NO_SUCH_FILE_OR_DIR = '(No such file or directory)'
192+
193+
194+def spawn_program(args, reply_handler, error_handler):
195+ """Spawn the program specified by 'args' using the GLib mainloop.
196+
197+ When the program finishes, 'reply_handler' will be called with a single
198+ argument that will be the porgram status code.
199+
200+ If there is an error, error_handler will be called with an instance of
201+ SpawnError.
202+
203+ """
204+
205+ def child_watch(pid, status):
206+ """Handle child termination."""
207+ # pylint: disable=E1103
208+ GLib.spawn_close_pid(pid)
209+ # pylint: enable=E1103
210+ reply_handler(status)
211+
212+ def handle_error(gerror):
213+ """Handle error when spawning the process."""
214+ failed_to_start = NO_SUCH_FILE_OR_DIR in gerror.message
215+ msg = 'GError is: code %r, message %r' % (gerror.code, gerror.message)
216+ error_handler(msg=msg, failed_to_start=failed_to_start)
217+
218+ # escape arguments
219+ args = [quote(a) for a in args]
220+
221+ flags = GLib.SpawnFlags.DO_NOT_REAP_CHILD | \
222+ GLib.SpawnFlags.SEARCH_PATH | \
223+ GLib.SpawnFlags.STDOUT_TO_DEV_NULL | \
224+ GLib.SpawnFlags.STDERR_TO_DEV_NULL
225+ pid = None
226+ try:
227+ pid, _, _, _ = GLib.spawn_async(argv=args, flags=flags)
228+ except GLib.GError, e:
229+ handle_error(e)
230+ else:
231+ logger.debug('Spawning the program %r with the glib mainloop '
232+ '(returned pid is %r).', args, pid)
233+ GLib.child_watch_add(pid, child_watch)
234
235=== added file 'ubuntu_sso/utils/runner/qt.py'
236--- ubuntu_sso/utils/runner/qt.py 1970-01-01 00:00:00 +0000
237+++ ubuntu_sso/utils/runner/qt.py 2012-02-08 18:36:30 +0000
238@@ -0,0 +1,59 @@
239+# -*- coding: utf-8 -*-
240+#
241+# Copyright 2012 Canonical Ltd.
242+#
243+# This program is free software: you can redistribute it and/or modify it
244+# under the terms of the GNU General Public License version 3, as published
245+# by the Free Software Foundation.
246+#
247+# This program is distributed in the hope that it will be useful, but
248+# WITHOUT ANY WARRANTY; without even the implied warranties of
249+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
250+# PURPOSE. See the GNU General Public License for more details.
251+#
252+# You should have received a copy of the GNU General Public License along
253+# with this program. If not, see <http://www.gnu.org/licenses/>.
254+
255+"""Utility to spawn another program from a plain Qt mainloop."""
256+
257+from PyQt4 import QtCore
258+
259+from ubuntu_sso.logger import setup_logging
260+
261+
262+logger = setup_logging("ubuntu_sso.utils.runner.qt")
263+
264+
265+def spawn_program(args, reply_handler, error_handler):
266+ """Spawn the program specified by 'args' using the Qt mainloop.
267+
268+ When the program finishes, 'reply_handler' will be called with a single
269+ argument that will be the porgram status code.
270+
271+ If there is an error, error_handler will be called with an instance of
272+ SpawnError.
273+
274+ """
275+
276+ process = QtCore.QProcess()
277+
278+ def print_pid():
279+ """Add a debug log message."""
280+ pid = process.pid()
281+ logger.debug('Spawning the program %r with the qt mainloop '
282+ '(returned pid is %r).', args, pid)
283+
284+ def handle_error(process_error):
285+ """Handle error when spawning the process."""
286+ failed_to_start = process_error == QtCore.QProcess.FailedToStart
287+ msg = 'ProcessError is %r' % process_error
288+ error_handler(msg=msg, failed_to_start=failed_to_start)
289+
290+ process.started.connect(print_pid)
291+ process.finished.connect(reply_handler)
292+ process.error.connect(handle_error)
293+
294+ args = list(args)
295+ program = args[0]
296+ argv = args[1:]
297+ process.start(program, argv)
298
299=== added directory 'ubuntu_sso/utils/runner/tests'
300=== added file 'ubuntu_sso/utils/runner/tests/__init__.py'
301--- ubuntu_sso/utils/runner/tests/__init__.py 1970-01-01 00:00:00 +0000
302+++ ubuntu_sso/utils/runner/tests/__init__.py 2012-02-08 18:36:30 +0000
303@@ -0,0 +1,17 @@
304+# -*- coding: utf-8 -*-
305+#
306+# Copyright 2012 Canonical Ltd.
307+#
308+# This program is free software: you can redistribute it and/or modify it
309+# under the terms of the GNU General Public License version 3, as published
310+# by the Free Software Foundation.
311+#
312+# This program is distributed in the hope that it will be useful, but
313+# WITHOUT ANY WARRANTY; without even the implied warranties of
314+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
315+# PURPOSE. See the GNU General Public License for more details.
316+#
317+# You should have received a copy of the GNU General Public License along
318+# with this program. If not, see <http://www.gnu.org/licenses/>.
319+
320+"""Tests for the program runner."""
321
322=== added file 'ubuntu_sso/utils/runner/tests/test_qt.py'
323--- ubuntu_sso/utils/runner/tests/test_qt.py 1970-01-01 00:00:00 +0000
324+++ ubuntu_sso/utils/runner/tests/test_qt.py 2012-02-08 18:36:30 +0000
325@@ -0,0 +1,93 @@
326+# -*- coding: utf-8 -*-
327+#
328+# Copyright 2012 Canonical Ltd.
329+#
330+# This program is free software: you can redistribute it and/or modify it
331+# under the terms of the GNU General Public License version 3, as published
332+# by the Free Software Foundation.
333+#
334+# This program is distributed in the hope that it will be useful, but
335+# WITHOUT ANY WARRANTY; without even the implied warranties of
336+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
337+# PURPOSE. See the GNU General Public License for more details.
338+#
339+# You should have received a copy of the GNU General Public License along
340+# with this program. If not, see <http://www.gnu.org/licenses/>.
341+
342+"""Tests for the qt runner helper module."""
343+
344+import subprocess
345+
346+from PyQt4 import QtCore
347+from twisted.internet import defer
348+
349+from ubuntu_sso.utils import runner
350+from ubuntu_sso.utils.runner.tests.test_runner import SpawnProgramTestCase
351+
352+
353+class FakedSignal(object):
354+ """Fake a Qt signal."""
355+
356+ def __init__(self, name):
357+ self.name = name
358+ self._handlers = []
359+
360+ def connect(self, handler):
361+ """Connect 'handler' with this signal."""
362+ self._handlers.append(handler)
363+
364+ def emit(self, *args, **kwargs):
365+ """Emit this signal."""
366+ for handler in self._handlers:
367+ handler(*args, **kwargs)
368+
369+
370+class FakedProcess(object):
371+ """Fake a Qt Process."""
372+
373+ _pid = 123456
374+ _error = None
375+ _status_code = 0
376+
377+ FailedToStart = 0
378+
379+ def __init__(self):
380+ self.pid = lambda: self._pid
381+ self.started = FakedSignal('started')
382+ self.finished = FakedSignal('finished')
383+ self.error = FakedSignal('error')
384+
385+ def start(self, program, arguments):
386+ """Start this process."""
387+ if self._error is None:
388+ self.started.emit()
389+
390+ args = (program,) + tuple(arguments)
391+ try:
392+ subprocess.call(args)
393+ except OSError, e:
394+ if e.errno == 2:
395+ self.error.emit(self.FailedToStart)
396+ else:
397+ self.error.emit(e)
398+ except Exception, e:
399+ self.error.emit(e)
400+ else:
401+ self.finished.emit(self._status_code)
402+ else:
403+ self.error.emit(self._error)
404+
405+
406+class QtSpawnProgramTestCase(SpawnProgramTestCase):
407+ """The test suite for the spawn_program method (using Qt)."""
408+
409+ use_reactor = False
410+
411+ @defer.inlineCallbacks
412+ def setUp(self):
413+ yield super(QtSpawnProgramTestCase, self).setUp()
414+ # Since we can't mix plan qt runner and the qt4reactor, we patch
415+ # QProcess and fake the conditions so the qt runner is chosen
416+ self.patch(QtCore, 'QProcess', FakedProcess)
417+ self.patch(runner, 'is_twisted_reactor_installed', lambda: False)
418+ self.patch(runner, 'is_qt4_main_loop_installed', lambda: True)
419
420=== added file 'ubuntu_sso/utils/runner/tests/test_runner.py'
421--- ubuntu_sso/utils/runner/tests/test_runner.py 1970-01-01 00:00:00 +0000
422+++ ubuntu_sso/utils/runner/tests/test_runner.py 2012-02-08 18:36:30 +0000
423@@ -0,0 +1,81 @@
424+# -*- coding: utf-8 -*-
425+#
426+# Copyright 2012 Canonical Ltd.
427+#
428+# This program is free software: you can redistribute it and/or modify it
429+# under the terms of the GNU General Public License version 3, as published
430+# by the Free Software Foundation.
431+#
432+# This program is distributed in the hope that it will be useful, but
433+# WITHOUT ANY WARRANTY; without even the implied warranties of
434+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
435+# PURPOSE. See the GNU General Public License for more details.
436+#
437+# You should have received a copy of the GNU General Public License along
438+# with this program. If not, see <http://www.gnu.org/licenses/>.
439+
440+"""Tests for the runner helper module."""
441+
442+import os
443+
444+from twisted.internet import defer
445+
446+from ubuntu_sso.tests import TestCase
447+from ubuntu_sso.utils import runner
448+
449+
450+TEST_ME_DIR = 'test-me'
451+
452+
453+class SpawnProgramTestCase(TestCase):
454+ """The test suite for the spawn_program method."""
455+
456+ timeout = 3
457+ args = ('python', '-c', 'import os; os.system("mkdir %s")' % TEST_ME_DIR)
458+
459+ @defer.inlineCallbacks
460+ def setUp(self):
461+ yield super(SpawnProgramTestCase, self).setUp()
462+ assert not os.path.exists(TEST_ME_DIR)
463+ self.addCleanup(lambda: os.path.exists(TEST_ME_DIR) and
464+ os.rmdir(TEST_ME_DIR))
465+
466+ def spawn_fn(self, args):
467+ """The target function to test."""
468+ return runner.spawn_program(args)
469+
470+ def assert_command_was_run(self):
471+ """The spawnned commnad was correctly run."""
472+ self.assertTrue(os.path.exists(TEST_ME_DIR))
473+ self.assertTrue(os.path.isdir(TEST_ME_DIR))
474+
475+ @defer.inlineCallbacks
476+ def test_program_is_spawned(self):
477+ """The program is actually spawned."""
478+ yield self.spawn_fn(self.args)
479+ self.assert_command_was_run()
480+
481+ @defer.inlineCallbacks
482+ def test_program_is_spawned_returned_code_non_zero(self):
483+ """The program is actually spawned."""
484+ status = yield self.spawn_fn(self.args)
485+ self.assertEqual(status, 0)
486+
487+ @defer.inlineCallbacks
488+ def test_failed_to_start(self):
489+ """FailedToStartError is raised if the program does not start."""
490+ no_such_program = './myexecutablethatdoesnotexist'
491+ assert not os.path.exists(no_such_program)
492+
493+ d = self.spawn_fn((no_such_program,))
494+ exc = yield self.assertFailure(d, runner.FailedToStartError)
495+
496+ self.assertIn(no_such_program, exc.message)
497+
498+ @defer.inlineCallbacks
499+ def test_other_error(self):
500+ """SpawnError is raised if the program does not start."""
501+ d = self.spawn_fn((None,))
502+ exc = yield self.assertFailure(d, runner.SpawnError)
503+
504+ self.assertIn('Unspecified error', exc.message)
505
506=== added file 'ubuntu_sso/utils/runner/tx.py'
507--- ubuntu_sso/utils/runner/tx.py 1970-01-01 00:00:00 +0000
508+++ ubuntu_sso/utils/runner/tx.py 2012-02-08 18:36:30 +0000
509@@ -0,0 +1,93 @@
510+# -*- coding: utf-8 -*-
511+#
512+# Copyright 2012 Canonical Ltd.
513+#
514+# This program is free software: you can redistribute it and/or modify it
515+# under the terms of the GNU General Public License version 3, as published
516+# by the Free Software Foundation.
517+#
518+# This program is distributed in the hope that it will be useful, but
519+# WITHOUT ANY WARRANTY; without even the implied warranties of
520+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
521+# PURPOSE. See the GNU General Public License for more details.
522+#
523+# You should have received a copy of the GNU General Public License along
524+# with this program. If not, see <http://www.gnu.org/licenses/>.
525+
526+"""Utility to spawn another program from a mainloop."""
527+
528+import os
529+import sys
530+
531+from twisted.internet import utils
532+
533+from ubuntu_sso.logger import setup_logging
534+
535+
536+logger = setup_logging("ubuntu_sso.utils.runner.tx")
537+
538+NO_SUCH_FILE_OR_DIR = 'OSError: [Errno 2] No such file or directory'
539+
540+
541+EXE_EXT = ''
542+if sys.platform == 'win32':
543+ EXE_EXT = '.exe'
544+
545+
546+def spawn_program(args, reply_handler, error_handler):
547+ """Spawn the program specified by 'args' using the twisted reactor.
548+
549+ When the program finishes, 'reply_handler' will be called with a single
550+ argument that will be the porgram status code.
551+
552+ If there is an error, error_handler will be called with an instance of
553+ SpawnError.
554+
555+ """
556+
557+ def child_watch((stdout, stderr, exit_code)):
558+ """Handle child termination."""
559+ if stdout:
560+ logger.debug('Returned stdout is (exit code was %r): %r',
561+ exit_code, stdout)
562+ if stderr:
563+ logger.warning('Returned stderr is (exit code was %r): %r',
564+ exit_code, stderr)
565+
566+ if OSError.__name__ in stderr:
567+ failed_to_start = NO_SUCH_FILE_OR_DIR in stderr
568+ error_handler(msg=stderr, failed_to_start=failed_to_start)
569+ else:
570+ reply_handler(exit_code)
571+
572+ def handle_error(failure):
573+ """Handle error when spawning the process."""
574+ error_handler(msg=failure.getErrorMessage())
575+
576+ args = list(args)
577+ program = args[0]
578+ argv = args[1:]
579+
580+ if program and not os.access(program, os.X_OK):
581+ # handle searching the executable in the PATH, since
582+ # twisted will not solve that for us :-/
583+ paths = os.environ['PATH'].split(os.pathsep)
584+ for path in paths:
585+ target = os.path.join(path, program)
586+ if not target.endswith(EXE_EXT):
587+ target += EXE_EXT
588+ if os.access(target, os.X_OK):
589+ program = target
590+ break
591+
592+ try:
593+ d = utils.getProcessOutputAndValue(program, argv, env=os.environ)
594+ except OSError, e:
595+ error_handler(msg=e, failed_to_start=True)
596+ except Exception, e:
597+ error_handler(msg=e, failed_to_start=False)
598+ else:
599+ logger.debug('Spawning the program %r with the twisted reactor '
600+ '(returned deferred is %r).', repr(args), d)
601+ d.addCallback(child_watch)
602+ d.addErrback(handle_error)

Subscribers

People subscribed via source and target branches