Merge lp:~leonardr/lazr.restful/forbid-reference-to-entry-not-published-in-this-version into lp:lazr.restful

Proposed by Leonard Richardson
Status: Merged
Merged at revision: 183
Proposed branch: lp:~leonardr/lazr.restful/forbid-reference-to-entry-not-published-in-this-version
Merge into: lp:lazr.restful
Prerequisite: lp:~leonardr/lazr.restful/entry-introduced-in-version
Diff against target: 512 lines (+224/-42)
4 files modified
src/lazr/restful/metazcml.py (+112/-16)
src/lazr/restful/publisher.py (+0/-1)
src/lazr/restful/testing/webservice.py (+28/-0)
src/lazr/restful/tests/test_declarations.py (+84/-25)
To merge this branch: bzr merge lp:~leonardr/lazr.restful/forbid-reference-to-entry-not-published-in-this-version
Reviewer Review Type Date Requested Status
Tim Penhey (community) Needs Fixing
Review via email: mp+53918@code.launchpad.net

Description of the change

This branch makes it impossible to define a Reference to an unpublished interface. This might be an interface that's not published *at all*, because you forgot export_as_webservice_entry(): a common mistake and one that I even found in our test suite. Or it might be an interface that is published in some versions but not others. If IFoo shows up starting in 2.0, you can't get IBar to publish a reference to an IFoo in 1.0.

The sanity checking happens after all the registrations are done. (This is necessary because IFoo may be defined after IBar, and the Reference schema patched.) I gather information about each published interface as it's registered. After they've all been registered, I get a list of all the IEntry registrations for different versions. (In practice, these two lists should be the same, but I don't know if I want to rely on that--an application might do some manual registrations or other trickery.)

Then, for each version, for each Reference field in a published interface, I see if the Reference points to some Interface that's registered for that version.

The sanity check code also keeps track of named operation registrations. This is for the next phase of the work, in which you will be prohibited from publishing a named operation in 1.0 if it has arguments or a return value of a type not published in 1.0.

I suspect I have not come up with all the tests necessary to show that this works in general; let me know if you can come up with more.

To post a comment you must log in.
Revision history for this message
Tim Penhey (thumper) wrote :

A simple docstring for webservice_sanity_checks would be nice.

Perhaps a dict string mapping would be better here:
    raise ValueError(
        "In version %s, %s.%s is a Reference to %s, "
        "but version %s of the web service does not publish "
        "%s as an entry. (It may not be published "
        "at all.)" % (
            version, interface.__name__, field.__name__,
            referenced_interface.__name__, version,
            referenced_interface.__name__))
becomes
    raise ValueError(
        "In version %(version)s, %(interface)s.%(field)s is a Reference "
        "to %(reference)s, but version %(version)s of the web service "
        "does not publish %(reference)s as an entry. "
        "(It may not be published at all.)" % {
            'version': version,
            'field': field.__name__,
            'interface': interface.__name__,
            'reference': referenced_interface.__name__}

For your assert raises test, why not grab the copy from testtools?

    def assertRaises(self, excClass, callableObj, *args, **kwargs):
        """Fail unless an exception of class excClass is thrown
           by callableObj when invoked with arguments args and keyword
           arguments kwargs. If a different type of exception is
           thrown, it will not be caught, and the test case will be
           deemed to have suffered an error, exactly as for an
           unexpected exception.
        """
        try:
            ret = callableObj(*args, **kwargs)
        except excClass:
            return sys.exc_info()[1]
        else:
            excName = self._formatTypes(excClass)
            self.fail("%s not raised, %r returned instead." % (excName, ret))

You'd need this too:

    def _formatTypes(self, classOrIterable):
        """Format a class or a bunch of classes for display in an error."""
        className = getattr(classOrIterable, '__name__', None)
        if className is None:
            className = ', '.join(klass.__name__ for klass in classOrIterable)
        return className

test_reference_to_object_published_later_fails exception string test
will always pass as you are only checking that the string isn't empty.

On the whole I think the implementation is fine.

review: Needs Fixing
209. By Leonard Richardson

Replaced _assertRaises with a copy of assertRaises. Made a test pass for the right reasons. Change a complicated string interpolation to use a dict mapping.

210. By Leonard Richardson

Added explanation of why my 'success' test worked right away even though I didn't expect it to.

Revision history for this message
Leonard Richardson (leonardr) wrote :

I figured out why I was worried about not having all the tests. Basically, I didn't expect test_reference_to_object_published_earlier_succeeds() to work and I was surprised that it did. I didn't understand why the code I wrote would find IPublishedEarly in 2.0 when it was defined in 1.0 and not changed afterwards.

The answer is that lazr.restful creates and registers an IEntry adapter for each published entry for each version. It's not smart enough to see that an entry hasn't changed since the last version. Now that lack of optimization has paid off.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/lazr/restful/metazcml.py'
2--- src/lazr/restful/metazcml.py 2011-03-10 14:07:30 +0000
3+++ src/lazr/restful/metazcml.py 2011-03-18 12:22:30 +0000
4@@ -9,24 +9,41 @@
5 import inspect
6 import itertools
7
8-from zope.component import getUtility
9+from zope.component import (
10+ getGlobalSiteManager,
11+ getUtility,
12+ )
13 from zope.component.zcml import handler
14 from zope.configuration.fields import GlobalObject
15 from zope.interface import Interface
16 from zope.interface.interfaces import IInterface
17
18-
19 from lazr.restful.declarations import (
20- COLLECTION_TYPE, ENTRY_TYPE, LAZR_WEBSERVICE_EXPORTED, OPERATION_TYPES,
21- REMOVED_OPERATION_TYPE, generate_collection_adapter,
22- generate_entry_adapters, generate_entry_interfaces,
23- generate_operation_adapter)
24+ COLLECTION_TYPE,
25+ ENTRY_TYPE,
26+ LAZR_WEBSERVICE_EXPORTED,
27+ OPERATION_TYPES,
28+ REMOVED_OPERATION_TYPE,
29+ generate_collection_adapter,
30+ generate_entry_adapters,
31+ generate_entry_interfaces,
32+ generate_operation_adapter,
33+ )
34 from lazr.restful.error import WebServiceExceptionView
35
36 from lazr.restful.interfaces import (
37- ICollection, IEntry, IResourceDELETEOperation, IResourceGETOperation,
38- IResourcePOSTOperation, IWebServiceClientRequest,
39- IWebServiceConfiguration, IWebServiceVersion)
40+ ICollection,
41+ IEntry,
42+ IReference,
43+ IResourceDELETEOperation,
44+ IResourceGETOperation,
45+ IResourcePOSTOperation,
46+ IWebServiceClientRequest,
47+ IWebServiceConfiguration,
48+ IWebServiceVersion,
49+ )
50+
51+from lazr.restful.utils import VersionedObject
52
53
54 class IRegisterDirective(Interface):
55@@ -36,7 +53,8 @@
56 title=u'Module which will be inspected for webservice declarations')
57
58
59-def generate_and_register_entry_adapters(interface, info, contributors):
60+def generate_and_register_entry_adapters(interface, info, contributors,
61+ repository=None):
62 """Generate an entry adapter for every version of the web service.
63
64 This code generates an IEntry subinterface for every version, each
65@@ -54,9 +72,9 @@
66 interface, contributors, *versions)
67 web_factories = generate_entry_adapters(
68 interface, contributors, web_interfaces)
69- for i in range(0, len(web_interfaces)):
70- interface_version, web_interface = web_interfaces[i]
71- factory_version, factory = web_factories[i]
72+ for index, (interface_version, web_interface) in (
73+ enumerate(web_interfaces)):
74+ factory_version, factory = web_factories[index]
75 assert factory_version==interface_version, (
76 "Generated interface and factory versions don't match up! "
77 '%s vs. %s' % (factory_version, interface_version))
78@@ -69,6 +87,11 @@
79 register_adapter_for_version(factory, interface, interface_version,
80 IEntry, '', info)
81
82+ # If we were given a repository, add the interface and version
83+ # to it.
84+ if repository is not None:
85+ repository.append(VersionedObject(interface_version, web_interface))
86+
87
88 def ensure_correct_version_ordering(name, version_list):
89 """Make sure that a list mentions versions from earliest to latest.
90@@ -146,6 +169,7 @@
91 handler('registerAdapter', factory, (interface, marker),
92 provides, name, info)
93
94+
95 def _is_exported_interface(member):
96 """Helper for find_exported_interfaces; a predicate to inspect.getmembers.
97
98@@ -163,6 +187,7 @@
99 return True
100 return False
101
102+
103 def find_exported_interfaces(module):
104 """Find all the interfaces in a module marked for export.
105
106@@ -173,6 +198,7 @@
107 return (interface for name, interface
108 in inspect.getmembers(module, _is_exported_interface))
109
110+
111 def find_interfaces_and_contributors(module):
112 """Find the interfaces and its contributors marked for export.
113
114@@ -254,6 +280,11 @@
115 module, type(module))
116 interfaces_with_contributors = find_interfaces_and_contributors(module)
117
118+ # Keep track of entry and operation registrations so we can
119+ # sanity-check them later.
120+ registered_entries = []
121+ registered_operations = []
122+
123 for interface, contributors in interfaces_with_contributors.items():
124 if issubclass(interface, Exception):
125 register_exception_view(context, interface)
126@@ -264,7 +295,8 @@
127 context.action(
128 discriminator=('webservice entry interface', interface),
129 callable=generate_and_register_entry_adapters,
130- args=(interface, context.info, contributors),
131+ args=(interface, context.info, contributors,
132+ registered_entries),
133 )
134 elif tag['type'] == COLLECTION_TYPE:
135 for version in tag['collection_default_content'].keys():
136@@ -282,12 +314,74 @@
137 raise AssertionError('Unknown export type: %s' % tag['type'])
138 context.action(
139 discriminator=('webservice versioned operations', interface),
140- args=(context, interface, contributors),
141+ args=(context, interface, contributors, registered_operations),
142 callable=generate_and_register_webservice_operations)
143
144+ # Now that all the adapters and operations are registered, run a
145+ # sanity check to make sure no version of the web service
146+ # references a feature not available in that version
147+ context.action(
148+ discriminator=('webservice sanity checks'),
149+ args=(registered_entries, registered_operations),
150+ callable=webservice_sanity_checks)
151+
152+
153+def webservice_sanity_checks(entries, operations):
154+ """Ensure the web service contains no references to unpublished objects.
155+
156+ We are worried about fields that link to unpublished objects, and
157+ operations that have arguments or return values that are
158+ unpublished. An unpublished object may not be published at all, or
159+ it may not be published in the same version in which it's
160+ referenced.
161+ """
162+
163+ # Create a mapping of marker interfaces to version names.
164+ versions = getUtility(IWebServiceConfiguration).active_versions
165+ version_for_marker = { IWebServiceClientRequest: versions[0] }
166+ for version in versions:
167+ marker_interface = getUtility(IWebServiceVersion, name=version)
168+ version_for_marker[marker_interface] = version
169+
170+ # For each version, build a list of all of the IEntries published
171+ # in that version's web service. This works because every IEntry
172+ # is explicitly registered for every version, even if there's been
173+ # no change since the last version. For the sake of performance,
174+ # the list is stored as a set of VersionedObject 2-tuples.
175+ available_registrations = set()
176+ registrations = getGlobalSiteManager().registeredAdapters()
177+ for registration in registrations:
178+ if (not IInterface.providedBy(registration.provided)
179+ or not registration.provided.isOrExtends(IEntry)):
180+ continue
181+ interface, version_marker = registration.required
182+ available_registrations.add(VersionedObject(
183+ version_for_marker[version_marker], interface))
184+
185+ # Check every Reference field in every IEntry interface, making
186+ # sure that it's a Reference to another IEntry published in that
187+ # version.
188+ for version, interface in entries:
189+ if version is None:
190+ version = versions[0]
191+ for name, field in interface.namesAndDescriptions():
192+ if IReference.providedBy(field):
193+ referenced_interface = field.schema
194+ to_check = VersionedObject(version, referenced_interface)
195+ if to_check not in available_registrations:
196+ raise ValueError(
197+ "In version %(version)s, %(interface)s.%(field)s "
198+ "is a Reference to %(reference)s, but version "
199+ "%(version)s of the web service does not publish "
200+ "%(reference)s as an entry. (It may not be published "
201+ "at all.)" % dict(
202+ version=version, interface=interface.__name__,
203+ field=field.__name__,
204+ reference=referenced_interface.__name__))
205+
206
207 def generate_and_register_webservice_operations(
208- context, interface, contributors):
209+ context, interface, contributors, repository=None):
210 """Create and register adapters for all exported methods.
211
212 Different versions of the web service may publish the same
213@@ -458,6 +552,8 @@
214 # the operation registration for this mutator
215 # will need to be blocked.
216 mutator_operation_needs_to_be_blocked = True
217+ if repository is not None:
218+ repository.append(VersionedObject(version, method))
219 previous_operation_name = operation_name
220 previous_operation_provides = operation_provides
221
222
223=== modified file 'src/lazr/restful/publisher.py'
224--- src/lazr/restful/publisher.py 2011-01-26 19:32:05 +0000
225+++ src/lazr/restful/publisher.py 2011-03-18 12:22:30 +0000
226@@ -9,7 +9,6 @@
227 __metaclass__ = type
228 __all__ = [
229 'browser_request_to_web_service_request',
230- 'TraverseWithGet',
231 'WebServicePublicationMixin',
232 'WebServiceRequestTraversal',
233 ]
234
235=== modified file 'src/lazr/restful/testing/webservice.py'
236--- src/lazr/restful/testing/webservice.py 2011-03-10 13:30:32 +0000
237+++ src/lazr/restful/testing/webservice.py 2011-03-18 12:22:30 +0000
238@@ -472,6 +472,34 @@
239 sm.registerUtility(
240 IWebServiceTestRequest20, IWebServiceVersion, name='2.0')
241
242+ def assertRaises(self, excClass, callableObj, *args, **kwargs):
243+ """Fail unless an exception of class excClass is thrown
244+ by callableObj when invoked with arguments args and keyword
245+ arguments kwargs. If a different type of exception is
246+ thrown, it will not be caught, and the test case will be
247+ deemed to have suffered an error, exactly as for an
248+ unexpected exception.
249+
250+ This is a copy of assertRaises from testtools.
251+ """
252+ try:
253+ ret = callableObj(*args, **kwargs)
254+ except excClass:
255+ return sys.exc_info()[1]
256+ else:
257+ excName = self._formatTypes(excClass)
258+ self.fail("%s not raised, %r returned instead." % (excName, ret))
259+
260+ def _formatTypes(self, classOrIterable):
261+ """Format a class or a bunch of classes for display in an error.
262+
263+ This is a copy of _formatTypes from testtools.
264+ """
265+ className = getattr(classOrIterable, '__name__', None)
266+ if className is None:
267+ className = ', '.join(klass.__name__ for klass in classOrIterable)
268+ return className
269+
270 def fake_request(self, version):
271 request = FakeRequest(version=version)
272 alsoProvides(
273
274=== modified file 'src/lazr/restful/tests/test_declarations.py'
275--- src/lazr/restful/tests/test_declarations.py 2011-03-18 12:22:30 +0000
276+++ src/lazr/restful/tests/test_declarations.py 2011-03-18 12:22:30 +0000
277@@ -1,3 +1,4 @@
278+from zope.configuration.config import ConfigurationExecutionError
279 from zope.component import (
280 adapts,
281 getMultiAdapter,
282@@ -102,7 +103,7 @@
283 # with its original name whereas for the '2.0' version it's exported
284 # as 'development_branch_20'.
285 self.product._dev_branch = Branch('A product branch')
286- register_test_module('testmod', IProduct, IHasBranches)
287+ register_test_module('testmod', IBranch, IProduct, IHasBranches)
288 adapter = getMultiAdapter((self.product, self.one_zero_request), IEntry)
289 self.assertEqual(adapter.development_branch, self.product._dev_branch)
290
291@@ -116,7 +117,7 @@
292 # development_branch field, but on version '2.0' that field can be
293 # modified as we define a mutator for it.
294 self.product._dev_branch = Branch('A product branch')
295- register_test_module('testmod', IProduct, IHasBranches)
296+ register_test_module('testmod', IBranch, IProduct, IHasBranches)
297 adapter = getMultiAdapter((self.product, self.one_zero_request), IEntry)
298 try:
299 adapter.development_branch = None
300@@ -151,7 +152,8 @@
301 # from both IHasBugs and IHasBranches.
302 self.product._bug_count = 10
303 self.product._dev_branch = Branch('A product branch')
304- register_test_module('testmod', IProduct, IHasBugs, IHasBranches)
305+ register_test_module(
306+ 'testmod', IBranch, IProduct, IHasBugs, IHasBranches)
307 adapter = getMultiAdapter((self.product, self.one_zero_request), IEntry)
308 self.assertEqual(adapter.bug_count, 10)
309 self.assertEqual(adapter.development_branch, self.product._dev_branch)
310@@ -172,7 +174,8 @@
311 # When looking up an entry's redacted_fields, we take into account the
312 # interface where the field is defined and adapt the context to that
313 # interface before accessing that field.
314- register_test_module('testmod', IProduct, IHasBugs, IHasBranches)
315+ register_test_module(
316+ 'testmod', IBranch, IProduct, IHasBugs, IHasBranches)
317 entry_resource = EntryResource(self.product, self.one_zero_request)
318 self.assertEquals([], entry_resource.redacted_fields)
319
320@@ -180,7 +183,8 @@
321 # When looking up an entry's redacted_fields for an object which is
322 # security proxied, we use the security checker for the interface
323 # where the field is defined.
324- register_test_module('testmod', IProduct, IHasBugs, IHasBranches)
325+ register_test_module(
326+ 'testmod', IBranch, IProduct, IHasBugs, IHasBranches)
327 newInteraction()
328 try:
329 secure_product = ProxyFactory(
330@@ -194,7 +198,8 @@
331 def test_duplicate_contributed_attributes(self):
332 # We do not allow a given attribute to be contributed to a given
333 # interface by more than one contributing interface.
334- testmod = create_test_module('testmod', IProduct, IHasBugs, IHasBugs2)
335+ testmod = create_test_module(
336+ 'testmod', IBranch, IProduct, IHasBugs, IHasBugs2)
337 self.assertRaises(
338 ConflictInContributingInterfaces,
339 find_interfaces_and_contributors, testmod)
340@@ -205,7 +210,7 @@
341 class DummyHasBranches:
342 implements(IHasBranches)
343 dummy = DummyHasBranches()
344- register_test_module('testmod', IProduct, IHasBranches)
345+ register_test_module('testmod', IBranch, IProduct, IHasBranches)
346 self.assertRaises(
347 ComponentLookupError,
348 getMultiAdapter, (dummy, self.one_zero_request), IEntry)
349@@ -318,6 +323,7 @@
350
351
352 class IBranch(Interface):
353+ export_as_webservice_entry()
354 name = TextLine(title=u'The branch name')
355
356
357@@ -552,16 +558,6 @@
358 self.utility = getUtility(IWebServiceConfiguration)
359 self.utility.require_explicit_versions = True
360
361- def _assertRaises(self, exception, func, *args, **kwargs):
362- # Approximate the behavior of testtools assertRaises, which
363- # returns the raised exception. I couldn't get
364- # testtools.TestCase to play nicely with zope's Cleanup class.
365- self.assertRaises(exception, func, *args, **kwargs)
366- try:
367- func(*args, **kwargs)
368- except Exception, e:
369- return e
370-
371 def test_entry_exported_with_as_of_succeeds(self):
372 # An entry exported using as_of is present in the as_of_version
373 # and in subsequent versions.
374@@ -582,7 +578,7 @@
375 self.assertEquals(interfaces[0].version, '2.0')
376
377 def test_entry_exported_without_as_of_fails(self):
378- exception = self._assertRaises(
379+ exception = self.assertRaises(
380 ValueError, generate_entry_interfaces,
381 IEntryExportedWithoutAsOf, [],
382 *self.utility.active_versions)
383@@ -618,7 +614,7 @@
384 def test_field_not_exported_using_as_of_fails(self):
385 # If you export a field without specifying as_of, you get an
386 # error.
387- exception = self._assertRaises(
388+ exception = self.assertRaises(
389 ValueError, generate_entry_interfaces,
390 IFieldExportedWithoutAsOf, [], *self.utility.active_versions)
391 self.assertEquals(
392@@ -631,7 +627,7 @@
393 def test_field_cannot_be_both_exported_and_not_exported(self):
394 # If you use the as_of keyword argument, you can't also set
395 # the exported keyword argument to False.
396- exception = self._assertRaises(
397+ exception = self.assertRaises(
398 ValueError, exported, TextLine(), as_of='1.0', exported=False)
399 self.assertEquals(
400 str(exception),
401@@ -640,7 +636,7 @@
402
403 def test_field_exported_as_of_nonexistent_version_fails(self):
404 # You can't export a field as_of a nonexistent version.
405- exception = self._assertRaises(
406+ exception = self.assertRaises(
407 ValueError, generate_entry_interfaces,
408 IFieldAsOfNonexistentVersion, [],
409 *self.utility.active_versions)
410@@ -652,7 +648,7 @@
411 def test_field_exported_with_duplicate_attributes_fails(self):
412 # You can't provide a dictionary of attributes for the
413 # version specified in as_of.
414- exception = self._assertRaises(
415+ exception = self.assertRaises(
416 ValueError, generate_entry_interfaces,
417 IFieldDoubleDefinition, [], *self.utility.active_versions)
418 self.assertEquals(
419@@ -663,7 +659,7 @@
420 def test_field_with_annotations_that_precede_as_of_fails(self):
421 # You can't provide a dictionary of attributes for a version
422 # preceding the version specified in as_of.
423- exception = self._assertRaises(
424+ exception = self.assertRaises(
425 ValueError, generate_entry_interfaces,
426 IFieldDefiningAttributesBeforeAsOf, [],
427 *self.utility.active_versions)
428@@ -682,7 +678,7 @@
429 method = [method for name, method in
430 IFieldImplicitOperationDefinition.namesAndDescriptions()
431 if name == 'implicitly_in_10'][0]
432- exception = self._assertRaises(
433+ exception = self.assertRaises(
434 ValueError, generate_operation_adapter, method, None)
435 self.assertEquals(
436 str(exception),
437@@ -694,7 +690,7 @@
438
439 def test_operation_implicitly_exported_in_earliest_version_fails(self):
440 # You can't implicitly define an operation for the earliest version.
441- exception = self._assertRaises(
442+ exception = self.assertRaises(
443 ValueError, generate_and_register_webservice_operations,
444 None, IFieldImplicitOperationDefinition, [])
445 self.assertEquals(
446@@ -720,3 +716,66 @@
447 self.assertEquals(
448 adapter.__class__.__name__,
449 'GET_IFieldExplicitOperationDefinition_explicitly_in_10_1_0')
450+
451+
452+# Classes for TestSanityChecking
453+
454+class INotPublished(Interface):
455+ pass
456+
457+
458+class IReferencesUnpublishedObject(Interface):
459+ export_as_webservice_entry()
460+ field = exported(Reference(schema=INotPublished))
461+
462+
463+class IPublishedTooLate(Interface):
464+ export_as_webservice_entry(as_of='2.0')
465+
466+
467+class IReferencesPublishedTooLate(Interface):
468+ export_as_webservice_entry(as_of='1.0')
469+ field = exported(Reference(schema=IPublishedTooLate))
470+
471+
472+class IPublishedEarly(Interface):
473+ export_as_webservice_entry(as_of='1.0')
474+
475+
476+class IPublishedLate(Interface):
477+ export_as_webservice_entry(as_of='2.0')
478+ field = exported(Reference(schema=IPublishedEarly))
479+
480+
481+class TestSanityChecking(TestCaseWithWebServiceFixtures):
482+ """Test lazr.restful's sanity checking upon web service registration."""
483+
484+ def test_reference_to_unpublished_object_fails(self):
485+ exception = self.assertRaises(
486+ ConfigurationExecutionError, register_test_module, 'testmod',
487+ INotPublished, IReferencesUnpublishedObject)
488+ self.assertTrue(
489+ ("In version 1.0, IReferencesUnpublishedObjectEntry_1_0.field "
490+ "is a Reference to INotPublished, but version 1.0 of the web "
491+ "service does not publish INotPublished as an entry. "
492+ "(It may not be published at all.)") in str(exception))
493+
494+ def test_reference_to_object_published_later_fails(self):
495+ exception = self.assertRaises(
496+ ConfigurationExecutionError, register_test_module, 'testmod',
497+ IPublishedTooLate, IReferencesPublishedTooLate)
498+ self.assertTrue(
499+ ("In version 1.0, IReferencesPublishedTooLateEntry_1_0.field is "
500+ "a Reference to IPublishedTooLate, but version 1.0 of the web "
501+ "service does not publish IPublishedTooLate as an entry. (It "
502+ "may not be published at all.)") in str(exception))
503+
504+ def test_reference_to_object_published_earlier_succeeds(self):
505+ # It's okay for an object defined in 2.0 to reference an
506+ # object first defined in 1.0, so long as the referenced
507+ # object is also present in 2.0.
508+
509+ # We'll call this test a success if it doesn't raise an exception.
510+ module = register_test_module(
511+ 'testmod', IPublishedEarly, IPublishedLate)
512+

Subscribers

People subscribed via source and target branches