Merge lp:~lifeless/launchpad/paralleltests into lp:launchpad
- paralleltests
- Merge into devel
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 |
Related bugs: |
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.
Robert Collins (lifeless) wrote : | # |
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.
Jonathan Lange (jml) : | # |
Preview Diff
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 |
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' launchpad/ testing/ tests/test_ pages.py 2010-10-04 19:50:45 +0000 launchpad/ testing/ tests/test_ pages.py 2010-10-15 11:52:20 +0000
> --- lib/canonical/
> +++ lib/canonical/
> @@ -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' testing/ parallel. py 1970-01-01 00:00:00 +0000 testing/ parallel. py 2010-10-15 11:52:20 +0000 output_ stream uite,
> --- lib/canonical/
> +++ lib/canonical/
> @@ -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_
> +from testtools import (
> + ConcurrentTestS
> + 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.
> + """ list(argv) read(). split(' \n') if id]
> + load_list = find_load_
> + if load_list:
> + # just use the load_list
> + with open(load_list, 'rt') as list_file:
> + return [id for id in list_file.
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 Popen(argv, stdin=subproces s.PIPE,
> + argv = prepare_argv(argv) + ['--list-tests', '--subunit']
> + process = subprocess.
> + stdout=subproc...