On Tue, Mar 16, 2010 at 4:58 PM, Gavin Panella
<email address hidden> wrote:
>> On Mon, Mar 15, 2010 at 1:30 PM, Gavin Panella
>> <email address hidden> wrote:
>> > Latest incremental diff, r10440..
>> >
>> > === modified file 'lib/lp/services/job/runner.py'
>> > --- lib/lp/services/job/runner.py 2010-02-25 12:07:15 +0000
>> > +++ lib/lp/services/job/runner.py 2010-03-15 13:26:40 +0000
>> > @@ -401,9 +401,10 @@
>> > class JobCronScript(LaunchpadCronScript):
>> > """Base class for scripts that run jobs."""
>> >
>> > - def __init__(self, runner_class=JobRunner):
>> > + def __init__(self, runner_class=JobRunner, test_args=None):
>> > self.dbuser = getattr(config, self.config_name).dbuser
>> > - super(JobCronScript, self).__init__(self.config_name, self.dbuser)
>> > + super(JobCronScript, self).__init__(
>> > + self.config_name, self.dbuser, test_args)
>> > self.runner_class = runner_class
>> >
>> > def main(self):
>> >
>>
>> What's the motivation for this change?
>
> It makes testing easier. I'll explain more below.
>
>>
>> > === modified file 'lib/lp/services/job/tests/test_runner.py'
>> > --- lib/lp/services/job/tests/test_runner.py 2010-02-24 19:52:01 +0000
>> > +++ lib/lp/services/job/tests/test_runner.py 2010-03-15 13:26:40 +0000
>> > @@ -11,23 +11,24 @@
>> > from unittest import TestLoader
>> >
>> > import transaction
>> > -from canonical.testing import LaunchpadZopelessLayer
>> > +
>> > from zope.component import getUtility
>> > from zope.error.interfaces import IErrorReportingUtility
>> > from zope.interface import implements
>> >
>> > +from canonical.launchpad.webapp import errorlog
>> > +from canonical.launchpad.webapp.interfaces import (
>> > + DEFAULT_FLAVOR, IStoreSelector, MAIN_STORE)
>> > +from canonical.testing import LaunchpadZopelessLayer
>> > +
>> > from lp.code.interfaces.branchmergeproposal import (
>> > - IUpdatePreviewDiffJobSource,)
>> > -from lp.testing.mail_helpers import pop_notifications
>> > -from lp.services.job.runner import (
>> > - JobCronScript, JobRunner, BaseRunnableJob, TwistedJobRunner
>> > -)
>> > + IUpdatePreviewDiffJobSource)
>> > from lp.services.job.interfaces.job import JobStatus, IRunnableJob
>> > from lp.services.job.model.job import Job
>> > -from lp.testing import TestCaseWithFactory
>> > -from canonical.launchpad.webapp import errorlog
>> > -from canonical.launchpad.webapp.interfaces import (
>> > - IStoreSelector, MAIN_STORE, DEFAULT_FLAVOR)
>> > +from lp.services.job.runner import (
>> > + BaseRunnableJob, JobCronScript, JobRunner, TwistedJobRunner)
>> > +from lp.testing import TestCaseWithFactory, ZopeTestInSubProcess
>> > +from lp.testing.mail_helpers import pop_notifications
>> >
>> >
>> > class NullJob(BaseRunnableJob):
>> > @@ -300,7 +301,7 @@
>> > self.entries.append(input)
>> >
>> >
>> > -class TestTwistedJobRunner(TestCaseWithFactory):
>> > +class TestTwistedJobRunner(ZopeTestInSubProcess, TestCaseWithFactory):
>> >
>> > layer = LaunchpadZopelessLayer
>> >
>> > @@ -325,7 +326,7 @@
>> > self.assertIn('Job ran too long.', oops.value)
>> >
>> >
>> > -class TestJobCronScript(TestCaseWithFactory):
>> > +class TestJobCronScript(ZopeTestInSubProcess, TestCaseWithFactory):
>> >
>> > layer = LaunchpadZopelessLayer
>> >
>> > @@ -351,7 +352,8 @@
>> > source_interface = IUpdatePreviewDiffJobSource
>> >
>> > def __init__(self):
>> > - super(JobCronScriptSubclass, self).__init__(DummyRunner)
>> > + super(JobCronScriptSubclass, self).__init__(
>> > + DummyRunner, test_args=[])
>>
>> How's this related to the Zope isolation change?
>
> It made this code easier to test. Without it, I can only test it (afaik)
> like so:
>
> bin/test test_runner
>
> I cannot, for example, do:
>
> bin/test -t 'test_(runner|checkwatches)|externalbugtracker.txt'
>
> because sys.argv contains stuff that LaunchpadScript tries to digest. I
> have to override that, and that's what test_args does.
>
OK, makes sense.
>
>> > self.logger = ListLogger()
>> >
>> > old_errorlog = errorlog.globalErrorUtility
>> >
>> > === modified file 'lib/lp/testing/__init__.py'
>> > --- lib/lp/testing/__init__.py 2010-03-01 03:06:02 +0000
>> > +++ lib/lp/testing/__init__.py 2010-03-15 13:26:40 +0000
>> > @@ -29,6 +29,7 @@
>> > 'validate_mock_class',
>> > 'WindmillTestCase',
>> > 'with_anonymous_login',
>> > + 'ZopeTestInSubProcess',
>> > ]
>> >
>> > import copy
>> > @@ -38,6 +39,8 @@
>> > from pprint import pformat
>> > import shutil
>> > import subprocess
>> > +import subunit
>> > +import sys
>> > import tempfile
>> > import time
>> >
>> > @@ -62,6 +65,7 @@
>> > from zope.interface.verify import verifyClass, verifyObject
>> > from zope.security.proxy import (
>> > isinstance as zope_isinstance, removeSecurityProxy)
>> > +from zope.testing.testrunner.runner import TestResult as ZopeTestResult
>> >
>> > from canonical.launchpad.webapp import errorlog
>> > from canonical.config import config
>> > @@ -586,6 +590,57 @@
>> > self.client.open(url=u'http://launchpad.dev:8085')
>> >
>> >
>> > +class ZopeTestInSubProcess:
>> > + """Run tests in a sub-process, respecting Zope idiosyncrasies.
>> > +
>> > + Use this as a mixin with an interesting `TestCase` to isolate
>> > + tests with side-effects. Each and every test *method* in the test
>> > + case is run in a new, forked, sub-process. This will slow down
>> > + your tests, so use it sparingly. However, when you need to, for
>> > + example, start the Twisted reactor (which cannot currently be
>> > + safely stopped and restarted in process) it is invaluable.
>> > +
>>
>> For the record, vast swathes of code have been written for Twisted that don't
>> require testing starting the reactor. Generally, starting and stopping the
>> reactor in application code is a bad pattern.
>
> In the case of the job runner, it's because Twisted is being shoe-horned
> into an API that was designed before Twisted was introduced. In
> checkwatches, it's like that because it's a transitionary API, almost a
> learning point. Also, our scripts are expected to inherit from
> LaunchpadScript (or, at least, that's the legacy we're working with),
> not something suitable for twistd.
>
> Is it such a bad pattern to start and stop the reactor? Other than it
> doesn't work reliably. If it did work perfectly, what would be the
> problem?
>
I don't know. I'll have to think about it.
>>
>> > + This is basically a reimplementation of subunit's
>> > + `IsolatedTestCase` or `IsolatedTestSuite`, but adjusted to work
>> > + with Zope. In particular, Zope's TestResult object is responsible
>> > + for calling testSetUp() and testTearDown() on the selected layer.
>> > + """
>> > +
>> > + def run(self, result):
>> > + assert isinstance(result, ZopeTestResult), (
>> > + "result must be a Zope result object, not %r." % (result,))
>>
>> Why? Please add a comment explaining the case.
>
> It's because it does super(ZopeTestResult, result) later on. I could
> change it to not care, then later do:
>
> if isinstance(result, ZopeTestResult):
> # Skip all the Zope-specific result stuff
> # by passing a super() of the result.
> result = super(ZopeTestResult, result)
>
> But, for non-Zope test results, this could cause confusion (duplicated
> stdout/stderr reporting, for example).
>
> For example, the Zope test result object is responsible for calling
> testSetUp() and testTearDown(), and for handling post-mortem
> debugging. If another test result object did something similar, it would
> make no sense to do it in the parent process.
>
Fair enough. Please add a comment to the isinstance check pointing to
the super() call below.
>>
>> > + pread, pwrite = os.pipe()
>> > + pid = os.fork()
>> > + if pid == 0:
>> > + # Child.
>> > + os.close(pread)
>> > + fdwrite = os.fdopen(pwrite, 'w', 1)
>> > + # Send results to both the Zope result object (so that
>> > + # layer setup and teardown are done properly, etc.) and to
>> > + # the subunit stream client so that the parent process can
>> > + # obtain the result.
>> > + result = testtools.MultiTestResult(
>> > + result, subunit.TestProtocolClient(fdwrite))
>> > + super(ZopeTestInSubProcess, self).run(result)
>> > + fdwrite.flush()
>> > + sys.stdout.flush()
>> > + sys.stderr.flush()
>> > + # Exit hard.
>> > + os._exit(0)
>>
>> Personally, I don't need a comment to explain what os._exit(0) does. It's more
>> interesting to know why we want to exit hard.
>
> I copied this from subunit. I guess it's to avoid running onexit
> handlers, but perhaps there are other reasons. Ah, maybe SystemExit is
> caught and just logged as a test failure.
>
Thanks.
>>
>> > + else:
>> > + # Parent.
>> > + os.close(pwrite)
>> > + fdread = os.fdopen(pread, 'rU')
>> > + # Accept the result from the child process. Skip all the
>> > + # Zope-specific result stuff by passing a super() of the
>> > + # result.
>>
>> This comment would be more helpful if it explained why we want to skip the
>> zope-specific result stuff.
>
> Done.
>
Thanks.
>>
>> > + result = super(ZopeTestResult, result)
>> > + protocol = subunit.TestProtocolServer(result)
>> > + protocol.readFrom(fdread)
>> > + fdread.close()
>> > + os.waitpid(pid, 0)
>>
>> Thanks for persevering. Not long to go now :)
>
> Thanks for the review :)
>
On Tue, Mar 16, 2010 at 4:58 PM, Gavin Panella services/ job/runner. py' services/ job/runner. py 2010-02-25 12:07:15 +0000 services/ job/runner. py 2010-03-15 13:26:40 +0000 LaunchpadCronSc ript): class=JobRunner ): class=JobRunner , test_args=None): name).dbuser ript, self)._ _init__ (self.config_ name, self.dbuser) ript, self).__init__(
<email address hidden> wrote:
>> On Mon, Mar 15, 2010 at 1:30 PM, Gavin Panella
>> <email address hidden> wrote:
>> > Latest incremental diff, r10440..
>> >
>> > === modified file 'lib/lp/
>> > --- lib/lp/
>> > +++ lib/lp/
>> > @@ -401,9 +401,10 @@
>> > class JobCronScript(
>> > """Base class for scripts that run jobs."""
>> >
>> > - def __init__(self, runner_
>> > + def __init__(self, runner_
>> > self.dbuser = getattr(config, self.config_
>> > - super(JobCronSc
>> > + super(JobCronSc
>> > + self.config_name, self.dbuser, test_args)
>> > self.runner_class = runner_class
>> >
>> > def main(self):
>> >
>>
>> What's the motivation for this change?
>
> It makes testing easier. I'll explain more below.
>
>> services/ job/tests/ test_runner. py' services/ job/tests/ test_runner. py 2010-02-24 19:52:01 +0000 services/ job/tests/ test_runner. py 2010-03-15 13:26:40 +0000 ssLayer interfaces import IErrorReporting Utility launchpad. webapp import errorlog launchpad. webapp. interfaces import ( ssLayer interfaces. branchmergeprop osal import ( iffJobSource, ) mail_helpers import pop_notifications job.runner import ( iffJobSource) job.interfaces. job import JobStatus, IRunnableJob job.model. job import Job launchpad. webapp import errorlog launchpad. webapp. interfaces import ( job.runner import ( tory, ZopeTestInSubPr ocess mail_helpers import pop_notifications BaseRunnableJob ): append( input) unner(TestCaseW ithFactory) : unner(ZopeTestI nSubProcess, TestCaseWithFac tory): ssLayer pt(TestCaseWith Factory) : pt(ZopeTestInSu bProcess, TestCaseWithFac tory): ssLayer iffJobSource riptSubclass, self)._ _init__ (DummyRunner) riptSubclass, self).__init__( runner| checkwatches) |externalbugtra cker.txt'
>> > === modified file 'lib/lp/
>> > --- lib/lp/
>> > +++ lib/lp/
>> > @@ -11,23 +11,24 @@
>> > from unittest import TestLoader
>> >
>> > import transaction
>> > -from canonical.testing import LaunchpadZopele
>> > +
>> > from zope.component import getUtility
>> > from zope.error.
>> > from zope.interface import implements
>> >
>> > +from canonical.
>> > +from canonical.
>> > + DEFAULT_FLAVOR, IStoreSelector, MAIN_STORE)
>> > +from canonical.testing import LaunchpadZopele
>> > +
>> > from lp.code.
>> > - IUpdatePreviewD
>> > -from lp.testing.
>> > -from lp.services.
>> > - JobCronScript, JobRunner, BaseRunnableJob, TwistedJobRunner
>> > -)
>> > + IUpdatePreviewD
>> > from lp.services.
>> > from lp.services.
>> > -from lp.testing import TestCaseWithFactory
>> > -from canonical.
>> > -from canonical.
>> > - IStoreSelector, MAIN_STORE, DEFAULT_FLAVOR)
>> > +from lp.services.
>> > + BaseRunnableJob, JobCronScript, JobRunner, TwistedJobRunner)
>> > +from lp.testing import TestCaseWithFac
>> > +from lp.testing.
>> >
>> >
>> > class NullJob(
>> > @@ -300,7 +301,7 @@
>> > self.entries.
>> >
>> >
>> > -class TestTwistedJobR
>> > +class TestTwistedJobR
>> >
>> > layer = LaunchpadZopele
>> >
>> > @@ -325,7 +326,7 @@
>> > self.assertIn('Job ran too long.', oops.value)
>> >
>> >
>> > -class TestJobCronScri
>> > +class TestJobCronScri
>> >
>> > layer = LaunchpadZopele
>> >
>> > @@ -351,7 +352,8 @@
>> > source_interface = IUpdatePreviewD
>> >
>> > def __init__(self):
>> > - super(JobCronSc
>> > + super(JobCronSc
>> > + DummyRunner, test_args=[])
>>
>> How's this related to the Zope isolation change?
>
> It made this code easier to test. Without it, I can only test it (afaik)
> like so:
>
> bin/test test_runner
>
> I cannot, for example, do:
>
> bin/test -t 'test_(
>
> because sys.argv contains stuff that LaunchpadScript tries to digest. I
> have to override that, and that's what test_args does.
>
OK, makes sense.
> globalErrorUtil ity testing/ __init_ _.py' testing/ __init_ _.py 2010-03-01 03:06:02 +0000 testing/ __init_ _.py 2010-03-15 13:26:40 +0000 mock_class' , _login' , rocess' , verify import verifyClass, verifyObject roxy) testrunner. runner import TestResult as ZopeTestResult launchpad. webapp import errorlog open(url= u'http:// launchpad. dev:8085') ocess:
>> > self.logger = ListLogger()
>> >
>> > old_errorlog = errorlog.
>> >
>> > === modified file 'lib/lp/
>> > --- lib/lp/
>> > +++ lib/lp/
>> > @@ -29,6 +29,7 @@
>> > 'validate_
>> > 'WindmillTestCase',
>> > 'with_anonymous
>> > + 'ZopeTestInSubP
>> > ]
>> >
>> > import copy
>> > @@ -38,6 +39,8 @@
>> > from pprint import pformat
>> > import shutil
>> > import subprocess
>> > +import subunit
>> > +import sys
>> > import tempfile
>> > import time
>> >
>> > @@ -62,6 +65,7 @@
>> > from zope.interface.
>> > from zope.security.proxy import (
>> > isinstance as zope_isinstance, removeSecurityP
>> > +from zope.testing.
>> >
>> > from canonical.
>> > from canonical.config import config
>> > @@ -586,6 +590,57 @@
>> > self.client.
>> >
>> >
>> > +class ZopeTestInSubPr
>> > + """Run tests in a sub-process, respecting Zope idiosyncrasies.
>> > +
>> > + Use this as a mixin with an interesting `TestCase` to isolate
>> > + tests with side-effects. Each and every test *method* in the test
>> > + case is run in a new, forked, sub-process. This will slow down
>> > + your tests, so use it sparingly. However, when you need to, for
>> > + example, start the Twisted reactor (which cannot currently be
>> > + safely stopped and restarted in process) it is invaluable.
>> > +
>>
>> For the record, vast swathes of code have been written for Twisted that don't
>> require testing starting the reactor. Generally, starting and stopping the
>> reactor in application code is a bad pattern.
>
> In the case of the job runner, it's because Twisted is being shoe-horned
> into an API that was designed before Twisted was introduced. In
> checkwatches, it's like that because it's a transitionary API, almost a
> learning point. Also, our scripts are expected to inherit from
> LaunchpadScript (or, at least, that's the legacy we're working with),
> not something suitable for twistd.
>
> Is it such a bad pattern to start and stop the reactor? Other than it
> doesn't work reliably. If it did work perfectly, what would be the
> problem?
>
I don't know. I'll have to think about it.
>> ite`, but adjusted to work esult, result) later on. I could esult, result)
>> > + This is basically a reimplementation of subunit's
>> > + `IsolatedTestCase` or `IsolatedTestSu
>> > + with Zope. In particular, Zope's TestResult object is responsible
>> > + for calling testSetUp() and testTearDown() on the selected layer.
>> > + """
>> > +
>> > + def run(self, result):
>> > + assert isinstance(result, ZopeTestResult), (
>> > + "result must be a Zope result object, not %r." % (result,))
>>
>> Why? Please add a comment explaining the case.
>
> It's because it does super(ZopeTestR
> change it to not care, then later do:
>
> if isinstance(result, ZopeTestResult):
> # Skip all the Zope-specific result stuff
> # by passing a super() of the result.
> result = super(ZopeTestR
>
> But, for non-Zope test results, this could cause confusion (duplicated
> stdout/stderr reporting, for example).
>
> For example, the Zope test result object is responsible for calling
> testSetUp() and testTearDown(), and for handling post-mortem
> debugging. If another test result object did something similar, it would
> make no sense to do it in the parent process.
>
Fair enough. Please add a comment to the isinstance check pointing to
the super() call below.
>> MultiTestResult ( TestProtocolCli ent(fdwrite) ) nSubProcess, self).run(result)
>> > + pread, pwrite = os.pipe()
>> > + pid = os.fork()
>> > + if pid == 0:
>> > + # Child.
>> > + os.close(pread)
>> > + fdwrite = os.fdopen(pwrite, 'w', 1)
>> > + # Send results to both the Zope result object (so that
>> > + # layer setup and teardown are done properly, etc.) and to
>> > + # the subunit stream client so that the parent process can
>> > + # obtain the result.
>> > + result = testtools.
>> > + result, subunit.
>> > + super(ZopeTestI
>> > + fdwrite.flush()
>> > + sys.stdout.flush()
>> > + sys.stderr.flush()
>> > + # Exit hard.
>> > + os._exit(0)
>>
>> Personally, I don't need a comment to explain what os._exit(0) does. It's more
>> interesting to know why we want to exit hard.
>
> I copied this from subunit. I guess it's to avoid running onexit
> handlers, but perhaps there are other reasons. Ah, maybe SystemExit is
> caught and just logged as a test failure.
>
Thanks.
>>
>> > + else:
>> > + # Parent.
>> > + os.close(pwrite)
>> > + fdread = os.fdopen(pread, 'rU')
>> > + # Accept the result from the child process. Skip all the
>> > + # Zope-specific result stuff by passing a super() of the
>> > + # result.
>>
>> This comment would be more helpful if it explained why we want to skip the
>> zope-specific result stuff.
>
> Done.
>
Thanks.
>> esult, result) TestProtocolSer ver(result) readFrom( fdread)
>> > + result = super(ZopeTestR
>> > + protocol = subunit.
>> > + protocol.
>> > + fdread.close()
>> > + os.waitpid(pid, 0)
>>
>> Thanks for persevering. Not long to go now :)
>
> Thanks for the review :)
>
No probs.