Merge lp:~stevenk/launchpad/bugs-use-information_type-redux into lp:launchpad

Proposed by Steve Kowalik
Status: Merged
Approved by: Steve Kowalik
Approved revision: no longer in the source branch.
Merged at revision: 15009
Proposed branch: lp:~stevenk/launchpad/bugs-use-information_type-redux
Merge into: lp:launchpad
Diff against target: 399 lines (+96/-44)
9 files modified
lib/lp/bugs/adapters/bug.py (+16/-1)
lib/lp/bugs/browser/tests/test_bugsubscription_views.py (+1/-5)
lib/lp/bugs/mail/tests/test_handler.py (+2/-2)
lib/lp/bugs/model/bug.py (+32/-23)
lib/lp/bugs/model/tests/test_bug.py (+19/-0)
lib/lp/bugs/tests/test_bug_mirror_access_triggers.py (+3/-3)
lib/lp/registry/enums.py (+11/-0)
lib/lp/registry/model/person.py (+2/-2)
lib/lp/services/feeds/stories/xx-security.txt (+10/-8)
To merge this branch: bzr merge lp:~stevenk/launchpad/bugs-use-information_type-redux
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+99236@code.launchpad.net

Commit message

Resurrect use of IBug.information_type, setting it properly at creation time.

Description of the change

Resurrect the rollback of using IBug.information_type that happened in r15006. The original MPs description is as follows:

Rename IBug.{private,security_related} to IBug._{private,security_related} as the next step in switching to information_type. This branch also adds IBug.{private,security_related} back as a property that depends on information_type if it is set.

Fixed some tests that assumed that IBug.{private,security_related} was something they could set directly.

I cleaned up a small amount of lint.

Further to those changes, I have created a new method, convert_to_information_type, which returns the relevant InformationType given a private and security_related parameter, which allows me to remove IBug._setInformationType() and to set the information_type of the bug right at the initial insert. I have added a test which checks that case.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) wrote :

251 + def test_information_type_does_not_leak(self):
252 + product = self.factory.makeProduct()

Not entirely descriptive :)

Also:

10:52:57 < wgrant> StevenK: I think I mentioned this in the last review, but from the diff it looks like bug.private writes were previously permitted by the security policy, but I don't see any ZCML changes in this brnach to prevent that.
10:58:14 < StevenK> wgrant: wgrant: private/security_related aren't in the ZCML, so I'm not sure how to tell it "forbid anyone setting these"
11:00:04 < wgrant> StevenK: Indeed. I wonder if the removeSecurityProxy is really required, then

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/lp/bugs/adapters/bug.py'
--- lib/lp/bugs/adapters/bug.py 2010-08-20 20:31:18 +0000
+++ lib/lp/bugs/adapters/bug.py 2012-03-26 00:18:19 +0000
@@ -1,4 +1,4 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the1# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Resources having to do with Launchpad bugs."""4"""Resources having to do with Launchpad bugs."""
@@ -7,11 +7,14 @@
7__all__ = [7__all__ = [
8 'bugcomment_to_entry',8 'bugcomment_to_entry',
9 'bugtask_to_privacy',9 'bugtask_to_privacy',
10 'convert_to_information_type',
10 ]11 ]
1112
12from lazr.restful.interfaces import IEntry13from lazr.restful.interfaces import IEntry
13from zope.component import getMultiAdapter14from zope.component import getMultiAdapter
1415
16from lp.registry.enums import InformationType
17
1518
16def bugcomment_to_entry(comment, version):19def bugcomment_to_entry(comment, version):
17 """Will adapt to the bugcomment to the real IMessage.20 """Will adapt to the bugcomment to the real IMessage.
@@ -22,9 +25,21 @@
22 return getMultiAdapter(25 return getMultiAdapter(
23 (comment.bugtask.bug.messages[comment.index], version), IEntry)26 (comment.bugtask.bug.messages[comment.index], version), IEntry)
2427
28
25def bugtask_to_privacy(bugtask):29def bugtask_to_privacy(bugtask):
26 """Adapt the bugtask to the underlying bug (which implements IPrivacy).30 """Adapt the bugtask to the underlying bug (which implements IPrivacy).
2731
28 Needed because IBugTask does not implement IPrivacy.32 Needed because IBugTask does not implement IPrivacy.
29 """33 """
30 return bugtask.bug34 return bugtask.bug
35
36
37def convert_to_information_type(private, security_related):
38 if private and security_related:
39 return InformationType.EMBARGOEDSECURITY
40 elif security_related:
41 return InformationType.UNEMBARGOEDSECURITY
42 elif private:
43 return InformationType.USERDATA
44 else:
45 return InformationType.PUBLIC
3146
=== modified file 'lib/lp/bugs/browser/tests/test_bugsubscription_views.py'
--- lib/lp/bugs/browser/tests/test_bugsubscription_views.py 2012-03-24 04:42:32 +0000
+++ lib/lp/bugs/browser/tests/test_bugsubscription_views.py 2012-03-26 00:18:19 +0000
@@ -40,10 +40,6 @@
40from lp.testing.views import create_initialized_view40from lp.testing.views import create_initialized_view
4141
4242
43ON = 'on'
44OFF = None
45
46
47class BugsubscriptionPrivacyTests(TestCaseWithFactory):43class BugsubscriptionPrivacyTests(TestCaseWithFactory):
4844
49 layer = LaunchpadFunctionalLayer45 layer = LaunchpadFunctionalLayer
@@ -52,7 +48,7 @@
52 super(BugsubscriptionPrivacyTests, self).setUp()48 super(BugsubscriptionPrivacyTests, self).setUp()
53 self.user = self.factory.makePerson()49 self.user = self.factory.makePerson()
54 self.bug = self.factory.makeBug(owner=self.user)50 self.bug = self.factory.makeBug(owner=self.user)
55 removeSecurityProxy(self.bug).private = True51 removeSecurityProxy(self.bug).setPrivate(True, self.user)
5652
57 def _assert_subscription_fails(self, team):53 def _assert_subscription_fails(self, team):
58 with person_logged_in(self.user):54 with person_logged_in(self.user):
5955
=== modified file 'lib/lp/bugs/mail/tests/test_handler.py'
--- lib/lp/bugs/mail/tests/test_handler.py 2012-03-24 04:42:32 +0000
+++ lib/lp/bugs/mail/tests/test_handler.py 2012-03-26 00:18:19 +0000
@@ -287,7 +287,7 @@
287 notification = self.getLatestBugNotification()287 notification = self.getLatestBugNotification()
288 bug = notification.bug288 bug = notification.bug
289 self.assertEqual('unsecure code', bug.title)289 self.assertEqual('unsecure code', bug.title)
290 self.assertEqual(True, bug.security_related)290 self.assertTrue(bug.security_related)
291 self.assertEqual(['ajax'], bug.tags)291 self.assertEqual(['ajax'], bug.tags)
292 self.assertEqual(1, len(bug.bugtasks))292 self.assertEqual(1, len(bug.bugtasks))
293 self.assertEqual(project, bug.bugtasks[0].target)293 self.assertEqual(project, bug.bugtasks[0].target)
@@ -310,7 +310,7 @@
310 notification = self.getLatestBugNotification()310 notification = self.getLatestBugNotification()
311 bug = notification.bug311 bug = notification.bug
312 self.assertEqual('security issue', bug.title)312 self.assertEqual('security issue', bug.title)
313 self.assertEqual(True, bug.security_related)313 self.assertTrue(bug.security_related)
314 self.assertEqual(1, len(bug.bugtasks))314 self.assertEqual(1, len(bug.bugtasks))
315 self.assertEqual(project, bug.bugtasks[0].target)315 self.assertEqual(project, bug.bugtasks[0].target)
316 recipients = set()316 recipients = set()
317317
=== modified file 'lib/lp/bugs/model/bug.py'
--- lib/lp/bugs/model/bug.py 2012-03-24 04:42:32 +0000
+++ lib/lp/bugs/model/bug.py 2012-03-26 00:18:19 +0000
@@ -89,6 +89,7 @@
89 )89 )
90from lp.app.interfaces.launchpad import ILaunchpadCelebrities90from lp.app.interfaces.launchpad import ILaunchpadCelebrities
91from lp.app.validators import LaunchpadValidationError91from lp.app.validators import LaunchpadValidationError
92from lp.bugs.adapters.bug import convert_to_information_type
92from lp.bugs.adapters.bugchange import (93from lp.bugs.adapters.bugchange import (
93 BranchLinkedToBug,94 BranchLinkedToBug,
94 BranchUnlinkedFromBug,95 BranchUnlinkedFromBug,
@@ -157,7 +158,11 @@
157 )158 )
158from lp.code.interfaces.branchcollection import IAllBranches159from lp.code.interfaces.branchcollection import IAllBranches
159from lp.hardwaredb.interfaces.hwdb import IHWSubmissionBugSet160from lp.hardwaredb.interfaces.hwdb import IHWSubmissionBugSet
160from lp.registry.enums import InformationType161from lp.registry.enums import (
162 InformationType,
163 PRIVATE_INFORMATION_TYPES,
164 SECURITY_INFORMATION_TYPES,
165 )
161from lp.registry.interfaces.distribution import IDistribution166from lp.registry.interfaces.distribution import IDistribution
162from lp.registry.interfaces.distroseries import IDistroSeries167from lp.registry.interfaces.distroseries import IDistroSeries
163from lp.registry.interfaces.person import (168from lp.registry.interfaces.person import (
@@ -347,12 +352,13 @@
347 dbName='duplicateof', foreignKey='Bug', default=None)352 dbName='duplicateof', foreignKey='Bug', default=None)
348 datecreated = UtcDateTimeCol(notNull=True, default=UTC_NOW)353 datecreated = UtcDateTimeCol(notNull=True, default=UTC_NOW)
349 date_last_updated = UtcDateTimeCol(notNull=True, default=UTC_NOW)354 date_last_updated = UtcDateTimeCol(notNull=True, default=UTC_NOW)
350 private = BoolCol(notNull=True, default=False)355 _private = BoolCol(dbName='private', notNull=True, default=False)
351 date_made_private = UtcDateTimeCol(notNull=False, default=None)356 date_made_private = UtcDateTimeCol(notNull=False, default=None)
352 who_made_private = ForeignKey(357 who_made_private = ForeignKey(
353 dbName='who_made_private', foreignKey='Person',358 dbName='who_made_private', foreignKey='Person',
354 storm_validator=validate_public_person, default=None)359 storm_validator=validate_public_person, default=None)
355 security_related = BoolCol(notNull=True, default=False)360 _security_related = BoolCol(
361 dbName='security_related', notNull=True, default=False)
356 information_type = EnumCol(362 information_type = EnumCol(
357 enum=InformationType, default=InformationType.PUBLIC)363 enum=InformationType, default=InformationType.PUBLIC)
358364
@@ -389,6 +395,20 @@
389 heat_last_updated = UtcDateTimeCol(default=None)395 heat_last_updated = UtcDateTimeCol(default=None)
390 latest_patch_uploaded = UtcDateTimeCol(default=None)396 latest_patch_uploaded = UtcDateTimeCol(default=None)
391397
398 @property
399 def private(self):
400 if self.information_type:
401 return self.information_type in PRIVATE_INFORMATION_TYPES
402 else:
403 return self._private
404
405 @property
406 def security_related(self):
407 if self.information_type:
408 return self.information_type in SECURITY_INFORMATION_TYPES
409 else:
410 return self._security_related
411
392 @cachedproperty412 @cachedproperty
393 def _subscriber_cache(self):413 def _subscriber_cache(self):
394 """Caches known subscribers."""414 """Caches known subscribers."""
@@ -1697,16 +1717,6 @@
16971717
1698 return bugtask1718 return bugtask
16991719
1700 def _setInformationType(self):
1701 if self.private and self.security_related:
1702 self.information_type = InformationType.EMBARGOEDSECURITY
1703 elif self.private:
1704 self.information_type = InformationType.USERDATA
1705 elif self.security_related:
1706 self.information_type = InformationType.UNEMBARGOEDSECURITY
1707 else:
1708 self.information_type = InformationType.PUBLIC
1709
1710 def setPrivacyAndSecurityRelated(self, private, security_related, who):1720 def setPrivacyAndSecurityRelated(self, private, security_related, who):
1711 """ See `IBug`."""1721 """ See `IBug`."""
1712 private_changed = False1722 private_changed = False
@@ -1734,7 +1744,7 @@
1734 raise BugCannotBePrivate(1744 raise BugCannotBePrivate(
1735 "Multi-pillar bugs cannot be private.")1745 "Multi-pillar bugs cannot be private.")
1736 private_changed = True1746 private_changed = True
1737 self.private = private1747 self._private = private
17381748
1739 if private:1749 if private:
1740 self.who_made_private = who1750 self.who_made_private = who
@@ -1750,14 +1760,15 @@
17501760
1751 if self.security_related != security_related:1761 if self.security_related != security_related:
1752 security_related_changed = True1762 security_related_changed = True
1753 self.security_related = security_related1763 self._security_related = security_related
17541764
1755 if private_changed or security_related_changed:1765 if private_changed or security_related_changed:
1756 # Correct the heat for the bug immediately, so that we don't have1766 # Correct the heat for the bug immediately, so that we don't have
1757 # to wait for the next calculation job for the adjusted heat.1767 # to wait for the next calculation job for the adjusted heat.
1758 self.updateHeat()1768 self.updateHeat()
17591769
1760 self._setInformationType()1770 self.information_type = convert_to_information_type(
1771 self._private, self._security_related)
17611772
1762 if private_changed or security_related_changed:1773 if private_changed or security_related_changed:
1763 changed_fields = []1774 changed_fields = []
@@ -2829,9 +2840,6 @@
2829 bug.subscribe(params.product.bug_supervisor, params.owner)2840 bug.subscribe(params.product.bug_supervisor, params.owner)
2830 else:2841 else:
2831 bug.subscribe(params.product.owner, params.owner)2842 bug.subscribe(params.product.owner, params.owner)
2832 else:
2833 # nothing to do
2834 pass
28352843
2836 # Create the task on a product if one was passed.2844 # Create the task on a product if one was passed.
2837 if params.product:2845 if params.product:
@@ -2858,8 +2866,6 @@
2858 if notify_event:2866 if notify_event:
2859 notify(event)2867 notify(event)
28602868
2861 bug._setInformationType()
2862
2863 # Calculate the bug's initial heat.2869 # Calculate the bug's initial heat.
2864 bug.updateHeat()2870 bug.updateHeat()
28652871
@@ -2906,11 +2912,14 @@
2906 date_made_private=params.datecreated,2912 date_made_private=params.datecreated,
2907 who_made_private=params.owner)2913 who_made_private=params.owner)
29082914
2915 information_type = convert_to_information_type(
2916 params.private, params.security_related)
2909 bug = Bug(2917 bug = Bug(
2910 title=params.title, description=params.description,2918 title=params.title, description=params.description,
2911 private=params.private, owner=params.owner,2919 _private=params.private, owner=params.owner,
2912 datecreated=params.datecreated,2920 datecreated=params.datecreated,
2913 security_related=params.security_related,2921 _security_related=params.security_related,
2922 information_type=information_type,
2914 **extra_params)2923 **extra_params)
29152924
2916 if params.subscribe_owner:2925 if params.subscribe_owner:
29172926
=== modified file 'lib/lp/bugs/model/tests/test_bug.py'
--- lib/lp/bugs/model/tests/test_bug.py 2012-03-14 12:39:42 +0000
+++ lib/lp/bugs/model/tests/test_bug.py 2012-03-26 00:18:19 +0000
@@ -9,6 +9,7 @@
9 )9 )
1010
11from pytz import UTC11from pytz import UTC
12from storm.expr import Join
12from storm.store import Store13from storm.store import Store
13from testtools.testcase import ExpectedException14from testtools.testcase import ExpectedException
14from zope.component import getUtility15from zope.component import getUtility
@@ -970,6 +971,24 @@
970 bug.setPrivate(True, bug.owner)971 bug.setPrivate(True, bug.owner)
971 self.assertEqual(InformationType.USERDATA, bug.information_type)972 self.assertEqual(InformationType.USERDATA, bug.information_type)
972973
974 def test_information_type_does_not_leak(self):
975 # Make sure that bug notifications for private bugs do not leak to
976 # people with a subscription on the product.
977 product = self.factory.makeProduct()
978 with person_logged_in(product.owner):
979 product.addSubscription(product.owner, product.owner)
980 reporter = self.factory.makePerson()
981 bug = self.factory.makeBug(
982 private=True, product=product, owner=reporter)
983 recipients = Store.of(bug).using(
984 BugNotificationRecipient,
985 Join(BugNotification, BugNotification.bugID == bug.id)).find(
986 BugNotificationRecipient,
987 BugNotificationRecipient.bug_notificationID ==
988 BugNotification.id)
989 self.assertEqual(
990 [reporter], [recipient.person for recipient in recipients])
991
973992
974class TestBugPrivateAndSecurityRelatedUpdatesPrivateProject(993class TestBugPrivateAndSecurityRelatedUpdatesPrivateProject(
975 TestBugPrivateAndSecurityRelatedUpdatesMixin, TestCaseWithFactory):994 TestBugPrivateAndSecurityRelatedUpdatesMixin, TestCaseWithFactory):
976995
=== modified file 'lib/lp/bugs/tests/test_bug_mirror_access_triggers.py'
--- lib/lp/bugs/tests/test_bug_mirror_access_triggers.py 2012-03-24 04:42:32 +0000
+++ lib/lp/bugs/tests/test_bug_mirror_access_triggers.py 2012-03-26 00:18:19 +0000
@@ -136,7 +136,7 @@
136 bug = self.makeBugAndPolicies(private=True)136 bug = self.makeBugAndPolicies(private=True)
137 self.assertIsNot(137 self.assertIsNot(
138 None, getUtility(IAccessArtifactSource).find([bug]).one())138 None, getUtility(IAccessArtifactSource).find([bug]).one())
139 bug.private = False139 bug.setPrivate(False, bug.owner)
140 self.assertIs(140 self.assertIs(
141 None, getUtility(IAccessArtifactSource).find([bug]).one())141 None, getUtility(IAccessArtifactSource).find([bug]).one())
142142
@@ -144,7 +144,7 @@
144 bug = self.makeBugAndPolicies(private=False)144 bug = self.makeBugAndPolicies(private=False)
145 self.assertIs(145 self.assertIs(
146 None, getUtility(IAccessArtifactSource).find([bug]).one())146 None, getUtility(IAccessArtifactSource).find([bug]).one())
147 bug.private = True147 bug.setPrivate(True, bug.owner)
148 self.assertIsNot(148 self.assertIsNot(
149 None, getUtility(IAccessArtifactSource).find([bug]).one())149 None, getUtility(IAccessArtifactSource).find([bug]).one())
150 self.assertEqual((1, 1), self.assertMirrored(bug))150 self.assertEqual((1, 1), self.assertMirrored(bug))
@@ -158,7 +158,7 @@
158 self.assertContentEqual(158 self.assertContentEqual(
159 [InformationType.USERDATA],159 [InformationType.USERDATA],
160 self.getPolicyTypesForArtifact(artifact))160 self.getPolicyTypesForArtifact(artifact))
161 bug.security_related = True161 bug.setSecurityRelated(True, bug.owner)
162 self.assertEqual((1, 1), self.assertMirrored(bug))162 self.assertEqual((1, 1), self.assertMirrored(bug))
163 self.assertContentEqual(163 self.assertContentEqual(
164 [InformationType.EMBARGOEDSECURITY],164 [InformationType.EMBARGOEDSECURITY],
165165
=== modified file 'lib/lp/registry/enums.py'
--- lib/lp/registry/enums.py 2012-03-24 04:42:32 +0000
+++ lib/lp/registry/enums.py 2012-03-26 00:18:19 +0000
@@ -9,7 +9,9 @@
9 'DistroSeriesDifferenceType',9 'DistroSeriesDifferenceType',
10 'InformationType',10 'InformationType',
11 'PersonTransferJobType',11 'PersonTransferJobType',
12 'PRIVATE_INFORMATION_TYPES',
12 'ProductJobType',13 'ProductJobType',
14 'SECURITY_INFORMATION_TYPES',
13 'SharingPermission',15 'SharingPermission',
14 ]16 ]
1517
@@ -61,6 +63,15 @@
61 """)63 """)
6264
6365
66PRIVATE_INFORMATION_TYPES = (
67 InformationType.EMBARGOEDSECURITY, InformationType.USERDATA,
68 InformationType.PROPRIETARY)
69
70
71SECURITY_INFORMATION_TYPES = (
72 InformationType.UNEMBARGOEDSECURITY, InformationType.EMBARGOEDSECURITY)
73
74
64class SharingPermission(DBEnumeratedType):75class SharingPermission(DBEnumeratedType):
65 """Sharing permission.76 """Sharing permission.
6677
6778
=== modified file 'lib/lp/registry/model/person.py'
--- lib/lp/registry/model/person.py 2012-03-24 04:42:32 +0000
+++ lib/lp/registry/model/person.py 2012-03-26 00:18:19 +0000
@@ -1744,14 +1744,14 @@
1744 Bug,1744 Bug,
1745 Join(BugSubscription, BugSubscription.bug_id == Bug.id)),1745 Join(BugSubscription, BugSubscription.bug_id == Bug.id)),
1746 where=And(1746 where=And(
1747 Bug.private == True,1747 Bug._private == True,
1748 BugSubscription.person_id == self.id)),1748 BugSubscription.person_id == self.id)),
1749 Select(1749 Select(
1750 Bug.id,1750 Bug.id,
1751 tables=(1751 tables=(
1752 Bug,1752 Bug,
1753 Join(BugTask, BugTask.bugID == Bug.id)),1753 Join(BugTask, BugTask.bugID == Bug.id)),
1754 where=And(Bug.private == True, BugTask.assignee == self.id)),1754 where=And(Bug._private == True, BugTask.assignee == self.id)),
1755 limit=1))1755 limit=1))
1756 if private_bugs_involved.rowcount:1756 if private_bugs_involved.rowcount:
1757 raise TeamSubscriptionPolicyError(1757 raise TeamSubscriptionPolicyError(
17581758
=== modified file 'lib/lp/services/feeds/stories/xx-security.txt'
--- lib/lp/services/feeds/stories/xx-security.txt 2012-03-24 04:42:32 +0000
+++ lib/lp/services/feeds/stories/xx-security.txt 2012-03-26 00:18:19 +0000
@@ -1,16 +1,16 @@
1== Feeds do not display private bugs ==1Feeds do not display private bugs
2=================================
23
3Feeds never contain private bugs, as we are serving feeds over HTTP.4Feeds never contain private bugs, as we are serving feeds over HTTP.
4First, set all the bugs to private.5First, set all the bugs to private.
56
6 >>> from zope.security.interfaces import Unauthorized7 >>> from zope.security.interfaces import Unauthorized
7 >>> from BeautifulSoup import BeautifulStoneSoup as BSS8 >>> from BeautifulSoup import BeautifulStoneSoup as BSS
9 >>> from lp.services.database.lpstorm import IStore
10 >>> import transaction
8 >>> from lp.bugs.model.bug import Bug11 >>> from lp.bugs.model.bug import Bug
9 >>> bugs = Bug.select()12 >>> IStore(Bug).find(Bug).set(_private=True)
10 >>> for bug in bugs:13 >>> transaction.commit()
11 ... bug.private = True
12 >>> from lp.services.database.sqlbase import flush_database_updates
13 >>> flush_database_updates()
1414
15There should be zero entries in these feeds, since all the bugs are private.15There should be zero entries in these feeds, since all the bugs are private.
1616
@@ -26,7 +26,8 @@
26 >>> BSS(browser.contents)('entry')26 >>> BSS(browser.contents)('entry')
27 []27 []
2828
29 >>> browser.open('http://feeds.launchpad.dev/~simple-team/latest-bugs.atom')29 >>> browser.open(
30 ... 'http://feeds.launchpad.dev/~simple-team/latest-bugs.atom')
30 >>> BSS(browser.contents)('entry')31 >>> BSS(browser.contents)('entry')
31 []32 []
3233
@@ -66,7 +67,8 @@
66 >>> print extract_text(BSS(browser.contents)('tr')[0])67 >>> print extract_text(BSS(browser.contents)('tr')[0])
67 Bugs for Foo Bar68 Bugs for Foo Bar
6869
69 >>> browser.open('http://feeds.launchpad.dev/~simple-team/latest-bugs.html')70 >>> browser.open(
71 ... 'http://feeds.launchpad.dev/~simple-team/latest-bugs.html')
70 >>> len(BSS(browser.contents)('tr'))72 >>> len(BSS(browser.contents)('tr'))
71 173 1
7274