Merge lp:~lifeless/launchpad/paralleltests into lp:launchpad

Proposed by Robert Collins
Status: Merged
Approved by: Robert Collins
Approved revision: no longer in the source branch.
Merged at revision: 11731
Proposed branch: lp:~lifeless/launchpad/paralleltests
Merge into: lp:launchpad
Diff against target: 1162 lines (+945/-5) (has conflicts)
14 files modified
buildout-templates/bin/test.in (+10/-2)
database/schema/security.cfg (+7/-0)
lib/canonical/testing/parallel.py (+174/-0)
lib/canonical/testing/tests/test_parallel.py (+117/-0)
lib/lp/archivepublisher/tests/test_publisher.py (+70/-0)
lib/lp/registry/doc/structural-subscriptions.txt (+62/-2)
lib/lp/registry/interfaces/structuralsubscription.py (+41/-0)
lib/lp/registry/model/structuralsubscription.py (+67/-0)
lib/lp/registry/tests/test_structuralsubscriptiontarget.py (+194/-0)
lib/lp/services/mail/tests/test_incoming.py (+5/-0)
lib/lp/soyuz/interfaces/distributionjob.py (+69/-0)
lib/lp/soyuz/model/initialisedistroseriesjob.py (+51/-0)
lib/lp/soyuz/tests/test_initialisedistroseriesjob.py (+77/-0)
versions.cfg (+1/-1)
Text conflict in database/schema/security.cfg
Text conflict in lib/lp/archivepublisher/tests/test_publisher.py
Text conflict in lib/lp/registry/doc/structural-subscriptions.txt
Text conflict in lib/lp/registry/interfaces/structuralsubscription.py
Text conflict in lib/lp/registry/model/structuralsubscription.py
Text conflict in lib/lp/registry/tests/test_structuralsubscriptiontarget.py
Text conflict in lib/lp/services/mail/tests/test_incoming.py
Text conflict in lib/lp/soyuz/interfaces/distributionjob.py
Text conflict in lib/lp/soyuz/model/initialisedistroseriesjob.py
Text conflict in lib/lp/soyuz/tests/test_initialisedistroseriesjob.py
To merge this branch: bzr merge lp:~lifeless/launchpad/paralleltests
Reviewer Review Type Date Requested Status
Jonathan Lange (community) Approve
Review via email: mp+38594@code.launchpad.net

Commit message

parallel testing facility that works with subunit.

Description of the change

Add a layer agnostic parallel testing facility.

The bin/test -j facility has a number of flaws; firstly it buffers without propogating timing data in subunit; secondly it groups by layers when we don't have a homogeneous distribution across layers.

This facility layers on the --load-list facility to run a simple partition of the requested tests in parallel. It works.

Its not as thoroughly tested as I normally would, but the glue is very shallow and some bits tests would be wasted on. I do think that more tests *may* be needed in future, but only if we're making further changes based on experience with the result of this patch.

To post a comment you must log in.
Revision history for this message
Robert Collins (lifeless) wrote :
Download full text (8.8 KiB)

Direct mail from jml:
"""Wow. Thanks for this Rob. While I lament the fact that we have to do hacks
to bin/test, I really like that you're starting on this, and that you've kept
the hack very clean.

Sending this to you directly since you didn't create an MP, and I
assume that you didn't for a reason :)

Lots of this stuff should just land now, regardless of whether the parallel
support is any good. I'm thinking particularly of all the boilerplate you
deleted.

Most of my comments below are asking for more or clearer documentation. I've
tried to supply docs where I know what's going on.

Thanks,
jml

> === modified file 'lib/canonical/launchpad/testing/tests/test_pages.py'
> --- lib/canonical/launchpad/testing/tests/test_pages.py 2010-10-04 19:50:45 +0000
> +++ lib/canonical/launchpad/testing/tests/test_pages.py 2010-10-15 11:52:20 +0000
> @@ -9,6 +9,8 @@ import shutil
> import tempfile
> import unittest
>
> +from bzrlib.tests import iter_suite_tests
> +

You can get this from testtools under a different name. Probably you should.

> === added file 'lib/canonical/testing/parallel.py'
> --- lib/canonical/testing/parallel.py 1970-01-01 00:00:00 +0000
> +++ lib/canonical/testing/parallel.py 2010-10-15 11:52:20 +0000
> @@ -0,0 +1,168 @@
> +# Copyright 2010 Canonical Ltd. This software is licensed under the
> +# GNU Affero General Public License version 3 (see the file LICENSE).
> +
> +from __future__ import with_statement
> +
> +"""Parallel test glue."""
> +
> +__metaclass__ = type
> +__all__ = ["main"]
> +
> +import itertools
> +import os
> +import subprocess
> +import sys
> +import tempfile
> +
> +from bzrlib.osutils import local_concurrency
> +from subunit import ProtocolTestCase
> +from subunit.run import SubunitTestRunner
> +from testtools.compat import unicode_output_stream
> +from testtools import (
> + ConcurrentTestSuite,
> + TestResult,
> + TextTestResult,
> + )
> +
> +
> +def prepare_argv(argv):
> + """Remove options from argv that ListTestCase will add in itself."""

This might seem pedantic, but I think the comment reads more clearly like
this:

 """Remove options from argv that would be added by ListTestCase."""

> +def find_load_list(args):

Add a docstring like:

 """Get the value passed in to --load-list=FOO."""

> +def find_tests(argv):
> + """Find tests to parallel run.
> +
> + :param argv: The argv given to the test runner, used to get the tests to
> + run.

Add:

 :return: A list of test IDs.

> + """
> + load_list = find_load_list(argv)
> + if load_list:
> + # just use the load_list
> + with open(load_list, 'rt') as list_file:
> + return [id for id in list_file.read().split('\n') if id]

I think you could also write that as:

 return filter(None, list_file.read().splitlines())

But better yet might be to do this:

 return [id for id in (line.strip() for line in list_file) if id]

or:

 return filter(None, (line.strip() for line in list_file))

But whatever. :)

> + # run in --list-tests mode
> + argv = prepare_argv(argv) + ['--list-tests', '--subunit']
> + process = subprocess.Popen(argv, stdin=subprocess.PIPE,
> + stdout=subproc...

Read more...

Revision history for this message
Robert Collins (lifeless) wrote :

Ah, I should have used a dependent branch, the stuff I deleted was from my approved 'doctests' branch, a necessary prereq.

Revision history for this message
Jonathan Lange (jml) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'buildout-templates/bin/test.in'
2--- buildout-templates/bin/test.in 2010-08-09 16:26:45 +0000
3+++ buildout-templates/bin/test.in 2010-10-16 18:22:51 +0000
4@@ -45,7 +45,6 @@
5 BUILD_DIR = ${buildout:directory|path-repr}
6 CUSTOM_SITE_DIR = ${scripts:parts-directory|path-repr}
7
8-
9 # Make tests run in a timezone no launchpad developers live in.
10 # Our tests need to run in any timezone.
11 # (This is no longer actually required, as PQM does this.)
12@@ -79,7 +78,6 @@
13 sys.exit(-1 * signal.SIGTERM)
14 signal.signal(signal.SIGTERM, exit_with_atexit_handlers)
15
16-
17 # Tell canonical.config to use the testrunner config instance.
18 from canonical.config import config
19 config.setInstance('testrunner')
20@@ -212,10 +210,20 @@
21 else:
22 args = sys.argv
23
24+ # thunk across to parallel support if needed.
25+ if '--parallel' in sys.argv and '--list-tests' not in sys.argv:
26+ # thunk over to parallel testing.
27+ from canonical.testing.parallel import main
28+ sys.exit(main(sys.argv))
29+
30 def load_list(option, opt_str, list_name, parser):
31 patch_find_tests(filter_tests(list_name))
32 options.parser.add_option(
33 '--load-list', type=str, action='callback', callback=load_list)
34+ options.parser.add_option(
35+ '--parallel', action='store_true',
36+ help='Run tests in parallel processes. '
37+ 'Poorly isolated tests will break.')
38
39 # tests_pattern is a regexp, so the parsed value is hard to compare
40 # with the default value in the loop below.
41
42=== modified file 'database/schema/security.cfg'
43--- database/schema/security.cfg 2010-10-08 10:06:11 +0000
44+++ database/schema/security.cfg 2010-10-16 18:22:51 +0000
45@@ -545,11 +545,18 @@
46 public.bugnotification = SELECT, INSERT
47 public.bugnotificationrecipient = SELECT, INSERT
48 public.bugsubscription = SELECT
49+<<<<<<< TREE
50 public.bugsubscriptionfilter = SELECT
51 public.bugsubscriptionfilterstatus = SELECT
52 public.bugsubscriptionfilterimportance = SELECT
53 public.bugsubscriptionfiltertag = SELECT
54 public.bugtag = SELECT
55+=======
56+public.bugsubscriptionfilter = SELECT
57+public.bugsubscriptionfilterstatus = SELECT
58+public.bugsubscriptionfilterimportance = SELECT
59+public.bugsubscriptionfiltertag = SELECT
60+>>>>>>> MERGE-SOURCE
61 public.bugtask = SELECT, INSERT, UPDATE
62 public.bugtracker = SELECT, INSERT
63 public.bugtrackercomponent = SELECT, INSERT, UPDATE, DELETE
64
65=== modified file 'lib/canonical/config/schema-lazr.conf'
66=== added file 'lib/canonical/testing/parallel.py'
67--- lib/canonical/testing/parallel.py 1970-01-01 00:00:00 +0000
68+++ lib/canonical/testing/parallel.py 2010-10-16 18:22:51 +0000
69@@ -0,0 +1,174 @@
70+# Copyright 2010 Canonical Ltd. This software is licensed under the
71+# GNU Affero General Public License version 3 (see the file LICENSE).
72+
73+from __future__ import with_statement
74+
75+"""Parallel test glue."""
76+
77+__metaclass__ = type
78+__all__ = ["main"]
79+
80+import itertools
81+import os
82+import subprocess
83+import sys
84+import tempfile
85+
86+from bzrlib.osutils import local_concurrency
87+from subunit import ProtocolTestCase
88+from subunit.run import SubunitTestRunner
89+from testtools.compat import unicode_output_stream
90+from testtools import (
91+ ConcurrentTestSuite,
92+ TestResult,
93+ TextTestResult,
94+ )
95+
96+
97+def prepare_argv(argv):
98+ """Remove options from argv that would be added by ListTestCase."""
99+ result = []
100+ skipn = 0
101+ for pos, arg in enumerate(argv):
102+ if skipn:
103+ skipn -= 1
104+ continue
105+ if arg in ('--subunit', '--parallel'):
106+ continue
107+ if arg.startswith('--load-list='):
108+ continue
109+ if arg == '--load-list':
110+ skipn = 1
111+ continue
112+ result.append(arg)
113+ return result
114+
115+
116+def find_load_list(args):
117+ """Get the value passed in to --load-list=FOO."""
118+ load_list = None
119+ for pos, arg in enumerate(args):
120+ if arg.startswith('--load-list='):
121+ load_list = arg[len('--load-list='):]
122+ if arg == '--load-list':
123+ load_list = args[pos+1]
124+ return load_list
125+
126+
127+class GatherIDs(TestResult):
128+ """Gather test ids from a test run."""
129+
130+ def __init__(self):
131+ super(GatherIDs, self).__init__()
132+ self.ids = []
133+
134+ def startTest(self, test):
135+ super(GatherIDs, self).startTest(test)
136+ self.ids.append(test.id())
137+
138+
139+def find_tests(argv):
140+ """Find tests to parallel run.
141+
142+ :param argv: The argv given to the test runner, used to get the tests to
143+ run.
144+ :return: A list of test IDs.
145+ """
146+ load_list = find_load_list(argv)
147+ if load_list:
148+ # just use the load_list
149+ with open(load_list, 'rt') as list_file:
150+ return [id for id in list_file.read().split('\n') if id]
151+ # run in --list-tests mode
152+ argv = prepare_argv(argv) + ['--list-tests', '--subunit']
153+ process = subprocess.Popen(argv, stdin=subprocess.PIPE,
154+ stdout=subprocess.PIPE)
155+ process.stdin.close()
156+ test = ProtocolTestCase(process.stdout)
157+ result = GatherIDs()
158+ test.run(result)
159+ process.wait()
160+ if process.returncode:
161+ raise Exception('error listing tests: %s' % err)
162+ return result.ids
163+
164+
165+class ListTestCase(ProtocolTestCase):
166+
167+ def __init__(self, test_ids, args):
168+ """Create a ListTestCase.
169+
170+ :param test_ids: The ids of the tests to run.
171+ :param args: The args to use to run the test runner (without
172+ --load-list - that is added automatically).
173+ """
174+ self._test_ids = test_ids
175+ self._args = args
176+
177+ def run(self, result):
178+ with tempfile.NamedTemporaryFile() as test_list_file:
179+ for test_id in self._test_ids:
180+ test_list_file.write(test_id + '\n')
181+ test_list_file.flush()
182+ argv = self._args + ['--subunit', '--load-list', test_list_file.name]
183+ process = subprocess.Popen(argv, stdin=subprocess.PIPE,
184+ stdout=subprocess.PIPE, bufsize=1)
185+ try:
186+ # If it tries to read, give it EOF.
187+ process.stdin.close()
188+ ProtocolTestCase.__init__(self, process.stdout)
189+ ProtocolTestCase.run(self, result)
190+ finally:
191+ process.wait()
192+
193+
194+def concurrency():
195+ """Return the number of current tests we should run on this machine.
196+
197+ Each test is run in its own process, and we assume that the optimal number
198+ is one per core.
199+ """
200+ # TODO: limit by memory as well.
201+ procs = local_concurrency()
202+ return procs
203+
204+
205+def partition_tests(test_ids, count):
206+ """Partition suite into count lists of tests."""
207+ # This just assigns tests in a round-robin fashion. On one hand this
208+ # splits up blocks of related tests that might run faster if they shared
209+ # resources, but on the other it avoids assigning blocks of slow tests to
210+ # just one partition. So the slowest partition shouldn't be much slower
211+ # than the fastest.
212+ partitions = [list() for i in range(count)]
213+ for partition, test_id in itertools.izip(itertools.cycle(partitions), test_ids):
214+ partition.append(test_id)
215+ return partitions
216+
217+
218+def main(argv, prepare_args=prepare_argv, find_tests=find_tests):
219+ """CLI entry point to adapt a test run to parallel testing."""
220+ child_args = prepare_argv(argv)
221+ test_ids = find_tests(argv)
222+ # We could create a proxy object per test id if desired in future)
223+ def parallelise_tests(suite):
224+ test_ids = list(suite)[0]._test_ids
225+ count = concurrency()
226+ partitions = partition_tests(test_ids, count)
227+ return [ListTestCase(partition, child_args) for partition in partitions]
228+ suite = ConcurrentTestSuite(ListTestCase(test_ids, None), parallelise_tests)
229+ if '--subunit' in argv:
230+ runner = SubunitTestRunner(sys.stdout)
231+ result = runner.run(suite)
232+ else:
233+ stream = unicode_output_stream(sys.stdout)
234+ result = TextTestResult(stream)
235+ result.startTestRun()
236+ try:
237+ suite.run(result)
238+ finally:
239+ result.stopTestRun()
240+ if result.wasSuccessful():
241+ return 0
242+ return -1
243+
244
245=== added file 'lib/canonical/testing/tests/test_parallel.py'
246--- lib/canonical/testing/tests/test_parallel.py 1970-01-01 00:00:00 +0000
247+++ lib/canonical/testing/tests/test_parallel.py 2010-10-16 18:22:51 +0000
248@@ -0,0 +1,117 @@
249+# Copyright 2010 Canonical Ltd. This software is licensed under the
250+# GNU Affero General Public License version 3 (see the file LICENSE).
251+
252+from __future__ import with_statement
253+
254+"""Parallel test glue."""
255+
256+__metaclass__ = type
257+
258+from StringIO import StringIO
259+import subprocess
260+import tempfile
261+
262+from testtools import (
263+ TestCase,
264+ TestResult,
265+ )
266+from fixtures import (
267+ PopenFixture,
268+ TestWithFixtures,
269+ )
270+
271+from canonical.testing.parallel import (
272+ find_load_list,
273+ find_tests,
274+ ListTestCase,
275+ main,
276+ prepare_argv,
277+ )
278+
279+
280+class TestListTestCase(TestCase, TestWithFixtures):
281+
282+ def test_run(self):
283+ # ListTestCase.run should run bin/test adding in --subunit,
284+ # --load-list, with a list file containing the supplied list ids.
285+ def check_list_file(info):
286+ """Callback from subprocess.Popen for testing run()."""
287+ # A temp file should have been made.
288+ args = info['args']
289+ load_list = find_load_list(args)
290+ self.assertNotEqual(None, load_list)
291+ with open(load_list, 'rt') as testlist:
292+ contents = testlist.readlines()
293+ self.assertEqual(['foo\n', 'bar\n'], contents)
294+ return {'stdout': StringIO(''), 'stdin': StringIO()}
295+ popen = self.useFixture(PopenFixture(check_list_file))
296+ case = ListTestCase(['foo', 'bar'], ['bin/test'])
297+ self.assertEqual([], popen.procs)
298+ result = TestResult()
299+ case.run(result)
300+ self.assertEqual(0, result.testsRun)
301+ self.assertEqual(1, len(popen.procs))
302+ self.assertEqual(['bin/test', '--subunit', '--load-list'],
303+ popen.procs[0]._args['args'][:-1])
304+
305+
306+class TestUtilities(TestCase, TestWithFixtures):
307+
308+ def test_prepare_argv_removes_subunit(self):
309+ self.assertEqual(
310+ ['bin/test', 'foo'],
311+ prepare_argv(['bin/test', '--subunit', 'foo']))
312+
313+ def test_prepare_argv_removes_parallel(self):
314+ self.assertEqual(
315+ ['bin/test', 'foo'],
316+ prepare_argv(['bin/test', '--parallel', 'foo']))
317+
318+ def test_prepare_argv_removes_load_list_with_equals(self):
319+ self.assertEqual(
320+ ['bin/test', 'foo'],
321+ prepare_argv(['bin/test', '--load-list=Foo', 'foo']))
322+
323+ def test_prepare_argv_removes_load_2_arg_form(self):
324+ self.assertEqual(
325+ ['bin/test', 'foo'],
326+ prepare_argv(['bin/test', '--load-list', 'Foo', 'foo']))
327+
328+ def test_find_tests_honours_list_list_equals(self):
329+ with tempfile.NamedTemporaryFile() as listfile:
330+ listfile.write('foo\nbar\n')
331+ listfile.flush()
332+ self.assertEqual(
333+ ['foo', 'bar'],
334+ find_tests(
335+ ['bin/test', '--load-list=%s' % listfile.name, 'foo']))
336+
337+ def test_find_tests_honours_list_list_two_arg_form(self):
338+ with tempfile.NamedTemporaryFile() as listfile:
339+ listfile.write('foo\nbar\n')
340+ listfile.flush()
341+ self.assertEqual(
342+ ['foo', 'bar'],
343+ find_tests(
344+ ['bin/test', '--load-list', listfile.name, 'foo']))
345+
346+ def test_find_tests_live(self):
347+ # When --load-tests wasn't supplied, find_tests needs to run bin/test
348+ # with --list-tests and --subunit, and parse the resulting subunit
349+ # stream.
350+ def inject_testlist(args):
351+ self.assertEqual(subprocess.PIPE, args['stdin'])
352+ self.assertEqual(subprocess.PIPE, args['stdout'])
353+ self.assertEqual(
354+ ['bin/test', '-vt', 'filter', '--list-tests', '--subunit'],
355+ args['args'])
356+ return {'stdin': StringIO(), 'stdout': StringIO(u"""
357+test: quux
358+successful: quux
359+test: glom
360+successful: glom
361+""")}
362+ popen = self.useFixture(PopenFixture(inject_testlist))
363+ self.assertEqual(
364+ ['quux', 'glom'],
365+ find_tests(['bin/test', '-vt', 'filter', '--parallel']))
366
367=== modified file 'lib/lp/archivepublisher/tests/test_publisher.py'
368--- lib/lp/archivepublisher/tests/test_publisher.py 2010-10-15 08:30:17 +0000
369+++ lib/lp/archivepublisher/tests/test_publisher.py 2010-10-16 18:22:51 +0000
370@@ -1081,6 +1081,7 @@
371 # The Label: field should be set to the archive displayname
372 self.assertEqual(release_contents[1], 'Label: Partner archive')
373
374+<<<<<<< TREE
375
376 class TestArchiveIndices(TestPublisherBase):
377 """Tests for the native publisher's index generation.
378@@ -1187,6 +1188,75 @@
379 publisher, self.breezy_autotest, PackagePublishingPocket.RELEASE,
380 present=['hppa'], absent=['i386'])
381
382+=======
383+ def assertIndicesForArchitectures(self, publisher, present, absent):
384+ """Assert that the correct set of archs has indices.
385+
386+ Checks that the architecture tags in 'present' have Packages and
387+ Release files and are in the series' Release file, and confirms
388+ that those in 'absent' are not.
389+ """
390+
391+ self.checkAllRequestedReleaseFiles(
392+ publisher, architecturetags=present)
393+
394+ arch_template = os.path.join(
395+ publisher._config.distsroot, 'breezy-autotest/main/binary-%s')
396+ release_template = os.path.join(arch_template, 'Release')
397+ packages_template = os.path.join(arch_template, 'Packages')
398+ release_content = open(os.path.join(
399+ publisher._config.distsroot,
400+ 'breezy-autotest/Release')).read()
401+
402+ for arch in present:
403+ self.assertTrue(os.path.exists(arch_template % arch))
404+ self.assertTrue(os.path.exists(release_template % arch))
405+ self.assertTrue(os.path.exists(packages_template % arch))
406+ self.assertTrue(arch in release_content)
407+
408+ for arch in absent:
409+ self.assertFalse(os.path.exists(arch_template % arch))
410+ self.assertFalse(arch in release_content)
411+
412+ def testNativeNoIndicesForDisabledArchitectures(self):
413+ """Test that no indices are created for disabled archs."""
414+ self.getPubBinaries()
415+
416+ ds = self.ubuntutest.getSeries('breezy-autotest')
417+ ds.getDistroArchSeries('i386').enabled = False
418+ self.config = Config(self.ubuntutest)
419+
420+ publisher = Publisher(
421+ self.logger, self.config, self.disk_pool,
422+ self.ubuntutest.main_archive)
423+
424+ publisher.A_publish(False)
425+ publisher.C_writeIndexes(False)
426+ publisher.D_writeReleaseFiles(False)
427+
428+ self.assertIndicesForArchitectures(
429+ publisher, present=['hppa'], absent=['i386'])
430+
431+ def testAptFtparchiveNoIndicesForDisabledArchitectures(self):
432+ """Test that no indices are created for disabled archs."""
433+ self.getPubBinaries()
434+
435+ ds = self.ubuntutest.getSeries('breezy-autotest')
436+ ds.getDistroArchSeries('i386').enabled = False
437+ self.config = Config(self.ubuntutest)
438+
439+ publisher = Publisher(
440+ self.logger, self.config, self.disk_pool,
441+ self.ubuntutest.main_archive)
442+
443+ publisher.A_publish(False)
444+ publisher.C_doFTPArchive(False)
445+ publisher.D_writeReleaseFiles(False)
446+
447+ self.assertIndicesForArchitectures(
448+ publisher, present=['hppa'], absent=['i386'])
449+
450+>>>>>>> MERGE-SOURCE
451 def testWorldAndGroupReadablePackagesAndSources(self):
452 """Test Packages.gz and Sources.gz files are world readable."""
453 publisher = Publisher(
454
455=== modified file 'lib/lp/bugs/browser/configure.zcml'
456=== modified file 'lib/lp/bugs/configure.zcml'
457=== modified file 'lib/lp/bugs/doc/initial-bug-contacts.txt'
458=== modified file 'lib/lp/bugs/stories/bug-tags/xx-official-bug-tags.txt'
459=== modified file 'lib/lp/bugs/stories/webservice/xx-bug-target.txt'
460=== modified file 'lib/lp/bugs/tests/has-bug-supervisor.txt'
461=== modified file 'lib/lp/registry/browser/configure.zcml'
462=== modified file 'lib/lp/registry/browser/tests/poll-views_0.txt'
463=== modified file 'lib/lp/registry/doc/structural-subscriptions.txt'
464--- lib/lp/registry/doc/structural-subscriptions.txt 2010-10-15 21:48:34 +0000
465+++ lib/lp/registry/doc/structural-subscriptions.txt 2010-10-16 18:22:51 +0000
466@@ -198,8 +198,68 @@
467 name16
468
469
470-Target type display
471-===================
472+<<<<<<< TREE
473+Target type display
474+===================
475+=======
476+Retrieving structural subscribers of targets
477+============================================
478+
479+Subscribers of one or more targets are retrieved by
480+PersonSet.getSubscribersForTargets.
481+
482+XXX Abel Deuring 2008-07-11 We must remove the security proxy, because
483+PersonSet.getSubscribersForTargets() accesses the private attribute
484+firefox._targets_args. This attribute should be renamed. Bug #247525.
485+
486+ >>> from zope.security.proxy import removeSecurityProxy
487+ >>> firefox_no_proxy = removeSecurityProxy(firefox)
488+ >>> from lp.registry.interfaces.person import IPersonSet
489+ >>> person_set = getUtility(IPersonSet)
490+ >>> firefox_subscribers = person_set.getSubscribersForTargets(
491+ ... [firefox_no_proxy])
492+ >>> print_bug_subscribers(firefox_subscribers)
493+ name12
494+
495+If PersonSet.getSubscribersForTargets() is passed a
496+BugNotificationLevel in its `level` parameter, only structural
497+subscribers with that notification level or higher will be returned.
498+The subscription level of ff_sub, the only structural subscription
499+to firefox, is NOTHING, hence we get an empty list if we pass any
500+larger bug notification level.
501+
502+ >>> firefox_subscribers = person_set.getSubscribersForTargets(
503+ ... [firefox_no_proxy], level=BugNotificationLevel.LIFECYCLE)
504+ >>> print len(firefox_subscribers)
505+ 0
506+
507+If the bug notification level of ff_sub is increased, the subscription
508+is included in the result of PersonSet.getSubscribersForTarget().
509+
510+ >>> ff_sub.level = BugNotificationLevel.LIFECYCLE
511+ >>> firefox_subscribers = person_set.getSubscribersForTargets(
512+ ... [firefox_no_proxy])
513+ >>> print_bug_subscribers(firefox_subscribers)
514+ name12
515+ >>> ff_sub.level = BugNotificationLevel.METADATA
516+ >>> firefox_subscribers = person_set.getSubscribersForTargets(
517+ ... [firefox_no_proxy])
518+ >>> print_bug_subscribers(firefox_subscribers)
519+ name12
520+
521+More than one target can be passed to PersonSet.getSubscribersForTargets().
522+
523+ >>> ubuntu_no_proxy = removeSecurityProxy(ubuntu)
524+ >>> ubuntu_or_firefox_subscribers = person_set.getSubscribersForTargets(
525+ ... [firefox_no_proxy, ubuntu_no_proxy])
526+ >>> print_bug_subscribers(ubuntu_or_firefox_subscribers)
527+ name12
528+ name16
529+
530+
531+Target type display
532+===================
533+>>>>>>> MERGE-SOURCE
534
535 Structural subscription targets have a `target_type_display` attribute, which
536 can be used to refer to them in display.
537
538=== modified file 'lib/lp/registry/doc/teammembership-email-notification.txt'
539=== modified file 'lib/lp/registry/interfaces/structuralsubscription.py'
540--- lib/lp/registry/interfaces/structuralsubscription.py 2010-10-07 10:06:55 +0000
541+++ lib/lp/registry/interfaces/structuralsubscription.py 2010-10-16 18:22:51 +0000
542@@ -172,6 +172,7 @@
543 def userCanAlterSubscription(subscriber, subscribed_by):
544 """Check if a user can change a subscription for a person."""
545
546+<<<<<<< TREE
547 def userCanAlterBugSubscription(subscriber, subscribed_by):
548 """Check if a user can change a bug subscription for a person."""
549
550@@ -210,6 +211,46 @@
551 Modify-only parts.
552 """
553
554+=======
555+ def userCanAlterBugSubscription(subscriber, subscribed_by):
556+ """Check if a user can change a bug subscription for a person."""
557+
558+ @operation_parameters(person=Reference(schema=IPerson))
559+ @operation_returns_entry(IStructuralSubscription)
560+ @export_read_operation()
561+ def getSubscription(person):
562+ """Return the subscription for `person`, if it exists."""
563+
564+ def getBugNotificationsRecipients(recipients=None, level=None):
565+ """Return the set of bug subscribers to this target.
566+
567+ :param recipients: If recipients is not None, a rationale
568+ is added for each subscriber.
569+ :type recipients: `INotificationRecipientSet`
570+ 'param level: If level is not None, only strucutral
571+ subscribers with a subscrition level greater or equal
572+ to the given value are returned.
573+ :type level: `BugNotificationLevel`
574+ :return: An `INotificationRecipientSet` instance containing
575+ the bug subscribers.
576+ """
577+
578+ target_type_display = Attribute("The type of the target, for display.")
579+
580+ def userHasBugSubscriptions(user):
581+ """Is `user` subscribed, directly or via a team, to bug mail?"""
582+
583+ def getSubscriptionsForBug(bug, level):
584+ """Return subscriptions for a given `IBug` at `level`."""
585+
586+
587+class IStructuralSubscriptionTargetWrite(Interface):
588+ """A Launchpad Structure allowing users to subscribe to it.
589+
590+ Modify-only parts.
591+ """
592+
593+>>>>>>> MERGE-SOURCE
594 def addSubscription(subscriber, subscribed_by):
595 """Add a subscription for this structure.
596
597
598=== modified file 'lib/lp/registry/model/distribution.py'
599=== modified file 'lib/lp/registry/model/structuralsubscription.py'
600--- lib/lp/registry/model/structuralsubscription.py 2010-10-07 10:06:55 +0000
601+++ lib/lp/registry/model/structuralsubscription.py 2010-10-16 18:22:51 +0000
602@@ -483,6 +483,7 @@
603 # The user has a bug subscription
604 return True
605 return False
606+<<<<<<< TREE
607
608 def getSubscriptionsForBugTask(self, bugtask, level):
609 """See `IStructuralSubscriptionTarget`."""
610@@ -533,3 +534,69 @@
611
612 return Store.of(self.__helper.pillar).using(*origin).find(
613 StructuralSubscription, self.__helper.join, *conditions)
614+=======
615+
616+ def getSubscriptionsForBug(self, bug, level):
617+ """See `IStructuralSubscriptionTarget`."""
618+ if IProjectGroup.providedBy(self):
619+ targets = set(self.products)
620+ elif IMilestone.providedBy(self):
621+ targets = [self.series_target]
622+ else:
623+ targets = [self]
624+
625+ bugtasks = [
626+ bugtask for bugtask in bug.bugtasks
627+ if bugtask.target in targets]
628+
629+ assert len(bugtasks) != 0, repr(self)
630+
631+ origin = [
632+ StructuralSubscription,
633+ LeftJoin(
634+ BugSubscriptionFilter,
635+ BugSubscriptionFilter.structural_subscription_id == (
636+ StructuralSubscription.id)),
637+ LeftJoin(
638+ BugSubscriptionFilterStatus,
639+ BugSubscriptionFilterStatus.filter_id == (
640+ BugSubscriptionFilter.id)),
641+ LeftJoin(
642+ BugSubscriptionFilterImportance,
643+ BugSubscriptionFilterImportance.filter_id == (
644+ BugSubscriptionFilter.id)),
645+ ]
646+
647+ if len(bug.tags) == 0:
648+ tag_conditions = [
649+ BugSubscriptionFilter.include_any_tags == False,
650+ ]
651+ else:
652+ tag_conditions = [
653+ BugSubscriptionFilter.exclude_any_tags == False,
654+ ]
655+
656+ conditions = [
657+ StructuralSubscription.bug_notification_level >= level,
658+ Or(
659+ # There's no filter or ...
660+ BugSubscriptionFilter.id == None,
661+ # There is a filter and ...
662+ And(
663+ # There's no status filter, or there is a status filter
664+ # and and it matches.
665+ Or(BugSubscriptionFilterStatus.id == None,
666+ BugSubscriptionFilterStatus.status.is_in(
667+ bugtask.status for bugtask in bugtasks)),
668+ # There's no importance filter, or there is an importance
669+ # filter and it matches.
670+ Or(BugSubscriptionFilterImportance.id == None,
671+ BugSubscriptionFilterImportance.importance.is_in(
672+ bugtask.importance for bugtask in bugtasks)),
673+ # Any number of conditions relating to tags.
674+ *tag_conditions)),
675+ ]
676+
677+ return Store.of(self.__helper.pillar).using(*origin).find(
678+ StructuralSubscription, self.__helper.join, *conditions)
679+>>>>>>> MERGE-SOURCE
680
681=== modified file 'lib/lp/registry/tests/structural-subscription-target.txt'
682=== modified file 'lib/lp/registry/tests/test_structuralsubscriptiontarget.py'
683--- lib/lp/registry/tests/test_structuralsubscriptiontarget.py 2010-10-07 10:07:51 +0000
684+++ lib/lp/registry/tests/test_structuralsubscriptiontarget.py 2010-10-16 18:22:51 +0000
685@@ -167,6 +167,7 @@
686 None)
687
688
689+<<<<<<< TREE
690 class FilteredStructuralSubscriptionTestBase(StructuralSubscriptionTestBase):
691 """Tests for filtered structural subscriptions."""
692
693@@ -359,6 +360,199 @@
694
695 class TestStructuralSubscriptionForDistro(
696 FilteredStructuralSubscriptionTestBase, TestCaseWithFactory):
697+=======
698+class FilteredStructuralSubscriptionTestBase(StructuralSubscriptionTestBase):
699+ """Tests for filtered structural subscriptions."""
700+
701+ def makeBugTask(self):
702+ return self.factory.makeBugTask(target=self.target)
703+
704+ def test_getSubscriptionsForBug(self):
705+ # If no one has a filtered subscription for the given bug, the result
706+ # of getSubscriptionsForBug() is the same as for getSubscriptions().
707+ bugtask = self.makeBugTask()
708+ subscriptions = self.target.getSubscriptions(
709+ min_bug_notification_level=BugNotificationLevel.NOTHING)
710+ subscriptions_for_bug = self.target.getSubscriptionsForBug(
711+ bugtask.bug, BugNotificationLevel.NOTHING)
712+ self.assertEqual(list(subscriptions), list(subscriptions_for_bug))
713+
714+ def test_getSubscriptionsForBug_with_filter_on_status(self):
715+ # If a status filter exists for a subscription, the result of
716+ # getSubscriptionsForBug() may be a subset of getSubscriptions().
717+ bugtask = self.makeBugTask()
718+
719+ # Create a new subscription on self.target.
720+ login_person(self.ordinary_subscriber)
721+ subscription = self.target.addSubscription(
722+ self.ordinary_subscriber, self.ordinary_subscriber)
723+ subscription.bug_notification_level = BugNotificationLevel.COMMENTS
724+
725+ # Without any filters the subscription is found.
726+ subscriptions_for_bug = self.target.getSubscriptionsForBug(
727+ bugtask.bug, BugNotificationLevel.NOTHING)
728+ self.assertEqual([subscription], list(subscriptions_for_bug))
729+
730+ # Filter the subscription to bugs in the CONFIRMED state.
731+ subscription_filter = BugSubscriptionFilter()
732+ subscription_filter.structural_subscription = subscription
733+ subscription_filter.statuses = [BugTaskStatus.CONFIRMED]
734+
735+ # With the filter the subscription is not found.
736+ subscriptions_for_bug = self.target.getSubscriptionsForBug(
737+ bugtask.bug, BugNotificationLevel.NOTHING)
738+ self.assertEqual([], list(subscriptions_for_bug))
739+
740+ # If the filter is adjusted, the subscription is found again.
741+ subscription_filter.statuses = [bugtask.status]
742+ subscriptions_for_bug = self.target.getSubscriptionsForBug(
743+ bugtask.bug, BugNotificationLevel.NOTHING)
744+ self.assertEqual([subscription], list(subscriptions_for_bug))
745+
746+ def test_getSubscriptionsForBug_with_filter_on_importance(self):
747+ # If an importance filter exists for a subscription, the result of
748+ # getSubscriptionsForBug() may be a subset of getSubscriptions().
749+ bugtask = self.makeBugTask()
750+
751+ # Create a new subscription on self.target.
752+ login_person(self.ordinary_subscriber)
753+ subscription = self.target.addSubscription(
754+ self.ordinary_subscriber, self.ordinary_subscriber)
755+ subscription.bug_notification_level = BugNotificationLevel.COMMENTS
756+
757+ # Without any filters the subscription is found.
758+ subscriptions_for_bug = self.target.getSubscriptionsForBug(
759+ bugtask.bug, BugNotificationLevel.NOTHING)
760+ self.assertEqual([subscription], list(subscriptions_for_bug))
761+
762+ # Filter the subscription to bugs in the CRITICAL state.
763+ subscription_filter = BugSubscriptionFilter()
764+ subscription_filter.structural_subscription = subscription
765+ subscription_filter.importances = [BugTaskImportance.CRITICAL]
766+
767+ # With the filter the subscription is not found.
768+ subscriptions_for_bug = self.target.getSubscriptionsForBug(
769+ bugtask.bug, BugNotificationLevel.NOTHING)
770+ self.assertEqual([], list(subscriptions_for_bug))
771+
772+ # If the filter is adjusted, the subscription is found again.
773+ subscription_filter.importances = [bugtask.importance]
774+ subscriptions_for_bug = self.target.getSubscriptionsForBug(
775+ bugtask.bug, BugNotificationLevel.NOTHING)
776+ self.assertEqual([subscription], list(subscriptions_for_bug))
777+
778+ def test_getSubscriptionsForBug_with_filter_on_level(self):
779+ # All structural subscriptions have a level for bug notifications
780+ # which getSubscriptionsForBug() observes.
781+ bugtask = self.makeBugTask()
782+
783+ # Create a new METADATA level subscription on self.target.
784+ login_person(self.ordinary_subscriber)
785+ subscription = self.target.addSubscription(
786+ self.ordinary_subscriber, self.ordinary_subscriber)
787+ subscription.bug_notification_level = BugNotificationLevel.METADATA
788+
789+ # The subscription is found when looking for NOTHING or above.
790+ subscriptions_for_bug = self.target.getSubscriptionsForBug(
791+ bugtask.bug, BugNotificationLevel.NOTHING)
792+ self.assertEqual([subscription], list(subscriptions_for_bug))
793+ # The subscription is found when looking for METADATA or above.
794+ subscriptions_for_bug = self.target.getSubscriptionsForBug(
795+ bugtask.bug, BugNotificationLevel.METADATA)
796+ self.assertEqual([subscription], list(subscriptions_for_bug))
797+ # The subscription is not found when looking for COMMENTS or above.
798+ subscriptions_for_bug = self.target.getSubscriptionsForBug(
799+ bugtask.bug, BugNotificationLevel.COMMENTS)
800+ self.assertEqual([], list(subscriptions_for_bug))
801+
802+ def test_getSubscriptionsForBug_with_filter_include_any_tags(self):
803+ # If a subscription filter has include_any_tags, a bug with one or
804+ # more tags is matched.
805+ bugtask = self.makeBugTask()
806+
807+ # Create a new subscription on self.target.
808+ login_person(self.ordinary_subscriber)
809+ subscription = self.target.addSubscription(
810+ self.ordinary_subscriber, self.ordinary_subscriber)
811+ subscription.bug_notification_level = BugNotificationLevel.COMMENTS
812+ subscription_filter = subscription.newBugFilter()
813+ subscription_filter.include_any_tags = True
814+
815+ # Without any tags the subscription is not found.
816+ subscriptions_for_bug = self.target.getSubscriptionsForBug(
817+ bugtask.bug, BugNotificationLevel.NOTHING)
818+ self.assertEqual([], list(subscriptions_for_bug))
819+
820+ # With any tag the subscription is found.
821+ bugtask.bug.tags = ["foo"]
822+ subscriptions_for_bug = self.target.getSubscriptionsForBug(
823+ bugtask.bug, BugNotificationLevel.NOTHING)
824+ self.assertEqual([subscription], list(subscriptions_for_bug))
825+
826+ def test_getSubscriptionsForBug_with_filter_exclude_any_tags(self):
827+ # If a subscription filter has exclude_any_tags, only bugs with no
828+ # tags are matched.
829+ bugtask = self.makeBugTask()
830+
831+ # Create a new subscription on self.target.
832+ login_person(self.ordinary_subscriber)
833+ subscription = self.target.addSubscription(
834+ self.ordinary_subscriber, self.ordinary_subscriber)
835+ subscription.bug_notification_level = BugNotificationLevel.COMMENTS
836+ subscription_filter = subscription.newBugFilter()
837+ subscription_filter.exclude_any_tags = True
838+
839+ # Without any tags the subscription is found.
840+ subscriptions_for_bug = self.target.getSubscriptionsForBug(
841+ bugtask.bug, BugNotificationLevel.NOTHING)
842+ self.assertEqual([subscription], list(subscriptions_for_bug))
843+
844+ # With any tag the subscription is not found.
845+ bugtask.bug.tags = ["foo"]
846+ subscriptions_for_bug = self.target.getSubscriptionsForBug(
847+ bugtask.bug, BugNotificationLevel.NOTHING)
848+ self.assertEqual([], list(subscriptions_for_bug))
849+
850+ def test_getSubscriptionsForBug_with_multiple_filters(self):
851+ # If multiple filters exist for a subscription, all filters must
852+ # match.
853+ bugtask = self.makeBugTask()
854+
855+ # Create a new subscription on self.target.
856+ login_person(self.ordinary_subscriber)
857+ subscription = self.target.addSubscription(
858+ self.ordinary_subscriber, self.ordinary_subscriber)
859+ subscription.bug_notification_level = BugNotificationLevel.COMMENTS
860+
861+ # Filter the subscription to bugs in the CRITICAL state.
862+ subscription_filter = BugSubscriptionFilter()
863+ subscription_filter.structural_subscription = subscription
864+ subscription_filter.statuses = [BugTaskStatus.CONFIRMED]
865+ subscription_filter.importances = [BugTaskImportance.CRITICAL]
866+
867+ # With the filter the subscription is not found.
868+ subscriptions_for_bug = self.target.getSubscriptionsForBug(
869+ bugtask.bug, BugNotificationLevel.NOTHING)
870+ self.assertEqual([], list(subscriptions_for_bug))
871+
872+ # If the filter is adjusted to match status but not importance, the
873+ # subscription is still not found.
874+ subscription_filter.statuses = [bugtask.status]
875+ subscriptions_for_bug = self.target.getSubscriptionsForBug(
876+ bugtask.bug, BugNotificationLevel.NOTHING)
877+ self.assertEqual([], list(subscriptions_for_bug))
878+
879+ # If the filter is adjusted to also match importance, the subscription
880+ # is found again.
881+ subscription_filter.importances = [bugtask.importance]
882+ subscriptions_for_bug = self.target.getSubscriptionsForBug(
883+ bugtask.bug, BugNotificationLevel.NOTHING)
884+ self.assertEqual([subscription], list(subscriptions_for_bug))
885+
886+
887+class TestStructuralSubscriptionForDistro(
888+ FilteredStructuralSubscriptionTestBase, TestCaseWithFactory):
889+>>>>>>> MERGE-SOURCE
890
891 layer = LaunchpadFunctionalLayer
892
893
894=== modified file 'lib/lp/services/mail/tests/test_incoming.py'
895--- lib/lp/services/mail/tests/test_incoming.py 2010-10-15 20:44:53 +0000
896+++ lib/lp/services/mail/tests/test_incoming.py 2010-10-16 18:22:51 +0000
897@@ -92,6 +92,7 @@
898
899
900 def test_suite():
901+<<<<<<< TREE
902 suite = unittest.TestLoader().loadTestsFromName(__name__)
903 suite.addTest(DocTestSuite('lp.services.mail.incoming'))
904 suite.addTest(
905@@ -101,4 +102,8 @@
906 tearDown=tearDown,
907 layer=LaunchpadZopelessLayer,
908 stdout_logging_level=logging.WARNING))
909+=======
910+ suite = unittest.TestLoader().loadTestsFromName(__name__)
911+ suite.addTest(DocTestSuite('canonical.launchpad.mail.incoming'))
912+>>>>>>> MERGE-SOURCE
913 return suite
914
915=== modified file 'lib/lp/services/mailman/doc/postings.txt'
916=== modified file 'lib/lp/soyuz/doc/distroarchseries.txt'
917=== modified file 'lib/lp/soyuz/doc/package-arch-specific.txt'
918=== modified file 'lib/lp/soyuz/interfaces/distributionjob.py'
919--- lib/lp/soyuz/interfaces/distributionjob.py 2010-10-12 07:46:56 +0000
920+++ lib/lp/soyuz/interfaces/distributionjob.py 2010-10-16 18:22:51 +0000
921@@ -1,3 +1,4 @@
922+<<<<<<< TREE
923 # Copyright 2010 Canonical Ltd. This software is licensed under the
924 # GNU Affero General Public License version 3 (see the file LICENSE).
925
926@@ -64,3 +65,71 @@
927
928 class IInitialiseDistroSeriesJob(IRunnableJob):
929 """A Job that performs actions on a distribution."""
930+=======
931+# Copyright 2010 Canonical Ltd. This software is licensed under the
932+# GNU Affero General Public License version 3 (see the file LICENSE).
933+
934+__metaclass__ = type
935+
936+__all__ = [
937+ "DistributionJobType",
938+ "IDistributionJob",
939+ "IInitialiseDistroSeriesJob",
940+ "IInitialiseDistroSeriesJobSource",
941+]
942+
943+from lazr.enum import DBEnumeratedType, DBItem
944+from zope.interface import Attribute, Interface
945+from zope.schema import Int, Object
946+
947+from canonical.launchpad import _
948+
949+from lp.services.job.interfaces.job import IJob, IJobSource, IRunnableJob
950+from lp.registry.interfaces.distribution import IDistribution
951+from lp.registry.interfaces.distroseries import IDistroSeries
952+
953+
954+class IDistributionJob(Interface):
955+ """A Job that initialises acts on a distribution."""
956+
957+ id = Int(
958+ title=_('DB ID'), required=True, readonly=True,
959+ description=_("The tracking number for this job."))
960+
961+ distribution = Object(
962+ title=_('The Distribution this job is about.'),
963+ schema=IDistribution, required=True)
964+
965+ distroseries = Object(
966+ title=_('The DistroSeries this job is about.'),
967+ schema=IDistroSeries, required=False)
968+
969+ job = Object(
970+ title=_('The common Job attributes'), schema=IJob, required=True)
971+
972+ metadata = Attribute('A dict of data about the job.')
973+
974+ def destroySelf():
975+ """Destroy this object."""
976+
977+
978+class DistributionJobType(DBEnumeratedType):
979+
980+ INITIALISE_SERIES = DBItem(1, """
981+ Initialise a Distro Series.
982+
983+ This job initialises a given distro series, creating builds, and
984+ populating the archive from the parent distroseries.
985+ """)
986+
987+
988+class IInitialiseDistroSeriesJobSource(IJobSource):
989+ """An interface for acquiring IDistributionJobs."""
990+
991+ def create(distroseries):
992+ """Create a new initialisation job for a distroseries."""
993+
994+
995+class IInitialiseDistroSeriesJob(IRunnableJob):
996+ """A Job that performs actions on a distribution."""
997+>>>>>>> MERGE-SOURCE
998
999=== modified file 'lib/lp/soyuz/model/initialisedistroseriesjob.py'
1000--- lib/lp/soyuz/model/initialisedistroseriesjob.py 2010-10-13 05:04:41 +0000
1001+++ lib/lp/soyuz/model/initialisedistroseriesjob.py 2010-10-16 18:22:51 +0000
1002@@ -1,3 +1,4 @@
1003+<<<<<<< TREE
1004 # Copyright 2010 Canonical Ltd. This software is licensed under the
1005 # GNU Affero General Public License version 3 (see the file LICENSE).
1006
1007@@ -66,3 +67,53 @@
1008 self.rebuild)
1009 ids.check()
1010 ids.initialise()
1011+=======
1012+# Copyright 2010 Canonical Ltd. This software is licensed under the
1013+# GNU Affero General Public License version 3 (see the file LICENSE).
1014+
1015+__metaclass__ = type
1016+
1017+__all__ = [
1018+ "InitialiseDistroSeriesJob",
1019+]
1020+
1021+from zope.interface import classProvides, implements
1022+
1023+from canonical.launchpad.interfaces.lpstorm import IMasterStore
1024+
1025+from lp.soyuz.interfaces.distributionjob import (
1026+ DistributionJobType,
1027+ IInitialiseDistroSeriesJob,
1028+ IInitialiseDistroSeriesJobSource,
1029+ )
1030+from lp.soyuz.model.distributionjob import (
1031+ DistributionJob,
1032+ DistributionJobDerived,
1033+ )
1034+from lp.soyuz.scripts.initialise_distroseries import (
1035+ InitialiseDistroSeries,
1036+ )
1037+
1038+
1039+class InitialiseDistroSeriesJob(DistributionJobDerived):
1040+
1041+ implements(IInitialiseDistroSeriesJob)
1042+
1043+ class_job_type = DistributionJobType.INITIALISE_SERIES
1044+ classProvides(IInitialiseDistroSeriesJobSource)
1045+
1046+ @classmethod
1047+ def create(cls, distroseries):
1048+ """See `IInitialiseDistroSeriesJob`."""
1049+ job = DistributionJob(
1050+ distroseries.distribution, distroseries, cls.class_job_type,
1051+ ())
1052+ IMasterStore(DistributionJob).add(job)
1053+ return cls(job)
1054+
1055+ def run(self):
1056+ """See `IRunnableJob`."""
1057+ ids = InitialiseDistroSeries(self.distroseries)
1058+ ids.check()
1059+ ids.initialise()
1060+>>>>>>> MERGE-SOURCE
1061
1062=== modified file 'lib/lp/soyuz/tests/test_initialisedistroseriesjob.py'
1063--- lib/lp/soyuz/tests/test_initialisedistroseriesjob.py 2010-10-12 07:46:56 +0000
1064+++ lib/lp/soyuz/tests/test_initialisedistroseriesjob.py 2010-10-16 18:22:51 +0000
1065@@ -1,3 +1,4 @@
1066+<<<<<<< TREE
1067 # Copyright 2010 Canonical Ltd. This software is licensed under the
1068 # GNU Affero General Public License version 3 (see the file LICENSE).
1069
1070@@ -88,3 +89,79 @@
1071 self.assertEqual(naked_job.arches, arches)
1072 self.assertEqual(naked_job.packagesets, packagesets)
1073 self.assertEqual(naked_job.rebuild, False)
1074+=======
1075+# Copyright 2010 Canonical Ltd. This software is licensed under the
1076+# GNU Affero General Public License version 3 (see the file LICENSE).
1077+
1078+import transaction
1079+from canonical.testing import LaunchpadZopelessLayer
1080+from storm.exceptions import IntegrityError
1081+from zope.component import getUtility
1082+from zope.security.proxy import removeSecurityProxy
1083+from lp.soyuz.interfaces.distributionjob import (
1084+ IInitialiseDistroSeriesJobSource,
1085+ )
1086+from lp.soyuz.model.initialisedistroseriesjob import (
1087+ InitialiseDistroSeriesJob,
1088+ )
1089+from lp.soyuz.scripts.initialise_distroseries import InitialisationError
1090+from lp.testing import TestCaseWithFactory
1091+
1092+
1093+class InitialiseDistroSeriesJobTests(TestCaseWithFactory):
1094+ """Test case for InitialiseDistroSeriesJob."""
1095+
1096+ layer = LaunchpadZopelessLayer
1097+
1098+ def test_getOopsVars(self):
1099+ distroseries = self.factory.makeDistroSeries()
1100+ job = getUtility(IInitialiseDistroSeriesJobSource).create(
1101+ distroseries)
1102+ vars = job.getOopsVars()
1103+ naked_job = removeSecurityProxy(job)
1104+ self.assertIn(
1105+ ('distribution_id', distroseries.distribution.id), vars)
1106+ self.assertIn(('distroseries_id', distroseries.id), vars)
1107+ self.assertIn(('distribution_job_id', naked_job.context.id), vars)
1108+
1109+ def _getJobs(self):
1110+ """Return the pending InitialiseDistroSeriesJobs as a list."""
1111+ return list(InitialiseDistroSeriesJob.iterReady())
1112+
1113+ def _getJobCount(self):
1114+ """Return the number of InitialiseDistroSeriesJobs in the
1115+ queue."""
1116+ return len(self._getJobs())
1117+
1118+ def test_create_only_creates_one(self):
1119+ distroseries = self.factory.makeDistroSeries()
1120+ # If there's already a InitialiseDistroSeriesJob for a
1121+ # DistroSeries, InitialiseDistroSeriesJob.create() won't create
1122+ # a new one.
1123+ job = getUtility(IInitialiseDistroSeriesJobSource).create(
1124+ distroseries)
1125+ transaction.commit()
1126+
1127+ # There will now be one job in the queue.
1128+ self.assertEqual(1, self._getJobCount())
1129+
1130+ new_job = getUtility(IInitialiseDistroSeriesJobSource).create(
1131+ distroseries)
1132+
1133+ # This is less than ideal
1134+ self.assertRaises(IntegrityError, self._getJobCount)
1135+
1136+ def test_run(self):
1137+ """Test that InitialiseDistroSeriesJob.run() actually
1138+ initialises builds and copies from the parent."""
1139+ distroseries = self.factory.makeDistroSeries()
1140+
1141+ job = getUtility(IInitialiseDistroSeriesJobSource).create(
1142+ distroseries)
1143+
1144+ # Since our new distroseries doesn't have a parent set, and the first
1145+ # thing that run() will execute is checking the distroseries, if it
1146+ # returns an InitialisationError, then it's good.
1147+ self.assertRaisesWithContent(
1148+ InitialisationError, "Parent series required.", job.run)
1149+>>>>>>> MERGE-SOURCE
1150
1151=== modified file 'versions.cfg'
1152--- versions.cfg 2010-10-06 11:46:51 +0000
1153+++ versions.cfg 2010-10-16 18:22:51 +0000
1154@@ -19,7 +19,7 @@
1155 epydoc = 3.0.1
1156 FeedParser = 4.1
1157 feedvalidator = 0.0.0DEV-r1049
1158-fixtures = 0.3
1159+fixtures = 0.3.1
1160 functest = 0.8.7
1161 funkload = 1.10.0
1162 grokcore.component = 1.6