← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wgrant/launchpad/bug-833384 into lp:launchpad

 

William Grant has proposed merging lp:~wgrant/launchpad/bug-833384 into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #833384 in Launchpad itself: "API access to debian changelog"
  https://bugs.launchpad.net/launchpad/+bug/833384

For more details, see:
https://code.launchpad.net/~wgrant/launchpad/bug-833384/+merge/80140

This branch exposes source package changelogs in the webapp traversal hierarchy, and provides references to them through the API.

I needed a fake request for tests, so I replaced three existing scattered identical implementations with one in lp.testing. There are also some lint fixes around the place, and I moved TestSPPHModel's lonely test into test_publishing_models with the rest.
-- 
https://code.launchpad.net/~wgrant/launchpad/bug-833384/+merge/80140
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wgrant/launchpad/bug-833384 into lp:launchpad.
=== modified file 'lib/canonical/launchpad/browser/tests/test_branchtraversal.py'
--- lib/canonical/launchpad/browser/tests/test_branchtraversal.py	2011-08-12 11:37:08 +0000
+++ lib/canonical/launchpad/browser/tests/test_branchtraversal.py	2011-10-23 06:44:24 +0000
@@ -3,13 +3,11 @@
 
 """Tests for branch traversal."""
 
-from lazr.restful.testing.webservice import FakeRequest
 from zope.component import getUtility
 from zope.publisher.interfaces import NotFound
 from zope.security.proxy import removeSecurityProxy
 
 from canonical.launchpad.webapp.publisher import canonical_url
-from canonical.launchpad.webapp.servers import StepsToGo
 from canonical.testing.layers import DatabaseFunctionalLayer
 from lp.registry.browser.person import PersonNavigation
 from lp.registry.browser.personproduct import PersonProductNavigation
@@ -17,17 +15,10 @@
     IPersonProduct,
     IPersonProductFactory,
     )
-from lp.testing import TestCaseWithFactory
-
-
-class LocalFakeRequest(FakeRequest):
-    @property
-    def stepstogo(self):
-        """See IBasicLaunchpadRequest.
-
-        This method is called by traversal machinery.
-        """
-        return StepsToGo(self)
+from lp.testing import (
+    FakeLaunchpadRequest,
+    TestCaseWithFactory,
+    )
 
 
 class TestPersonBranchTraversal(TestCaseWithFactory):
@@ -59,7 +50,7 @@
         """
         stack = list(reversed(segments))
         name = stack.pop()
-        request = LocalFakeRequest(['~' + self.person.name], stack)
+        request = FakeLaunchpadRequest(['~' + self.person.name], stack)
         traverser = PersonNavigation(self.person, request)
         return traverser.publishTraverse(request, name)
 
@@ -141,7 +132,7 @@
         """
         stack = list(reversed(segments))
         name = stack.pop()
-        request = LocalFakeRequest(
+        request = FakeLaunchpadRequest(
             ['~' + self.person.name, self.product.name], stack)
         traverser = PersonProductNavigation(self.person_product, request)
         return traverser.publishTraverse(request, name)

=== modified file 'lib/lp/blueprints/browser/tests/test_specification.py'
--- lib/lp/blueprints/browser/tests/test_specification.py	2011-08-28 07:29:11 +0000
+++ lib/lp/blueprints/browser/tests/test_specification.py	2011-10-23 06:44:24 +0000
@@ -7,7 +7,6 @@
 import doctest
 import unittest
 
-from lazr.restful.testing.webservice import FakeRequest
 import pytz
 from testtools.matchers import (
     DocTestMatches,
@@ -22,7 +21,6 @@
     find_tag_by_id,
     )
 from canonical.launchpad.webapp.interfaces import BrowserNotificationLevel
-from canonical.launchpad.webapp.servers import StepsToGo
 from canonical.testing.layers import DatabaseFunctionalLayer
 from lp.app.browser.tales import format_link
 from lp.blueprints.browser import specification
@@ -32,6 +30,7 @@
     ISpecificationSet,
     )
 from lp.testing import (
+    FakeLaunchpadRequest,
     login_person,
     person_logged_in,
     TestCaseWithFactory,
@@ -51,17 +50,6 @@
         self.assertEqual([], view.errors)
 
 
-class LocalFakeRequest(FakeRequest):
-
-    @property
-    def stepstogo(self):
-        """See IBasicLaunchpadRequest.
-
-        This method is called by traversal machinery.
-        """
-        return StepsToGo(self)
-
-
 class TestBranchTraversal(TestCaseWithFactory):
 
     layer = DatabaseFunctionalLayer
@@ -80,7 +68,7 @@
     def traverse(self, segments):
         stack = list(reversed(['+branch'] + segments))
         name = stack.pop()
-        request = LocalFakeRequest([], stack)
+        request = FakeLaunchpadRequest([], stack)
         traverser = specification.SpecificationNavigation(
             self.specification, request)
         return traverser.publishTraverse(request, name)

=== modified file 'lib/lp/bugs/browser/tests/test_structuralsubscription.py'
--- lib/lp/bugs/browser/tests/test_structuralsubscription.py	2011-03-22 14:56:58 +0000
+++ lib/lp/bugs/browser/tests/test_structuralsubscription.py	2011-10-23 06:44:24 +0000
@@ -5,7 +5,6 @@
 
 from urlparse import urlparse
 
-from lazr.restful.testing.webservice import FakeRequest
 import transaction
 from zope.publisher.interfaces import NotFound
 
@@ -14,7 +13,6 @@
     logout,
     )
 from canonical.launchpad.webapp.publisher import canonical_url
-from canonical.launchpad.webapp.servers import StepsToGo
 from canonical.testing.layers import (
     AppServerLayer,
     DatabaseFunctionalLayer,
@@ -29,6 +27,7 @@
 from lp.registry.browser.productseries import ProductSeriesNavigation
 from lp.registry.browser.project import ProjectNavigation
 from lp.testing import (
+    FakeLaunchpadRequest,
     person_logged_in,
     TestCaseWithFactory,
     ws_object,
@@ -36,14 +35,6 @@
 from lp.testing.views import create_initialized_view
 
 
-class FakeLaunchpadRequest(FakeRequest):
-
-    @property
-    def stepstogo(self):
-        """See `IBasicLaunchpadRequest`."""
-        return StepsToGo(self)
-
-
 class StructuralSubscriptionTraversalTestBase(TestCaseWithFactory):
     """Verify that we can reach a target's structural subscriptions."""
 

=== modified file 'lib/lp/soyuz/browser/configure.zcml'
--- lib/lp/soyuz/browser/configure.zcml	2011-09-23 07:49:54 +0000
+++ lib/lp/soyuz/browser/configure.zcml	2011-10-23 06:44:24 +0000
@@ -171,6 +171,9 @@
             path_expression="string:+sourcepub"
             attribute_to_parent="archive"
             urldata="lp.soyuz.browser.publishing.SourcePublicationURL"/>
+        <browser:navigation
+            module="lp.soyuz.browser.publishing"
+            classes="SourcePackagePublishingHistoryNavigation" />
     </facet>
     <browser:defaultView
         for="lp.soyuz.interfaces.distroseriessourcepackagerelease.IDistroSeriesSourcePackageRelease"

=== modified file 'lib/lp/soyuz/browser/publishing.py'
--- lib/lp/soyuz/browser/publishing.py	2011-07-18 09:23:10 +0000
+++ lib/lp/soyuz/browser/publishing.py	2011-10-23 06:44:24 +0000
@@ -17,7 +17,11 @@
 from lazr.delegates import delegates
 from zope.interface import implements
 
-from canonical.launchpad.browser.librarian import ProxiedLibraryFileAlias
+from canonical.launchpad.browser.librarian import (
+    FileNavigationMixin,
+    ProxiedLibraryFileAlias,
+    )
+from canonical.launchpad.webapp import Navigation
 from canonical.launchpad.webapp.authorization import check_permission
 from canonical.launchpad.webapp.interfaces import ICanonicalUrlData
 from canonical.launchpad.webapp.menu import structured
@@ -62,6 +66,11 @@
         return u"+binarypub/%s" % self.context.id
 
 
+class SourcePackagePublishingHistoryNavigation(Navigation,
+                                               FileNavigationMixin):
+    usedfor = ISourcePackagePublishingHistory
+
+
 class ProxiedPackageDiff:
     """A `PackageDiff` extension.
 
@@ -121,7 +130,6 @@
         accessor = attrgetter(self.timestamp_map[self.context.status])
         return accessor(self.context)
 
-
     def wasDeleted(self):
         """Whether or not a publishing record deletion was requested.
 
@@ -157,17 +165,19 @@
         archive disk once it pass through its quarantine period and it's not
         referred by any other archive publishing record.
         Archive removal represents the act of having its content purged from
-        archive disk, such situation can be triggered for different status,
-        each one representing a distinct step in the Soyuz publishing workflow:
+        archive disk, such situation can be triggered for different
+        status, each one representing a distinct step in the Soyuz
+        publishing workflow:
 
-         * SUPERSEDED -> the publication is not necessary since there is already
-           a newer/higher/modified version available
+         * SUPERSEDED -> the publication is not necessary since there is
+           already a newer/higher/modified version available
 
          * DELETED -> the publishing was explicitly marked for removal by a
            archive-administrator, it's not wanted in the archive.
 
-         * OBSOLETE -> the publication has become obsolete because its targeted
-           distroseries has become obsolete (not supported by its developers).
+         * OBSOLETE -> the publication has become obsolete because its
+           targeted distroseries has become obsolete (not supported by its
+           developers).
         """
         return self.context.dateremoved is not None
 
@@ -303,8 +313,8 @@
             packagename = package.binarypackagename.name
             if packagename not in packagenames:
                 entry = {
-                    "binarypackagename" : packagename,
-                    "summary" : package.summary,
+                    "binarypackagename": packagename,
+                    "summary": package.summary,
                     }
                 results.append(entry)
                 packagenames.add(packagename)
@@ -331,7 +341,7 @@
         return check_permission('launchpad.View', archive)
 
     @property
-    def recipe_build_details(self): 
+    def recipe_build_details(self):
         """Return a linkified string containing details about a
         SourcePackageRecipeBuild.
         """
@@ -393,4 +403,3 @@
             return True
 
         return False
-

=== modified file 'lib/lp/soyuz/browser/tests/test_publishing.py'
--- lib/lp/soyuz/browser/tests/test_publishing.py	2011-03-01 05:00:01 +0000
+++ lib/lp/soyuz/browser/tests/test_publishing.py	2011-10-23 06:44:24 +0000
@@ -6,17 +6,33 @@
 __metaclass__ = type
 
 import soupmatchers
+from testtools.matchers import (
+    Contains,
+    MatchesAll,
+    )
+from zope.app.testing.functional import HTTPCaller
 from zope.component import getUtility
+from zope.publisher.interfaces import NotFound
+from zope.security.interfaces import Unauthorized
 
+from canonical.launchpad.ftests import logout
 from canonical.testing.layers import LaunchpadFunctionalLayer
-from canonical.launchpad.webapp.publisher import canonical_url
-
+from canonical.launchpad.webapp.publisher import (
+    canonical_url,
+    RedirectionView,
+    )
 from lp.registry.interfaces.person import IPersonSet
+from lp.soyuz.browser.publishing import (
+    SourcePackagePublishingHistoryNavigation,
+    )
 from lp.soyuz.enums import PackagePublishingStatus
+from lp.soyuz.interfaces.archive import ArchivePurpose
 from lp.soyuz.tests.test_publishing import SoyuzTestPublisher
 from lp.testing import (
     BrowserTestCase,
+    FakeLaunchpadRequest,
     person_logged_in,
+    TestCaseWithFactory,
     )
 from lp.testing.sampledata import ADMIN_EMAIL
 
@@ -77,7 +93,7 @@
             archive=self.archive, status=PackagePublishingStatus.PUBLISHED)
         browser = self.getViewBrowser(spph, '+listing-archive-extra')
         self.assertNotIn('Built by recipe', browser.contents)
-        
+
     def test_view_with_deleted_source_package_recipe(self):
         # If a SourcePackageRelease is linked to a deleted recipe, the text
         # 'deleted recipe' is displayed, rather than a link.
@@ -103,3 +119,65 @@
         browser = self.getViewBrowser(spph, '+listing-archive-extra')
         self.assertThat(browser.contents, recipe_link_matches)
         self.assertIn('deleted recipe', browser.contents)
+
+
+class TestSourcePackagePublishingHistoryNavigation(TestCaseWithFactory):
+    layer = LaunchpadFunctionalLayer
+
+    def traverse(self, spph, segments):
+        req = FakeLaunchpadRequest([], segments[1:])
+        nav = SourcePackagePublishingHistoryNavigation(spph, req)
+        return nav.publishTraverse(req, segments[0])
+
+    def makeSPPHWithChangelog(self, archive=None):
+        lfa = self.factory.makeLibraryFileAlias(
+            filename='changelog',
+            restricted=(archive is not None and archive.private))
+        spr = self.factory.makeSourcePackageRelease(changelog=lfa)
+        return self.factory.makeSourcePackagePublishingHistory(
+            archive=archive,
+            sourcepackagerelease=spr)
+
+    def test_changelog(self):
+        # SPPH.SPR.changelog is accessible at +files/changelog.
+        spph = self.makeSPPHWithChangelog()
+        view = self.traverse(spph, ['+files', 'changelog'])
+        self.assertIsInstance(view, RedirectionView)
+        self.assertEqual(
+            spph.sourcepackagerelease.changelog.http_url, view.target)
+
+    def test_private_changelog(self):
+        # Private changelogs are inaccessible to anonymous users.
+        archive = self.factory.makeArchive(
+            purpose=ArchivePurpose.PPA, private=True)
+        spph = self.makeSPPHWithChangelog(archive=archive)
+
+        # A normal user can't traverse to the changelog.
+        self.assertRaises(
+            Unauthorized, self.traverse, spph, ['+files', 'changelog'])
+
+        # But the archive owner gets a librarian URL with a token.
+        with person_logged_in(archive.owner):
+            view = self.traverse(spph, ['+files', 'changelog'])
+        self.assertThat(view.target, Contains('?token='))
+
+    def test_unhandled_name(self):
+        # Unhandled names raise a NotFound.
+        spph = self.factory.makeSourcePackagePublishingHistory()
+        self.assertRaises(
+            NotFound, self.traverse, spph, ['+files', 'not-changelog'])
+
+    def test_registered(self):
+        # The Navigation is registered and traversable over HTTP.
+        spph = self.makeSPPHWithChangelog()
+        lfa_url = spph.sourcepackagerelease.changelog.http_url
+        redir_url = (
+            canonical_url(spph, path_only_if_possible=True)
+            + '/+files/changelog')
+        logout()
+        response = str(HTTPCaller()("GET %s HTTP/1.1\n\n" % redir_url))
+        self.assertThat(
+            response,
+            MatchesAll(
+                Contains("HTTP/1.1 303 See Other"),
+                Contains("Location: %s" % lfa_url)))

=== modified file 'lib/lp/soyuz/interfaces/publishing.py'
--- lib/lp/soyuz/interfaces/publishing.py	2011-09-24 12:57:47 +0000
+++ lib/lp/soyuz/interfaces/publishing.py	2011-10-23 06:44:24 +0000
@@ -34,6 +34,7 @@
     export_read_operation,
     export_write_operation,
     exported,
+    operation_for_version,
     operation_parameters,
     operation_returns_collection_of,
     REQUEST_USER,
@@ -533,6 +534,12 @@
         :return: a list of `IBuilds`.
         """
 
+    def getFileByName(name):
+        """Return the file with the specified name.
+
+        Only supports 'changelog' at present.
+        """
+
     @export_read_operation()
     def changesFileUrl():
         """The .changes file URL for this source publication.
@@ -540,6 +547,14 @@
         :return: the .changes file URL for this source (a string).
         """
 
+    @export_read_operation()
+    @operation_for_version('devel')
+    def changelogUrl():
+        """The URL for this source package release's changelog.
+
+        :return: the changelog file URL for this source (a string).
+        """
+
     def getUnpublishedBuilds(build_states=None):
         """Return a resultset of `IBuild` objects in this context that are
         not published.

=== modified file 'lib/lp/soyuz/model/publishing.py'
--- lib/lp/soyuz/model/publishing.py	2011-10-18 09:18:05 +0000
+++ lib/lp/soyuz/model/publishing.py	2011-10-23 06:44:24 +0000
@@ -68,6 +68,7 @@
     IStoreSelector,
     MAIN_STORE,
     )
+from lp.app.errors import NotFoundError
 from lp.buildmaster.enums import BuildStatus
 from lp.buildmaster.model.buildfarmjob import BuildFarmJob
 from lp.buildmaster.model.packagebuild import PackageBuild
@@ -552,6 +553,13 @@
             self, build_states)
         return DecoratedResultSet(result_set, operator.itemgetter(1))
 
+    def getFileByName(self, name):
+        """See `ISourcePackagePublishingHistory`."""
+        changelog = self.sourcepackagerelease.changelog
+        if changelog is not None and name == changelog.filename:
+            return changelog
+        raise NotFoundError(name)
+
     def changesFileUrl(self):
         """See `ISourcePackagePublishingHistory`."""
         # We use getChangesFileLFA() as opposed to getChangesFilesForSources()
@@ -575,6 +583,13 @@
         the_url = self._proxied_urls((changes_lfa,), self.archive)[0]
         return the_url
 
+    def changelogUrl(self):
+        """See `ISourcePackagePublishingHistory`."""
+        lfa = self.sourcepackagerelease.changelog
+        if lfa is not None:
+            return self._proxied_urls((lfa,), self)[0]
+        return None
+
     def _getAllowedArchitectures(self, available_archs):
         """Filter out any restricted architectures not specifically allowed
         for an archive.

=== modified file 'lib/lp/soyuz/stories/webservice/xx-source-package-publishing.txt'
--- lib/lp/soyuz/stories/webservice/xx-source-package-publishing.txt	2011-09-26 06:30:07 +0000
+++ lib/lp/soyuz/stories/webservice/xx-source-package-publishing.txt	2011-10-23 06:44:24 +0000
@@ -402,6 +402,20 @@
     []
     [u'http://launchpad.dev/~cprov/+archive/ppa/+files/testwebservice-bin_666_all.deb']
 
+changelogUrl() returns the URL of debian/changelog, if it's available in
+the librarian.
+
+    >>> from lp.testing import celebrity_logged_in
+    >>> with celebrity_logged_in('admin'):
+    ...     spr = factory.makeSourcePackageRelease(
+    ...         changelog=factory.makeLibraryFileAlias(filename='changelog'))
+    ...     spph = factory.makeSourcePackagePublishingHistory(
+    ...         sourcepackagerelease=spr)
+    ...     spph_url = canonical_url(spph, path_only_if_possible=True)
+    >>> print webservice.named_get(
+    ...     spph_url, 'changelogUrl', api_version='devel').jsonBody()
+    http://launchpad.dev/.../+sourcepub/.../+files/changelog
+
 The debdiff to a particular version can also be retrieved using the
 packageDiffUrl() method.  It takes one parameter, 'to_version' which
 specifies the version of the package you want a diff against.  If there

=== modified file 'lib/lp/soyuz/tests/test_publishing.py'
--- lib/lp/soyuz/tests/test_publishing.py	2011-10-18 09:18:05 +0000
+++ lib/lp/soyuz/tests/test_publishing.py	2011-10-23 06:44:24 +0000
@@ -1458,19 +1458,6 @@
         self.checkOtherPublications(foreign_bins[0], foreign_bins)
 
 
-class TestSPPHModel(TestCaseWithFactory):
-    """Test parts of the SourcePackagePublishingHistory model."""
-
-    layer = LaunchpadZopelessLayer
-
-    def testAncestry(self):
-        """Ancestry can be traversed."""
-        ancestor = self.factory.makeSourcePackagePublishingHistory()
-        spph = self.factory.makeSourcePackagePublishingHistory(
-            ancestor=ancestor)
-        self.assertEquals(spph.ancestor.displayname, ancestor.displayname)
-
-
 class TestGetOtherPublicationsForSameSource(TestNativePublishingBase):
     """Test parts of the BinaryPackagePublishingHistory model.
 

=== modified file 'lib/lp/soyuz/tests/test_publishing_models.py'
--- lib/lp/soyuz/tests/test_publishing_models.py	2011-08-26 16:44:05 +0000
+++ lib/lp/soyuz/tests/test_publishing_models.py	2011-10-23 06:44:24 +0000
@@ -7,13 +7,19 @@
 from zope.security.proxy import removeSecurityProxy
 
 from canonical.database.constants import UTC_NOW
-from canonical.testing.layers import LaunchpadZopelessLayer
+from canonical.launchpad.webapp.publisher import canonical_url
+from canonical.testing.layers import (
+    LaunchpadFunctionalLayer,
+    LaunchpadZopelessLayer,
+    )
+from lp.app.errors import NotFoundError
 from lp.buildmaster.enums import BuildStatus
 from lp.soyuz.interfaces.publishing import (
     IPublishingSet,
     PackagePublishingStatus,
     )
 from lp.soyuz.tests.test_binarypackagebuild import BaseTestCaseWithThreeBuilds
+from lp.testing import TestCaseWithFactory
 
 
 class TestPublishingSet(BaseTestCaseWithThreeBuilds):
@@ -45,7 +51,7 @@
     def test_getUnpublishedBuildsForSources_one_published(self):
         # If we publish a binary for a build, it is no longer returned.
         bpr = self.publisher.uploadBinaryForBuild(self.builds[0], 'gedit')
-        bpph = self.publisher.publishBinaryInArchive(
+        self.publisher.publishBinaryInArchive(
             bpr, self.sources[0].archive,
             status=PackagePublishingStatus.PUBLISHED)
 
@@ -86,3 +92,47 @@
         self.assert_(urls[1].endswith('/96/firefox_666_source.changes'))
         self.assert_(urls[2].endswith(
             '/98/getting-things-gnome_666_source.changes'))
+
+
+class TestSourcePackagePublishingHistory(TestCaseWithFactory):
+
+    layer = LaunchpadFunctionalLayer
+
+    def test_ancestry(self):
+        """Ancestry can be traversed."""
+        ancestor = self.factory.makeSourcePackagePublishingHistory()
+        spph = self.factory.makeSourcePackagePublishingHistory(
+            ancestor=ancestor)
+        self.assertEquals(spph.ancestor.displayname, ancestor.displayname)
+
+    def test_changelogUrl_missing(self):
+        spr = self.factory.makeSourcePackageRelease(changelog=None)
+        spph = self.factory.makeSourcePackagePublishingHistory(
+            sourcepackagerelease=spr)
+        self.assertEqual(None, spph.changelogUrl())
+
+    def test_changelogUrl(self):
+        spr = self.factory.makeSourcePackageRelease(
+            changelog=self.factory.makeChangelog('foo', ['1.0']))
+        spph = self.factory.makeSourcePackagePublishingHistory(
+            sourcepackagerelease=spr)
+        self.assertEqual(
+            canonical_url(spph) + '/+files/%s' % spr.changelog.filename,
+            spph.changelogUrl())
+
+    def test_getFileByName_changelog(self):
+        spr = self.factory.makeSourcePackageRelease(
+            changelog=self.factory.makeLibraryFileAlias(filename='changelog'))
+        spph = self.factory.makeSourcePackagePublishingHistory(
+            sourcepackagerelease=spr)
+        self.assertEqual(spr.changelog, spph.getFileByName('changelog'))
+
+    def test_getFileByName_changelog_absent(self):
+        spr = self.factory.makeSourcePackageRelease(changelog=None)
+        spph = self.factory.makeSourcePackagePublishingHistory(
+            sourcepackagerelease=spr)
+        self.assertRaises(NotFoundError, spph.getFileByName, 'changelog')
+
+    def test_getFileByName_unhandled_name(self):
+        spph = self.factory.makeSourcePackagePublishingHistory()
+        self.assertRaises(NotFoundError, spph.getFileByName, 'not-changelog')

=== modified file 'lib/lp/testing/__init__.py'
--- lib/lp/testing/__init__.py	2011-10-11 07:20:53 +0000
+++ lib/lp/testing/__init__.py	2011-10-23 06:44:24 +0000
@@ -17,6 +17,7 @@
     'celebrity_logged_in',
     'ExpectedException',
     'extract_lp_cache',
+    'FakeLaunchpadRequest',
     'FakeTime',
     'get_lsb_information',
     'launchpadlib_credentials_for',
@@ -85,6 +86,7 @@
     )
 from bzrlib.transport import get_transport
 import fixtures
+from lazr.restful.testing.webservice import FakeRequest
 import oops_datedir_repo.serializer_rfc822
 import pytz
 import simplejson
@@ -122,6 +124,7 @@
 from canonical.launchpad.webapp.interaction import ANONYMOUS
 from canonical.launchpad.webapp.servers import (
     LaunchpadTestRequest,
+    StepsToGo,
     WebServiceTestRequest,
     )
 from lp.codehosting.vfs import (
@@ -1345,3 +1348,11 @@
             result.write(next_char)
         now = time.time()
     return result.getvalue()
+
+
+class FakeLaunchpadRequest(FakeRequest):
+
+    @property
+    def stepstogo(self):
+        """See `IBasicLaunchpadRequest`."""
+        return StepsToGo(self)