← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~lifeless/launchpad/cp into lp:~launchpad-pqm/launchpad/production-devel

 

Robert Collins has proposed merging lp:~lifeless/launchpad/cp into lp:~launchpad-pqm/launchpad/production-devel.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)


More qa-passed revisions from stable.

Revision 11547 can be deployed: qa-ok
    bug 597738 where-bugs-are-tracked UI
Revision 11548 can be deployed: qa-ok
    bug 413174 better exceptions for API clients
Revision 11549 can be deployed: qa-ok
    bug 159146 OOPS setting answer contact
Revision 11550 can be deployed: qa-ok
    bug 636420 allow anonymous access to project release files
Revision 11551 can be deployed: qa-ok
    bug 611274 remove an inappropriate oops raising in checkwatches
Revision 11552 can be deployed: qa-ok
    bug 638420 turn on project configuration bars
Revision 11553 can be deployed: qa-ok
    no-qa
Revision 11554 can be deployed: qa-ok
    no-qa
Revision 11555 can be deployed: qa-ok
   no-qa
-- 
https://code.launchpad.net/~lifeless/launchpad/cp/+merge/35965
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~lifeless/launchpad/cp into lp:~launchpad-pqm/launchpad/production-devel.
=== modified file 'lib/canonical/launchpad/components/decoratedresultset.py'
--- lib/canonical/launchpad/components/decoratedresultset.py	2010-08-25 19:23:13 +0000
+++ lib/canonical/launchpad/components/decoratedresultset.py	2010-09-19 21:32:44 +0000
@@ -9,6 +9,7 @@
     ]
 
 from lazr.delegates import delegates
+from storm import Undef
 from storm.zope.interfaces import IResultSet
 from zope.security.proxy import removeSecurityProxy
 
@@ -33,25 +34,32 @@
     """
     delegates(IResultSet, context='result_set')
 
-    def __init__(self, result_set, result_decorator=None, pre_iter_hook=None):
+    def __init__(self, result_set, result_decorator=None, pre_iter_hook=None,
+                 slice_info=False):
         """
         :param result_set: The original result set to be decorated.
         :param result_decorator: The method with which individual results
             will be passed through before being returned.
         :param pre_iter_hook: The method to be called (with the 'result_set')
             immediately before iteration starts.
+        :param slice_info: If True pass information about the slice parameters
+            to the result_decorator and pre_iter_hook. any() and similar
+            methods will cause None to be supplied.
         """
         self.result_set = result_set
         self.result_decorator = result_decorator
         self.pre_iter_hook = pre_iter_hook
+        self.slice_info = slice_info
 
-    def decorate_or_none(self, result):
+    def decorate_or_none(self, result, row_index=None):
         """Decorate a result or return None if the result is itself None"""
         if result is None:
             return None
         else:
             if self.result_decorator is None:
                 return result
+            elif self.slice_info:
+                return self.result_decorator(result, row_index)
             else:
                 return self.result_decorator(result)
 
@@ -62,7 +70,8 @@
         """
         new_result_set = self.result_set.copy(*args, **kwargs)
         return DecoratedResultSet(
-            new_result_set, self.result_decorator, self.pre_iter_hook)
+            new_result_set, self.result_decorator, self.pre_iter_hook,
+            self.slice_info)
 
     def config(self, *args, **kwargs):
         """See `IResultSet`.
@@ -79,10 +88,25 @@
         """
         # Execute/evaluate the result set query.
         results = list(self.result_set.__iter__(*args, **kwargs))
+        if self.slice_info:
+            # Calculate slice data
+            start = self.result_set._offset
+            if start is Undef:
+                start = 0
+            stop = start + len(results)
+            result_slice = slice(start, stop)
         if self.pre_iter_hook is not None:
-            self.pre_iter_hook(results)
-        for value in results:
-            yield self.decorate_or_none(value)
+            if self.slice_info:
+                self.pre_iter_hook(results, result_slice)
+            else:
+                self.pre_iter_hook(results)
+        if self.slice_info:
+            start = result_slice.start
+            for offset, value in enumerate(results):
+                yield self.decorate_or_none(value, offset + start)
+        else:
+            for value in results:
+                yield self.decorate_or_none(value)
 
     def __getitem__(self, *args, **kwargs):
         """See `IResultSet`.
@@ -94,7 +118,8 @@
         naked_value = removeSecurityProxy(value)
         if IResultSet.providedBy(naked_value):
             return DecoratedResultSet(
-                value, self.result_decorator, self.pre_iter_hook)
+                value, self.result_decorator, self.pre_iter_hook,
+                self.slice_info)
         else:
             return self.decorate_or_none(value)
 
@@ -137,4 +162,5 @@
         """
         new_result_set = self.result_set.order_by(*args, **kwargs)
         return DecoratedResultSet(
-            new_result_set, self.result_decorator, self.pre_iter_hook)
+            new_result_set, self.result_decorator, self.pre_iter_hook,
+            self.slice_info)

=== modified file 'lib/canonical/launchpad/configure.zcml'
--- lib/canonical/launchpad/configure.zcml	2010-08-17 13:58:57 +0000
+++ lib/canonical/launchpad/configure.zcml	2010-09-19 21:32:44 +0000
@@ -31,8 +31,6 @@
   <include package="lp.translations" />
   <include package="lp.testopenid" />
   <include package="lp.blueprints" />
-  <include package="lp.services.comments" />
-  <include package="lp.services.fields" />
   <include package="lp.vostok" />
 
   <browser:url

=== modified file 'lib/canonical/launchpad/doc/decoratedresultset.txt'
--- lib/canonical/launchpad/doc/decoratedresultset.txt	2009-07-23 17:51:28 +0000
+++ lib/canonical/launchpad/doc/decoratedresultset.txt	2010-09-19 21:32:44 +0000
@@ -1,30 +1,17 @@
 = DecoratedResultSet =
 
-Within Soyuz (and possibly other areas of Launchpad?) there are a 
-number of content classes which do not actually correspond directly to
-a database table. For example, a `DistroSeriesBinaryPackage` is really
-just a representation of a `BinaryPackageName` within a certain
-DistroSeries. Nonetheless, this representation is presented (via views)
-to users as a useful reference point for obtaining particular package
-releases and/or architectures.
-
-A problem arises however when attempting to present a search result of
-such content. For example, when a user searches for all packages within
-the Ubuntu Intrepid `DistroSeries` that include the letter 'l', it is 
-actually the `DistroSeriesPackageCache` that is searched, but the
-results need to be presented back to the browser as a set of
-`DistroSeriesBinaryPackage`s. In the past this was achieved by using a
-list comprehension on the complete result set to convert each result
-into the expected DSBP. This in-memory list was then passed to the view.
-But given that views usually paginate results, they are only interested
-in 20 or so items at a time, so there is a huge waste of resources (DB
-and memory primarily, but some CPU).
-
-The purpose of the `DecoratedResultSet` is to allow such content
-classes to pass an un-evaluated result set to the view so that the view
-can add limits to the query *before* it is evaluated (batching), while
-still ensuring that when the query is evaluated the result is converted
-into the expected content class.
+Within Launchpad we often want to return related data for a ResultSet
+but not have every call site know how that data is structured - they
+should not need to know how to go about loading related Persons for
+a BugMessage, or how to calculate whether a Person is valid. Nor do
+we want to return a less capable object - that prohibits late slicing
+and sorting.
+
+DecoratedResultSet permits some preprocessing of Storm ResultSet
+objects at the time the query executes. This can be used to present
+content classes which are not backed directly in the database, to
+eager load multiple related tables and present just one in the result,
+and so on.
 
 First, we'll create the un-decorated result set of all distributions:
 
@@ -38,7 +25,8 @@
 == Creating the decorator method ==
 
 We create a decorator method that we want to be applied to any
-results obtained from our undecorated result set:
+results obtained from our undecorated result set. For instance,
+we can turn a model object into a string:
 
     >>> def result_decorator(distribution):
     ...     return "Dist name is: %s" % distribution.name
@@ -150,3 +138,47 @@
     > original value : 4
      decorated result: 8
 
+
+== Calculating row numbers ==
+
+DecoratedResultSet can inform its hooks about slice data if slice_info=True is
+passed.
+
+    >>> def pre_iter(rows, slice):
+    ...     print "pre iter", len(rows), slice.start, slice.stop
+    >>> def decorate(row, row_index):
+    ...     print "row", row.id, row_index
+    >>> _ = result_set.order_by(Distribution.id)
+    >>> drs = DecoratedResultSet(
+    ...     result_set, decorate, pre_iter, slice_info=True)
+
+We need enough rows to play with:
+
+    >>> drs.count()
+    7
+
+    >>> _ = list(drs[1:3])
+    pre iter 2 1 3
+    row 2 1
+    row 3 2
+
+Half open slicing is supported too:
+
+    >>> _ = list(drs[:3])
+    pre iter 3 0 3
+    row 1 0
+    row 2 1
+    row 3 2
+
+    >>> _ = list(drs[2:])
+    pre iter 5 2 7
+    row 3 2
+    row 4 3
+    row 5 4
+    row 7 5
+    row 8 6
+
+And of course empty slices:
+
+    >>> _ = list(drs[3:3])
+    pre iter 0 3 3

=== modified file 'lib/canonical/launchpad/helpers.py'
--- lib/canonical/launchpad/helpers.py	2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/helpers.py	2010-09-19 21:32:44 +0000
@@ -25,18 +25,18 @@
 from zope.security.interfaces import ForbiddenAttribute
 
 import canonical
-from canonical.launchpad.interfaces import (
-    ILaunchBag,
+from canonical.launchpad.interfaces import ILaunchBag
+from lp.services.geoip.interfaces import (
     IRequestLocalLanguages,
     IRequestPreferredLanguages,
     )
 
-# pylint: disable-msg=W0102
+
 def text_replaced(text, replacements, _cache={}):
     """Return a new string with text replaced according to the dict provided.
 
-    The keys of the dict are substrings to find, the values are what to replace
-    found substrings with.
+    The keys of the dict are substrings to find, the values are what to
+    replace found substrings with.
 
     :arg text: An unicode or str to do the replacement.
     :arg replacements: A dictionary with the replacements that should be done
@@ -78,13 +78,16 @@
         # Make a copy of the replacements dict, as it is mutable, but we're
         # keeping a cached reference to it.
         replacements_copy = dict(replacements)
+
         def matchobj_replacer(matchobj):
             return replacements_copy[matchobj.group()]
+
         regexsub = re.compile(join_char.join(L)).sub
+
         def replacer(s):
             return regexsub(matchobj_replacer, s)
+
         _cache[cachekey] = replacer
-
     return _cache[cachekey](text)
 
 
@@ -98,7 +101,7 @@
 def join_lines(*lines):
     """Concatenate a list of strings, adding a newline at the end of each."""
 
-    return ''.join([ x + '\n' for x in lines ])
+    return ''.join([x + '\n' for x in lines])
 
 
 def string_to_tarfile(s):
@@ -169,7 +172,7 @@
     in the database.
     """
     # All chars should be lower case, underscores and spaces become dashes.
-    return text_replaced(invalid_name.lower(), {'_': '-', ' ':'-'})
+    return text_replaced(invalid_name.lower(), {'_': '-', ' ': '-'})
 
 
 def browserLanguages(request):
@@ -193,8 +196,7 @@
 
     p = subprocess.Popen(
             command, env=env, stdin=subprocess.PIPE,
-            stdout=subprocess.PIPE, stderr=subprocess.STDOUT
-            )
+            stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
     (output, nothing) = p.communicate(input)
     return output
 
@@ -247,7 +249,7 @@
                 3: {'.': ' (!) ',
                     '@': ' (at) '},
                 4: {'.': ' {dot} ',
-                    '@': ' {at} '}
+                    '@': ' {at} '},
                 }
 
 
@@ -444,12 +446,13 @@
     >>> filenameToContentType('test.tgz')
     'application/octet-stream'
     """
-    ftmap = {".dsc":      "text/plain",
-             ".changes":  "text/plain",
-             ".deb":      "application/x-debian-package",
-             ".udeb":     "application/x-debian-package",
-             ".txt":      "text/plain",
-             ".txt.gz":   "text/plain", # For the build master logs
+    ftmap = {".dsc": "text/plain",
+             ".changes": "text/plain",
+             ".deb": "application/x-debian-package",
+             ".udeb": "application/x-debian-package",
+             ".txt": "text/plain",
+             # For the build master logs
+             ".txt.gz": "text/plain",
              }
     for ending in ftmap:
         if fname.endswith(ending):

=== modified file 'lib/canonical/launchpad/interfaces/__init__.py'
--- lib/canonical/launchpad/interfaces/__init__.py	2010-08-20 12:42:25 +0000
+++ lib/canonical/launchpad/interfaces/__init__.py	2010-09-19 21:32:44 +0000
@@ -71,7 +71,6 @@
 from lp.bugs.interfaces.externalbugtracker import *
 from lp.registry.interfaces.featuredproject import *
 from lp.soyuz.interfaces.files import *
-from canonical.launchpad.interfaces.geoip import *
 from lp.registry.interfaces.gpg import *
 from canonical.launchpad.interfaces.gpghandler import *
 from lp.hardwaredb.interfaces.hwdb import *

=== modified file 'lib/canonical/launchpad/security.py'
--- lib/canonical/launchpad/security.py	2010-09-10 16:21:21 +0000
+++ lib/canonical/launchpad/security.py	2010-09-19 21:32:44 +0000
@@ -418,6 +418,11 @@
             user)
 
 
+class ViewProductReleaseFile(AnonymousAuthorization):
+    """Anyone can view an IProductReleaseFile."""
+    usedfor = IProductReleaseFile
+
+
 class AdminDistributionMirrorByDistroOwnerOrMirrorAdminsOrAdmins(
         AuthorizationBase):
     permission = 'launchpad.Admin'

=== modified file 'lib/canonical/launchpad/tests/test_helpers.py'
--- lib/canonical/launchpad/tests/test_helpers.py	2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/tests/test_helpers.py	2010-09-19 21:32:44 +0000
@@ -183,8 +183,8 @@
     >>> from zope.app.testing.placelesssetup import setUp, tearDown
     >>> from zope.app.testing import ztapi
     >>> from zope.i18n.interfaces import IUserPreferredLanguages
-    >>> from canonical.launchpad.interfaces import IRequestPreferredLanguages
-    >>> from canonical.launchpad.interfaces import IRequestLocalLanguages
+    >>> from lp.services.geoip.interfaces import IRequestPreferredLanguages
+    >>> from lp.services.geoip.interfaces import IRequestLocalLanguages
     >>> from canonical.launchpad.helpers import preferred_or_request_languages
 
     First, test with a person who has a single preferred language.

=== modified file 'lib/canonical/launchpad/webapp/errorlog.py'
--- lib/canonical/launchpad/webapp/errorlog.py	2010-09-12 11:43:36 +0000
+++ lib/canonical/launchpad/webapp/errorlog.py	2010-09-19 21:32:44 +0000
@@ -324,7 +324,7 @@
         # Check today
         oopsid, filename = self.log_namer._findHighestSerialFilename(time=now)
         if filename is None:
-            # Check yesterday
+            # Check yesterday, we may have just passed midnight.
             yesterday = now - datetime.timedelta(days=1)
             oopsid, filename = self.log_namer._findHighestSerialFilename(
                 time=yesterday)
@@ -484,6 +484,7 @@
             info.
         :param now: The datetime to use as the current time.  Will be
             determined if not supplied.  Useful for testing.
+        :return: The ErrorReport created.
         """
         return self._raising(
             info, request=request, now=now, informational=True)

=== modified file 'lib/canonical/launchpad/zcml/configure.zcml'
--- lib/canonical/launchpad/zcml/configure.zcml	2010-08-11 15:47:27 +0000
+++ lib/canonical/launchpad/zcml/configure.zcml	2010-09-19 21:32:44 +0000
@@ -37,7 +37,6 @@
     <!-- Event configuration -->
 
     <!-- Special Utilities -->
-    <include file="geoip.zcml" />
     <include file="gpghandler.zcml" />
     <include file="searchservice.zcml" />
 

=== modified file 'lib/canonical/widgets/location.py'
--- lib/canonical/widgets/location.py	2010-07-15 10:55:27 +0000
+++ lib/canonical/widgets/location.py	2010-09-19 21:32:44 +0000
@@ -24,12 +24,12 @@
 
 from canonical.config import config
 from canonical.launchpad import _
-from canonical.launchpad.interfaces.geoip import IGeoIPRecord
-from lp.registry.interfaces.location import IObjectWithLocation
 from canonical.launchpad.validators import LaunchpadValidationError
 from canonical.launchpad.webapp.interfaces import (
     ILaunchBag, IMultiLineWidgetLayout)
 from canonical.launchpad.webapp.tales import ObjectImageDisplayAPI
+from lp.registry.interfaces.location import IObjectWithLocation
+from lp.services.geoip.interfaces import IGeoIPRecord
 
 
 class ILocationWidget(IInputWidget, IBrowserWidget, IMultiLineWidgetLayout):

=== modified file 'lib/lp/answers/browser/questiontarget.py'
--- lib/lp/answers/browser/questiontarget.py	2010-09-03 13:32:42 +0000
+++ lib/lp/answers/browser/questiontarget.py	2010-09-19 21:32:44 +0000
@@ -507,22 +507,6 @@
                 question.sourcepackagename.name)
 
     @property
-    def ubuntu_packages(self):
-        """The Ubuntu `IDistributionSourcePackage`s linked to the context.
-
-        If the context is an `IProduct` and it has `IPackaging` links to
-        Ubuntu, a list is returned. Otherwise None is returned
-        """
-        if IProduct.providedBy(self.context):
-            ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
-            packages = [
-                package for package in self.context.distrosourcepackages
-                if package.distribution == ubuntu]
-            if len(packages) > 0:
-                return packages
-        return None
-
-    @property
     def can_configure_answers(self):
         """Can the user configure answers for the `IQuestionTarget`."""
         target = self.context

=== modified file 'lib/lp/answers/browser/tests/test_questiontarget.py'
--- lib/lp/answers/browser/tests/test_questiontarget.py	2010-09-06 09:11:43 +0000
+++ lib/lp/answers/browser/tests/test_questiontarget.py	2010-09-19 21:32:44 +0000
@@ -135,30 +135,6 @@
         self.assertViewTemplate(question_set, 'question-listing.pt')
 
 
-class TestSearchQuestionsView_ubuntu_packages(TestSearchQuestionsView):
-    """Test the behaviour of SearchQuestionsView.ubuntu_packages."""
-
-    def test_nonproduct_ubuntu_packages(self):
-        distribution = self.factory.makeDistribution()
-        view = create_initialized_view(distribution, '+questions')
-        packages = view.ubuntu_packages
-        self.assertEqual(None, packages)
-
-    def test_product_ubuntu_packages_unlinked(self):
-        product = self.factory.makeProduct()
-        view = create_initialized_view(product, '+questions')
-        packages = view.ubuntu_packages
-        self.assertEqual(None, packages)
-
-    def test_product_ubuntu_packages_linked(self):
-        product = self.factory.makeProduct()
-        self.linkPackage(product, 'cow')
-        view = create_initialized_view(product, '+questions')
-        packages = view.ubuntu_packages
-        self.assertEqual(1, len(packages))
-        self.assertEqual('cow', packages[0].name)
-
-
 class TestSearchQuestionsViewUnknown(TestSearchQuestionsView):
     """Test the behaviour of SearchQuestionsView unknown support."""
 

=== modified file 'lib/lp/answers/templates/unknown-support.pt'
--- lib/lp/answers/templates/unknown-support.pt	2010-08-05 17:14:05 +0000
+++ lib/lp/answers/templates/unknown-support.pt	2010-09-19 21:32:44 +0000
@@ -21,9 +21,9 @@
         </p>
 
         <p id="ubuntu-support"
-          tal:define="packages view/ubuntu_packages"
+          tal:define="packages context/ubuntu_packages | nothing"
           tal:condition="packages">
-          <tal:project replace="context/displayname" /> questions are also
+          <tal:project replace="context/displayname" /> questions are
           tracked in: <tal:packages repeat="package packages">
             <tal:package replace="structure package/fmt:link" /><tal:comma
             condition="not:repeat/package/end">, </tal:comma></tal:packages>.
@@ -31,10 +31,9 @@
 
         <p id="configure-support"
           tal:condition="view/can_configure_answers">
-          Launchpad allows your project to track questions and create FAQs.
-          <br /><a class="sprite maybe"
+          <a class="sprite maybe"
             href="https://help.launchpad.net/Answers";>Getting started
-          with support tracking in Launchpad</a>.
+          tracking questions and FAQs in Launchpad</a>.
           <br /><a tal:replace="structure context/menu:overview/configure_answers/fmt:link" />
         </p>
       </div>

=== modified file 'lib/lp/bugs/browser/bugtarget.py'
--- lib/lp/bugs/browser/bugtarget.py	2010-09-03 03:12:39 +0000
+++ lib/lp/bugs/browser/bugtarget.py	2010-09-19 21:32:44 +0000
@@ -1252,13 +1252,18 @@
             bug_statuses_to_show.append(BugTaskStatus.FIXRELEASED)
 
     @property
-    def uses_launchpad_bugtracker(self):
-        """Whether this distro or product tracks bugs in launchpad.
-
-        :returns: boolean
+    def can_have_external_bugtracker(self):
+        return (IProduct.providedBy(self.context)
+                or IProductSeries.providedBy(self.context))
+
+    @property
+    def bug_tracking_usage(self):
+        """Whether the context tracks bugs in launchpad.
+
+        :returns: ServiceUsage enum value
         """
         service_usage = IServiceUsage(self.context)
-        return service_usage.bug_tracking_usage == ServiceUsage.LAUNCHPAD
+        return service_usage.bug_tracking_usage
 
     @property
     def external_bugtracker(self):
@@ -1278,7 +1283,7 @@
 
         :returns: str which may contain HTML.
         """
-        if self.uses_launchpad_bugtracker:
+        if self.bug_tracking_usage == ServiceUsage.LAUNCHPAD:
             return 'Launchpad'
         elif self.external_bugtracker:
             return BugTrackerFormatterAPI(self.external_bugtracker).link(None)

=== modified file 'lib/lp/bugs/model/bugtask.py'
--- lib/lp/bugs/model/bugtask.py	2010-09-03 03:12:39 +0000
+++ lib/lp/bugs/model/bugtask.py	2010-09-19 21:32:44 +0000
@@ -159,6 +159,7 @@
 from lp.soyuz.model.publishing import SourcePackagePublishingHistory
 from lp.soyuz.model.sourcepackagerelease import SourcePackageRelease
 
+
 debbugsseveritymap = {
     None: BugTaskImportance.UNDECIDED,
     'wishlist': BugTaskImportance.WISHLIST,

=== modified file 'lib/lp/bugs/stories/bugs/xx-front-page-info.txt'
--- lib/lp/bugs/stories/bugs/xx-front-page-info.txt	2010-05-08 15:29:56 +0000
+++ lib/lp/bugs/stories/bugs/xx-front-page-info.txt	2010-09-19 21:32:44 +0000
@@ -19,7 +19,8 @@
     >>> anon_browser.open('http://bugs.launchpad.dev/test-project')
     >>> uses_malone_p = find_tag_by_id(anon_browser.contents, 'no-malone')
     >>> print extract_text(uses_malone_p)
-    Simple Test Project does not use Launchpad for bug tracking.
+    Test-project must be configured in order for Launchpad to forward bugs to
+    the project's developers.
 
 Only users who have permission to do so can enable bug tracking
 for a project.
@@ -53,7 +54,7 @@
     >>> bug_list = find_tag_by_id(
     ...     anon_browser.contents, 'no-bugs-filed')
     >>> print extract_text(bug_list)
-    There are currently no bugs filed against Project Uses Malone.
+    There are currently no bugs filed against Uses-malone.
 
 Since there are no bugs at all filed for the project, no search box is
 shown.
@@ -100,7 +101,7 @@
     >>> bug_list = find_tag_by_id(
     ...     anon_browser.contents, 'no-bugs-filed')
     >>> print extract_text(bug_list)
-    There are currently no open bugs filed against Project Uses Malone.
+    There are currently no open bugs filed against Uses-malone.
 
 But since the project has a bug, the search box is still visible.
 
@@ -112,8 +113,7 @@
     Advanced search
 
 Projects that use an external bug tracker will list the tracker on a
-bugs home page in addition to the message that the project does not
-use Launchpad for bug tracking.
+bugs home page.
 
     >>> login('foo.bar@xxxxxxxxxxxxx')
     >>> some_tracker = factory.makeBugTracker(
@@ -121,9 +121,22 @@
     >>> test_project.bugtracker = some_tracker
     >>> logout()
     >>> anon_browser.open('http://bugs.launchpad.dev/test-project')
-    >>> uses_malone_p = find_tag_by_id(anon_browser.contents, 'no-malone')
-    >>> print extract_text(uses_malone_p)
-    Simple Test Project does not use Launchpad for bug tracking.
     >>> tracker_text = find_tag_by_id(anon_browser.contents, 'bugtracker')
     >>> print extract_text(tracker_text)
     Bugs are tracked in tracker.example.com/.
+
+Projects that are linked to an Ubuntu distro source package and that
+don't use Launchpad for bug tracking will inform the user that a bug can
+be reported on the project's source packages.
+
+    >>> login('foo.bar@xxxxxxxxxxxxx')
+    >>> factory.makePackagingLink(
+    ...     productseries=test_project.development_focus,
+    ...     sourcepackagename='test-project-package',
+    ...     in_ubuntu=True)
+    >>> logout()
+    >>> anon_browser.open('http://bugs.launchpad.dev/test-project')
+    >>> print extract_text(
+    ...     find_tag_by_id(anon_browser.contents, 'also-in-ubuntu'))
+    Ubuntu also tracks bugs for packages derived from this project:
+    test-project-package in ubuntu.

=== modified file 'lib/lp/bugs/templates/bugtarget-bugs.pt'
--- lib/lp/bugs/templates/bugtarget-bugs.pt	2010-08-04 11:01:15 +0000
+++ lib/lp/bugs/templates/bugtarget-bugs.pt	2010-09-19 21:32:44 +0000
@@ -10,12 +10,15 @@
   i18n:domain="malone"
 >
   <metal:block fill-slot="head_epilogue">
+    <meta tal:condition="not: view/bug_tracking_usage/enumvalue:LAUNCHPAD"
+          name="robots" content="noindex,nofollow" />
     <style type="text/css">
       p#more-hot-bugs {float:right; margin-top:7px;}
     </style>
   </metal:block>
   <body>
-    <tal:side metal:fill-slot="side" condition="view/uses_launchpad_bugtracker">
+    <tal:side metal:fill-slot="side"
+              condition="view/bug_tracking_usage/enumvalue:LAUNCHPAD">
       <div id="involvement" class="portlet">
         <ul class="involvement">
           <li style="border: none">
@@ -95,7 +98,8 @@
 
 
 
-      <tal:uses_malone condition="view/uses_launchpad_bugtracker">
+      <tal:uses_launchpad_bugtracker
+        condition="view/bug_tracking_usage/enumvalue:LAUNCHPAD">
       <tal:has_bugtasks condition="context/has_bugtasks">
         <div class="search-box" style="margin-bottom:2em">
           <metal:search
@@ -158,31 +162,65 @@
       <tal:no_hot_bugs condition="not: view/hot_bugs_info/bugtasks">
         <p id="no-bugs-filed"><strong>There are currently no
           <span tal:condition="context/has_bugtasks">open</span> bugs filed
-          against <tal:project_title replace="context/title" />.</strong></p>
+          against <tal:displayname replace="context/displayname" />.</strong></p>
 
         <p id="no-bugs-report"><a href="+filebug">Report a bug.</a></p>
       </tal:no_hot_bugs>
-    </tal:uses_malone>
-
-    <tal:not_uses_malone condition="not: view/uses_launchpad_bugtracker"
-     tal:define ="configure_bugtracker context/menu:overview/configure_bugtracker | nothing">
-      <p id="no-malone"><strong><tal:project_title replace="context/title" /> does not use Launchpad for
-        bug tracking.</strong></p>
-          <p tal:condition="view/external_bugtracker"
-             id="bugtracker"><strong>Bugs are tracked in
-            <tal:bugtracker replace="structure view/bugtracker" />.</strong>
-          </p>
-
-        <p tal:condition="context/required:launchpad.Edit"
-           id="no-malone-edit"
-           >
-          <a tal:condition="configure_bugtracker"
-             tal:replace="structure configure_bugtracker/fmt:link"/>
-          <a tal:condition="not: configure_bugtracker"
-             tal:attributes="href string:${context/fmt:url/+edit}">
-            Enable bug tracking.</a>
-        </p>
-    </tal:not_uses_malone>
+    </tal:uses_launchpad_bugtracker>
+
+    <p id="no-malone"
+       tal:condition="view/bug_tracking_usage/enumvalue:UNKNOWN">
+      <strong tal:condition="view/can_have_external_bugtracker">
+        <tal:displayname replace="context/displayname" />
+        must be configured in order for Launchpad to forward bugs to
+        the project's developers.
+      </strong>
+      <strong tal:condition="not: view/can_have_external_bugtracker">
+        <tal:displayname replace="context/displayname" />
+        does not use Launchpad for bug tracking.
+      </strong>
+    </p>
+
+    <p tal:condition="view/external_bugtracker"
+       id="bugtracker">
+      <strong>Bugs are tracked in
+        <tal:bugtracker replace="structure view/bugtracker" />.
+      </strong>
+    </p>
+
+    <tal:also_in_ubuntu
+      condition="not: view/bug_tracking_usage/enumvalue:LAUNCHPAD">
+      <p tal:define="packages context/ubuntu_packages | nothing"
+         tal:condition="packages"
+         id="also-in-ubuntu">
+        Ubuntu
+        <tal:also condition="view/external_bugtracker">also</tal:also>
+        tracks bugs for packages derived from this project:
+        <tal:packages repeat="package packages">
+          <span style="white-space: nowrap"
+                tal:content="structure package/fmt:link" /><tal:comma
+          condition="not:repeat/package/end">,</tal:comma></tal:packages>.
+      </p>
+    </tal:also_in_ubuntu>
+
+    <div
+      tal:condition="not: view/bug_tracking_usage/enumvalue:LAUNCHPAD"
+      tal:define="configure_bugtracker context/menu:overview/configure_bugtracker | nothing">
+      <a class="sprite maybe"
+        href="https://help.launchpad.net/Bugs";>Getting started
+      with bug tracking in Launchpad</a>.
+
+      <p tal:condition="context/required:launchpad.Edit"
+          id="no-malone-edit"
+          >
+        <a tal:condition="configure_bugtracker"
+          tal:replace="structure configure_bugtracker/fmt:link"/>
+        <a class="sprite edit"
+          tal:condition="not: configure_bugtracker"
+          tal:attributes="href string:${context/fmt:url/+edit}">
+          Enable bug tracking.</a>
+      </p>
+    </div>
 
     </div><!-- main -->
   </body>

=== modified file 'lib/lp/registry/browser/distribution.py'
--- lib/lp/registry/browser/distribution.py	2010-09-10 13:29:42 +0000
+++ lib/lp/registry/browser/distribution.py	2010-09-19 21:32:44 +0000
@@ -48,10 +48,6 @@
 from canonical.launchpad.components.decoratedresultset import (
     DecoratedResultSet,
     )
-from canonical.launchpad.components.request_country import (
-    ipaddress_from_request,
-    request_country,
-    )
 from canonical.launchpad.helpers import english_list
 from canonical.launchpad.webapp import (
     action,
@@ -109,6 +105,10 @@
     )
 from lp.registry.interfaces.product import IProduct
 from lp.registry.interfaces.series import SeriesStatus
+from lp.services.geoip.helpers import (
+    ipaddress_from_request,
+    request_country,
+    )
 from lp.services.propertycache import cachedproperty
 from lp.soyuz.browser.packagesearch import PackageSearchViewBase
 from lp.soyuz.enums import ArchivePurpose

=== modified file 'lib/lp/registry/browser/person.py'
--- lib/lp/registry/browser/person.py	2010-09-17 00:53:33 +0000
+++ lib/lp/registry/browser/person.py	2010-09-19 21:32:44 +0000
@@ -159,7 +159,6 @@
     IEmailAddress,
     IEmailAddressSet,
     )
-from canonical.launchpad.interfaces.geoip import IRequestPreferredLanguages
 from canonical.launchpad.interfaces.gpghandler import (
     GPGKeyNotFoundError,
     IGPGHandler,
@@ -300,6 +299,7 @@
     IWikiNameSet,
     )
 from lp.services.fields import LocationField
+from lp.services.geoip.interfaces import IRequestPreferredLanguages
 from lp.services.openid.adapters.openid import CurrentOpenIDEndPoint
 from lp.services.openid.browser.openiddiscovery import (
     XRDSContentNegotiationMixin,

=== modified file 'lib/lp/registry/browser/productseries.py'
--- lib/lp/registry/browser/productseries.py	2010-09-09 18:21:55 +0000
+++ lib/lp/registry/browser/productseries.py	2010-09-19 21:32:44 +0000
@@ -280,12 +280,29 @@
     usedfor = IProductSeries
     facet = 'overview'
     links = [
-        'edit', 'delete', 'driver', 'link_branch', 'ubuntupkg',
-        'create_milestone', 'create_release', 'rdf', 'subscribe',
+        'configure_bugtracker',
+        'create_milestone',
+        'create_release',
+        'delete',
+        'driver',
+        'edit',
+        'link_branch',
+        'rdf',
         'set_branch',
+        'subscribe',
+        'ubuntupkg',
         ]
 
     @enabled_with_permission('launchpad.Edit')
+    def configure_bugtracker(self):
+        text = 'Configure bug tracker'
+        summary = 'Specify where bugs are tracked for this project'
+        return Link(
+            canonical_url(self.context.product,
+                          view_name='+configure-bugtracker'),
+            text, summary, icon='edit')
+
+    @enabled_with_permission('launchpad.Edit')
     def edit(self):
         """Return a link to edit this series."""
         text = 'Change details'

=== modified file 'lib/lp/registry/browser/tests/pillar-views.txt'
--- lib/lp/registry/browser/tests/pillar-views.txt	2010-09-10 13:29:42 +0000
+++ lib/lp/registry/browser/tests/pillar-views.txt	2010-09-19 21:32:44 +0000
@@ -153,35 +153,6 @@
     <span class="sprite no">...
     </table>
 
-Until other supporting code lands, the progress bar is not going to be
-shown on lpnet.
-
-    >>> # Pretend that we're on launchpad.net:
-    >>> from canonical.config import config
-    >>> from textwrap import dedent
-    >>> test_data = dedent("""
-    ...     [launchpad]
-    ...     is_lpnet: True
-    ...     """)
-    >>> config.push('test_data', test_data)
-
-The progress bar is not shown on lpnet.
-
-    >>> view = create_view(product, '+get-involved')
-    >>> rendered = view.render()
-    >>> print find_tag_by_id(rendered, 'progressbar')
-    None
-
-Neither are the indicator sprites.
-
-    >>> 'sprite' in find_tag_by_id(rendered, 'configuration_links')
-    False
-
-    >>> # Restore the previous config:
-    >>> config_data = config.pop('test_data')
-    >>> print config.launchpad.is_lpnet
-    False
-
 Project groups are supported too, but they only display the
 applications used by their products.
 

=== modified file 'lib/lp/registry/doc/product.txt'
--- lib/lp/registry/doc/product.txt	2010-09-10 13:29:42 +0000
+++ lib/lp/registry/doc/product.txt	2010-09-19 21:32:44 +0000
@@ -225,6 +225,11 @@
     >>> [(sp.name, sp.distribution.name) for sp in alsa.distrosourcepackages]
     [(u'alsa-utils', u'debian'), (u'alsa-utils', u'ubuntu')]
 
+For convenience, you can get just the distro source packages for Ubuntu.
+
+    >>> [(sp.name, sp.distribution.name) for sp in alsa.ubuntu_packages]
+    [(u'alsa-utils', u'ubuntu')]
+
 The date_next_suggest_packaging attribute records the date when Launchpad can
 resume suggesting Ubuntu packages that the project provides. A value of None
 means that no user has ever confirmed that that the project has no Ubuntu
@@ -714,6 +719,7 @@
     ...     print series.name
     trunk
 
+
 Changing ownership
 ==================
 

=== modified file 'lib/lp/registry/interfaces/product.py'
--- lib/lp/registry/interfaces/product.py	2010-08-31 00:02:42 +0000
+++ lib/lp/registry/interfaces/product.py	2010-09-19 21:32:44 +0000
@@ -644,6 +644,9 @@
     distrosourcepackages = Attribute(_("List of distribution packages for "
         "this product"))
 
+    ubuntu_packages = Attribute(
+        _("List of distribution packages for this product in Ubuntu"))
+
     series = exported(
         doNotSnapshot(
             CollectionField(value_type=Object(schema=IProductSeries))))

=== modified file 'lib/lp/registry/model/distributionsourcepackage.py'
--- lib/lp/registry/model/distributionsourcepackage.py	2010-08-26 08:02:08 +0000
+++ lib/lp/registry/model/distributionsourcepackage.py	2010-09-19 21:32:44 +0000
@@ -34,8 +34,6 @@
     Unicode,
     )
 from storm.store import EmptyResultSet
-from zope.component import getUtility
-from zope.error.interfaces import IErrorReportingUtility
 from zope.interface import implements
 
 from canonical.database.sqlbase import sqlvalues
@@ -113,13 +111,6 @@
 
     def __set__(self, obj, value):
         if obj._self_in_database is None:
-            # Log an oops without raising an error.
-            exception = AssertionError(
-                "DistributionSourcePackage record should have been created "
-                "earlier in the database for distro=%s, sourcepackagename=%s"
-                % (obj.distribution.name, obj.sourcepackagename.name))
-            getUtility(IErrorReportingUtility).raising(
-                (exception.__class__, exception, None))
             spph = Store.of(obj.distribution).find(
                 SourcePackagePublishingHistory,
                 SourcePackagePublishingHistory.distroseriesID ==

=== modified file 'lib/lp/registry/model/milestone.py'
--- lib/lp/registry/model/milestone.py	2010-08-30 22:08:25 +0000
+++ lib/lp/registry/model/milestone.py	2010-09-19 21:32:44 +0000
@@ -36,6 +36,7 @@
     sqlvalues,
     )
 from canonical.launchpad.webapp.sorting import expand_numbers
+from lazr.restful.error import expose
 from lp.app.errors import NotFoundError
 from lp.blueprints.model.specification import Specification
 from lp.bugs.interfaces.bugtarget import IHasBugs
@@ -116,6 +117,13 @@
         return result.order_by(self._milestone_order)
 
 
+class MultipleProductReleases(Exception):
+    """Raised when a second ProductRelease is created for a milestone."""
+
+    def __init__(self, msg='A milestone can only have one ProductRelease.'):
+        super(MultipleProductReleases, self).__init__(msg)
+
+
 class Milestone(SQLBase, StructuralSubscriptionTargetMixin, HasBugsBase):
     implements(IHasBugs, IMilestone)
 
@@ -201,8 +209,7 @@
                              changelog=None, release_notes=None):
         """See `IMilestone`."""
         if self.product_release is not None:
-            raise AssertionError(
-                'A milestone can only have one ProductRelease.')
+            raise expose(MultipleProductReleases())
         release = ProductRelease(
             owner=owner,
             changelog=changelog,

=== modified file 'lib/lp/registry/model/product.py'
--- lib/lp/registry/model/product.py	2010-09-03 11:05:21 +0000
+++ lib/lp/registry/model/product.py	2010-09-19 21:32:44 +0000
@@ -17,6 +17,7 @@
 import operator
 
 from lazr.delegates import delegates
+from lazr.restful.error import expose
 import pytz
 from sqlobject import (
     BoolCol,
@@ -48,6 +49,9 @@
     SQLBase,
     sqlvalues,
     )
+from canonical.launchpad.components.decoratedresultset import (
+    DecoratedResultSet,
+    )
 from canonical.launchpad.interfaces.launchpad import (
     IHasIcon,
     IHasLogo,
@@ -64,6 +68,7 @@
     MAIN_STORE,
     )
 from canonical.launchpad.webapp.sorting import sorted_version_numbers
+
 from lp.answers.interfaces.faqtarget import IFAQTarget
 from lp.answers.interfaces.questioncollection import (
     QUESTION_STATUS_DEFAULT_SEARCH,
@@ -146,6 +151,7 @@
 from lp.registry.model.productlicense import ProductLicense
 from lp.registry.model.productrelease import ProductRelease
 from lp.registry.model.productseries import ProductSeries
+from lp.registry.model.sourcepackagename import SourcePackageName
 from lp.registry.model.structuralsubscription import (
     StructuralSubscriptionTargetMixin,
     )
@@ -271,6 +277,13 @@
                 tables=[ProductLicense]))
 
 
+class UnDeactivateable(Exception):
+    """Raised when a project is requested to deactivate but can not."""
+
+    def __init__(self, msg):
+        super(UnDeactivateable, self).__init__(msg)
+
+
 class Product(SQLBase, BugTargetBase, MakesAnnouncements,
               HasSpecificationsMixin, HasSprintsMixin,
               KarmaContextMixin, BranchVisibilityPolicyMixin,
@@ -440,9 +453,9 @@
         # Validate deactivation.
         if self.active == True and value == False:
             if len(self.sourcepackages) > 0:
-                raise AssertionError(
+                raise expose(UnDeactivateable(
                     'This project cannot be deactivated since it is '
-                    'linked to source packages.')
+                    'linked to source packages.'))
         return value
 
     active = BoolCol(dbName='active', notNull=True, default=True,
@@ -829,28 +842,40 @@
             (x.sourcepackagename.name, x.distroseries.name,
              x.distroseries.distribution.name))
 
-    @property
+    @cachedproperty
     def distrosourcepackages(self):
-        from lp.registry.model.distributionsourcepackage \
-            import DistributionSourcePackage
-        clause = """ProductSeries.id=Packaging.productseries AND
-                    ProductSeries.product = %s
-                    """ % sqlvalues(self.id)
-        clauseTables = ['ProductSeries']
-        ret = Packaging.select(clause, clauseTables,
-            prejoins=["sourcepackagename", "distroseries.distribution"])
-        distros = set()
-        dsps = []
-        for packaging in ret:
-            distro = packaging.distroseries.distribution
-            if distro in distros:
-                continue
-            distros.add(distro)
-            dsps.append(DistributionSourcePackage(
-                sourcepackagename=packaging.sourcepackagename,
-                distribution=distro))
-        return sorted(dsps, key=lambda x:
-            (x.sourcepackagename.name, x.distribution.name))
+        from lp.registry.model.distributionsourcepackage import (
+            DistributionSourcePackage,
+            )
+        store = IStore(Packaging)
+        origin = [
+            Packaging,
+            Join(SourcePackageName,
+                 Packaging.sourcepackagename == SourcePackageName.id),
+            Join(ProductSeries, Packaging.productseries == ProductSeries.id),
+            Join(DistroSeries, Packaging.distroseries == DistroSeries.id),
+            Join(Distribution, DistroSeries.distribution == Distribution.id),
+            ]
+        result = store.using(*origin).find(
+            (SourcePackageName, Distribution),
+            ProductSeries.product == self)
+        result = result.order_by(SourcePackageName.name, Distribution.name)
+        result.config(distinct=True)
+
+        return [
+            DistributionSourcePackage(
+                sourcepackagename=sourcepackagename,
+                distribution=distro)
+            for sourcepackagename, distro in result]
+
+    @cachedproperty
+    def ubuntu_packages(self):
+        """The Ubuntu `IDistributionSourcePackage`s linked to the `IProduct`.
+        """
+        ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+        return [
+            package for package in self.distrosourcepackages
+            if package.distribution == ubuntu]
 
     @property
     def bugtargetdisplayname(self):

=== modified file 'lib/lp/registry/stories/webservice/xx-project-registry.txt'
--- lib/lp/registry/stories/webservice/xx-project-registry.txt	2010-07-20 17:50:45 +0000
+++ lib/lp/registry/stories/webservice/xx-project-registry.txt	2010-09-19 21:32:44 +0000
@@ -568,7 +568,9 @@
 
     >>> project_collection = webservice.named_get(
     ...     "/projects", "search", text="Apache").jsonBody()
-    >>> projects = [project['display_name'] for project in project_collection['entries']]
+    >>> projects = [
+    ...     project['display_name']
+    ...     for project in project_collection['entries']]
     >>> for project_name in sorted(projects):
     ...     print project_name
     Derby
@@ -1016,9 +1018,9 @@
     ...     date_released='2000-01-01T01:01:01+00:00Z',
     ...     changelog='Added 5,000 features.')
     >>> print response
-    HTTP/1.1 500 Internal Server Error
+    HTTP/1.1 400 Bad Request
     ...
-    AssertionError: A milestone can only have one ProductRelease.
+    MultipleProductReleases: A milestone can only have one ProductRelease.
 
 
 Project release entries
@@ -1228,6 +1230,13 @@
     >>> print_self_link_of_entries(ff_100_files)
     http://.../firefox/1.0/1.0.0/+file/filename2.txt
 
+Anonymous users can access project release files.
+
+    >>> release_files = anon_webservice.get(
+    ...     '/firefox/1.0/1.0.0/files').jsonBody()
+    >>> print_self_link_of_entries(release_files)
+    http://.../firefox/1.0/1.0.0/+file/filename2.txt
+
 
 Commercial subscriptions
 ------------------------
@@ -1252,7 +1261,6 @@
 
     >>> logout()
 
-    >>> from lazr.restful.interfaces import IRepresentationCache
     >>> ws_uncache(mmm)
     >>> mmm = webservice.get("/mega-money-maker").jsonBody()
     >>> print mmm['display_name']

=== modified file 'lib/lp/registry/templates/pillar-involvement-portlet.pt'
--- lib/lp/registry/templates/pillar-involvement-portlet.pt	2010-08-04 12:34:28 +0000
+++ lib/lp/registry/templates/pillar-involvement-portlet.pt	2010-09-19 21:32:44 +0000
@@ -3,7 +3,6 @@
   xmlns:i18n="http://xml.zope.org/namespaces/i18n";
   id="involvement" class="portlet"
   tal:condition="view/has_involvement"
-  tal:define="is_lpnet modules/canonical.config/config/launchpad/is_lpnet;"
   >
   <h2>Get Involved</h2>
 
@@ -35,7 +34,6 @@
   <tal:editor condition="context/required:launchpad.Edit"
               define="registration view/registration_completeness">
 
-    <tal:not_lpnet condition="not:is_lpnet">
     <tal:show_configuration condition="registration">
       <h2>Configuration Progress</h2>
       <div class="centered" id="progressbar">
@@ -54,7 +52,6 @@
           </table>
       </div>
     </tal:show_configuration>
-    </tal:not_lpnet>
 
     <table style="width: 100%; padding-top: 1em"
            tal:condition="view/configuration_links"
@@ -65,11 +62,9 @@
             <a tal:replace="structure link_status/link/fmt:link" />
           </td>
 
-          <tal:not_lpnet condition="not:is_lpnet">
           <td style="text-align: right;" tal:condition="registration">
             <tal:yes-no replace="structure link_status/configured/image:boolean" />
           </td>
-          </tal:not_lpnet>
 
         </tr>
       </tal:item>

=== modified file 'lib/lp/registry/tests/test_milestone.py'
--- lib/lp/registry/tests/test_milestone.py	2010-08-20 20:31:18 +0000
+++ lib/lp/registry/tests/test_milestone.py	2010-09-19 21:32:44 +0000
@@ -15,6 +15,7 @@
     logout,
     )
 from canonical.testing import LaunchpadFunctionalLayer
+
 from lp.app.errors import NotFoundError
 from lp.registry.interfaces.distribution import IDistributionSet
 from lp.registry.interfaces.milestone import IMilestoneSet
@@ -79,6 +80,7 @@
             all_visible_milestones_ids,
             [1, 2, 3])
 
+
 def test_suite():
     """Return the test suite for the tests in this module."""
     return unittest.TestLoader().loadTestsFromName(__name__)

=== modified file 'lib/lp/registry/tests/test_product.py'
--- lib/lp/registry/tests/test_product.py	2010-09-03 06:06:40 +0000
+++ lib/lp/registry/tests/test_product.py	2010-09-19 21:32:44 +0000
@@ -28,7 +28,7 @@
     License,
     )
 from lp.registry.model.commercialsubscription import CommercialSubscription
-from lp.registry.model.product import Product
+from lp.registry.model.product import Product, UnDeactivateable
 from lp.registry.model.productlicense import ProductLicense
 from lp.testing import TestCaseWithFactory
 
@@ -48,7 +48,7 @@
         source_package.setPackaging(
             product.development_focus, self.factory.makePerson())
         self.assertRaises(
-            AssertionError,
+            UnDeactivateable,
             setattr, product, 'active', False)
 
     def test_deactivation_success(self):

=== modified file 'lib/lp/registry/tests/test_project.py'
--- lib/lp/registry/tests/test_project.py	2010-08-20 20:31:18 +0000
+++ lib/lp/registry/tests/test_project.py	2010-09-19 21:32:44 +0000
@@ -6,11 +6,22 @@
 import unittest
 
 from zope.component import getUtility
+from lazr.restfulclient.errors import ClientError
 
+from canonical.launchpad.testing.pages import LaunchpadWebServiceCaller
 from canonical.launchpad.ftests import login
-from canonical.testing import LaunchpadFunctionalLayer
+from canonical.launchpad.webapp.errorlog import globalErrorUtility
+from canonical.testing import (
+    LaunchpadFunctionalLayer,
+    DatabaseFunctionalLayer,
+    )
 from lp.registry.interfaces.projectgroup import IProjectGroupSet
-from lp.testing import TestCaseWithFactory
+from lp.soyuz.enums import ArchivePurpose
+from lp.testing import (
+    celebrity_logged_in,
+    launchpadlib_for,
+    TestCaseWithFactory,
+    )
 
 
 class ProjectGroupSearchTestCase(TestCaseWithFactory):
@@ -99,5 +110,27 @@
         self.assertEqual(self.project3, results[0])
 
 
+class TestLaunchpadlibAPI(TestCaseWithFactory):
+    layer = DatabaseFunctionalLayer
+
+    def test_inappropriate_deactivation_does_not_cause_an_OOPS(self):
+        # Make sure a 400 error and not an OOPS is returned when a ValueError
+        # is raised when trying to deactivate a project that has source
+        # releases.
+        last_oops = globalErrorUtility.getLastOopsReport()
+        launchpad = launchpadlib_for("test", "salgado", "WRITE_PUBLIC")
+
+        project = launchpad.projects['evolution']
+        project.active = False
+        e = self.assertRaises(ClientError, project.lp_save)
+
+        # no OOPS was generated as a result of the exception
+        self.assertNoNewOops(last_oops)
+        self.assertEqual(400, e.response.status)
+        self.assertIn(
+            'This project cannot be deactivated since it is linked to source '
+            'packages.', e.content)
+
+
 def test_suite():
     return unittest.TestLoader().loadTestsFromName(__name__)

=== modified file 'lib/lp/registry/tests/test_project_milestone.py'
--- lib/lp/registry/tests/test_project_milestone.py	2010-08-20 20:31:18 +0000
+++ lib/lp/registry/tests/test_project_milestone.py	2010-09-19 21:32:44 +0000
@@ -10,12 +10,19 @@
 
 from storm.store import Store
 from zope.component import getUtility
+import pytz
 
 from canonical.launchpad.ftests import (
     login,
     syncUpdate,
     )
-from canonical.testing import LaunchpadFunctionalLayer
+from canonical.launchpad.webapp.errorlog import globalErrorUtility
+from canonical.testing import (
+    LaunchpadFunctionalLayer,
+    DatabaseFunctionalLayer,
+    )
+from lazr.restfulclient.errors import ClientError
+
 from lp.blueprints.interfaces.specification import (
     ISpecificationSet,
     SpecificationDefinitionStatus,
@@ -30,6 +37,11 @@
 from lp.registry.interfaces.person import IPersonSet
 from lp.registry.interfaces.product import IProductSet
 from lp.registry.interfaces.projectgroup import IProjectGroupSet
+from lp.registry.model.milestone import MultipleProductReleases
+from lp.testing import (
+    launchpadlib_for,
+    TestCaseWithFactory,
+    )
 
 
 class ProjectMilestoneTest(unittest.TestCase):
@@ -197,7 +209,7 @@
         spec = specset.new(
             name='%s-specification' % product_name,
             title='Title %s specification' % product_name,
-            specurl='http://www.example.com/spec/%s' %product_name ,
+            specurl='http://www.example.com/spec/%s' % product_name,
             summary='summary',
             definition_status=SpecificationDefinitionStatus.APPROVED,
             priority=SpecificationPriority.HIGH,
@@ -317,6 +329,48 @@
         self._createProductSeriesBugtask('evolution', 'trunk', '1.1')
 
 
+class TestDuplicateProductReleases(TestCaseWithFactory):
+    layer = DatabaseFunctionalLayer
+
+    def test_inappropriate_release_raises(self):
+        # A milestone that already has a ProductRelease can not be given
+        # another one.
+        login('foo.bar@xxxxxxxxxxxxx')
+        product_set = getUtility(IProductSet)
+        product = product_set['evolution']
+        series = product.getSeries('trunk')
+        milestone = series.newMilestone(name='1.1', dateexpected=None)
+        now = datetime.now(pytz.UTC)
+        milestone.createProductRelease(1, now)
+        self.assertRaises(MultipleProductReleases,
+            milestone.createProductRelease, 1, now)
+        try:
+            milestone.createProductRelease(1, now)
+        except MultipleProductReleases, e:
+            self.assert_(
+                str(e), 'A milestone can only have one ProductRelease.')
+
+    def test_inappropriate_deactivation_does_not_cause_an_OOPS(self):
+        # Make sure a 400 error and not an OOPS is returned when an exception
+        # is raised when trying to create a product release when a milestone
+        # already has one.
+        last_oops = globalErrorUtility.getLastOopsReport()
+        launchpad = launchpadlib_for("test", "salgado", "WRITE_PUBLIC")
+
+        project = launchpad.projects['evolution']
+        milestone = project.getMilestone(name='2.1.6')
+        now = datetime.now(pytz.UTC)
+
+        e = self.assertRaises(
+            ClientError, milestone.createProductRelease, date_released=now)
+
+        # no OOPS was generated as a result of the exception
+        self.assertNoNewOops(last_oops)
+        self.assertEqual(400, e.response.status)
+        self.assertIn(
+            'A milestone can only have one ProductRelease.', e.content)
+
+
 def test_suite():
     """Return the test suite for the tests in this module."""
     return unittest.TestLoader().loadTestsFromName(__name__)

=== modified file 'lib/lp/services/apachelogparser/base.py'
--- lib/lp/services/apachelogparser/base.py	2010-08-30 17:12:59 +0000
+++ lib/lp/services/apachelogparser/base.py	2010-09-19 21:32:44 +0000
@@ -11,13 +11,13 @@
 from zope.component import getUtility
 
 from canonical.config import config
-from canonical.launchpad.interfaces.geoip import IGeoIP
 from canonical.launchpad.webapp.interfaces import (
     DEFAULT_FLAVOR,
     IStoreSelector,
     MAIN_STORE,
     )
 from lp.services.apachelogparser.model.parsedapachelog import ParsedApacheLog
+from lp.services.geoip.interfaces import IGeoIP
 
 
 parser = apachelog.parser(apachelog.formats['extended'])

=== modified file 'lib/lp/services/configure.zcml'
--- lib/lp/services/configure.zcml	2010-08-20 12:11:28 +0000
+++ lib/lp/services/configure.zcml	2010-09-19 21:32:44 +0000
@@ -6,8 +6,11 @@
   <adapter factory=".propertycache.get_default_cache"/>
   <adapter factory=".propertycache.PropertyCacheManager"/>
   <adapter factory=".propertycache.DefaultPropertyCacheManager"/>
+  <include package=".comments" />
   <include package=".database" />
   <include package=".features" />
+  <include package=".fields" />
+  <include package=".geoip" />
   <include package=".inlinehelp" file="meta.zcml" />
   <include package=".job" />
   <include package=".memcache" />

=== added directory 'lib/lp/services/geoip'
=== added file 'lib/lp/services/geoip/__init__.py'
--- lib/lp/services/geoip/__init__.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/geoip/__init__.py	2010-09-19 21:32:44 +0000
@@ -0,0 +1,3 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+"""Launchpad integration of the GEOIP library."""

=== renamed file 'lib/canonical/launchpad/zcml/geoip.zcml' => 'lib/lp/services/geoip/configure.zcml'
--- lib/canonical/launchpad/zcml/geoip.zcml	2009-07-13 18:15:02 +0000
+++ lib/lp/services/geoip/configure.zcml	2010-09-19 21:32:44 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2009 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2009-2010 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -9,34 +9,34 @@
     xmlns:zope="http://namespaces.zope.org/zope";
     i18n_domain="launchpad">
 
-    <class class="canonical.launchpad.utilities.geoip.GeoIP">
-        <allow interface="canonical.launchpad.interfaces.IGeoIP" />
+    <class class="lp.services.geoip.model.GeoIP">
+        <allow interface="lp.services.geoip.interfaces.IGeoIP" />
     </class>
 
     <securedutility
-        class="canonical.launchpad.utilities.geoip.GeoIP"
-        provides="canonical.launchpad.interfaces.IGeoIP">
-        <allow interface="canonical.launchpad.interfaces.IGeoIP" />
+        class="lp.services.geoip.model.GeoIP"
+        provides="lp.services.geoip.interfaces.IGeoIP">
+        <allow interface="lp.services.geoip.interfaces.IGeoIP" />
     </securedutility>
 
     <adapter
         for="zope.publisher.interfaces.browser.IBrowserRequest"
-        factory="canonical.launchpad.utilities.geoip.RequestLocalLanguages"
-        provides="canonical.launchpad.interfaces.IRequestLocalLanguages" />
-
-    <adapter
-        for="zope.publisher.interfaces.browser.IBrowserRequest"
-        factory="canonical.launchpad.utilities.geoip.RequestPreferredLanguages"
-        provides="canonical.launchpad.interfaces.IRequestPreferredLanguages" />
-
-    <adapter
-        for="zope.publisher.interfaces.browser.IBrowserRequest"
-        factory="canonical.launchpad.components.request_country.request_country"
-        provides="canonical.launchpad.interfaces.ICountry" />
-
-    <adapter
-        for="zope.publisher.interfaces.browser.IBrowserRequest"
-        factory="canonical.launchpad.utilities.geoip.GeoIPRequest"
-        provides="canonical.launchpad.interfaces.IGeoIPRecord" />
+        factory="lp.services.geoip.model.RequestLocalLanguages"
+        provides="lp.services.geoip.interfaces.IRequestLocalLanguages" />
+
+    <adapter
+        for="zope.publisher.interfaces.browser.IBrowserRequest"
+        factory="lp.services.geoip.model.RequestPreferredLanguages"
+        provides="lp.services.geoip.interfaces.IRequestPreferredLanguages" />
+
+    <adapter
+        for="zope.publisher.interfaces.browser.IBrowserRequest"
+        factory="lp.services.geoip.helpers.request_country"
+        provides="lp.services.worlddata.interfaces.country.ICountry" />
+
+    <adapter
+        for="zope.publisher.interfaces.browser.IBrowserRequest"
+        factory="lp.services.geoip.model.GeoIPRequest"
+        provides="lp.services.geoip.interfaces.IGeoIPRecord" />
 
 </configure>

=== added directory 'lib/lp/services/geoip/doc'
=== renamed file 'lib/canonical/launchpad/doc/geoip.txt' => 'lib/lp/services/geoip/doc/geoip.txt'
--- lib/canonical/launchpad/doc/geoip.txt	2009-04-17 10:32:16 +0000
+++ lib/lp/services/geoip/doc/geoip.txt	2010-09-19 21:32:44 +0000
@@ -1,10 +1,11 @@
-= GeoIP =
+GeoIP
+=====
 
 GeoIP allows us to guess the location of a user based on his IP address.
 Our IGeoIP utility provides a couple methods to get location information
 from a given IP address.
 
-    >>> from canonical.launchpad.interfaces.geoip import IGeoIP
+    >>> from lp.services.geoip.interfaces import IGeoIP
     >>> geoip = getUtility(IGeoIP)
 
 The getCountryByAddr() method will return the country of the given IP
@@ -80,14 +81,13 @@
 request's originating IP address.
 
     >>> from canonical.launchpad.webapp.servers import LaunchpadTestRequest
-    >>> from canonical.launchpad.interfaces.geoip import IGeoIPRecord
+    >>> from lp.services.geoip.interfaces import IGeoIPRecord
     >>> request = LaunchpadTestRequest()
 
 Since our request won't have an originating IP address, we'll use that
 same South African IP address.
 
-    >>> from canonical.launchpad.components.request_country import (
-    ...     ipaddress_from_request)
+    >>> from lp.services.geoip.helpers import ipaddress_from_request
     >>> print ipaddress_from_request(request)
     None
     >>> geoip_request = IGeoIPRecord(request)

=== renamed file 'lib/canonical/launchpad/components/request_country.py' => 'lib/lp/services/geoip/helpers.py'
--- lib/canonical/launchpad/components/request_country.py	2010-08-20 20:31:18 +0000
+++ lib/lp/services/geoip/helpers.py	2010-09-19 21:32:44 +0000
@@ -7,10 +7,14 @@
 
 from zope.component import getUtility
 
-from canonical.launchpad.interfaces import IGeoIP
-
-
-__all__ = ['request_country', 'ipaddress_from_request']
+from lp.services.geoip.interfaces import IGeoIP
+
+
+__all__ = [
+    'request_country',
+    'ipaddress_from_request',
+    ]
+
 
 def request_country(request):
     """Adapt a request to the country in which the request was made.
@@ -26,8 +30,10 @@
         return getUtility(IGeoIP).getCountryByAddr(ipaddress)
     return None
 
+
 _ipaddr_re = re.compile('\d\d?\d?\.\d\d?\d?\.\d\d?\d?\.\d\d?\d?')
 
+
 def ipaddress_from_request(request):
     """Determine the IP address for this request.
 
@@ -67,8 +73,7 @@
     ipaddresses = [
         addr for addr in ipaddresses
             if not (addr.startswith('127.')
-                    or _ipaddr_re.search(addr) is None)
-        ]
+                    or _ipaddr_re.search(addr) is None)]
 
     if ipaddresses:
         # If we have more than one, have a guess.

=== renamed file 'lib/canonical/launchpad/interfaces/geoip.py' => 'lib/lp/services/geoip/interfaces.py'
=== renamed file 'lib/canonical/launchpad/utilities/geoip.py' => 'lib/lp/services/geoip/model.py'
--- lib/canonical/launchpad/utilities/geoip.py	2010-08-24 10:45:57 +0000
+++ lib/lp/services/geoip/model.py	2010-09-19 21:32:44 +0000
@@ -16,10 +16,8 @@
 from zope.interface import implements
 
 from canonical.config import config
-from canonical.launchpad.components.request_country import (
-    ipaddress_from_request,
-    )
-from canonical.launchpad.interfaces.geoip import (
+from lp.services.geoip.helpers import ipaddress_from_request
+from lp.services.geoip.interfaces import (
     IGeoIP,
     IGeoIPRecord,
     IRequestLocalLanguages,
@@ -144,7 +142,7 @@
 
         codes = IUserPreferredLanguages(self.request).getPreferredLanguages()
         languageset = getUtility(ILanguageSet)
-        languages = []
+        languages = set()
 
         for code in codes:
             # We need to ensure that the code received contains only ASCII
@@ -164,7 +162,7 @@
                     continue
             code = languageset.canonicalise_language_code(code)
             try:
-                languages.append(languageset[code])
+                languages.add(languageset[code])
             except KeyError:
                 pass
 

=== added directory 'lib/lp/services/geoip/tests'
=== added file 'lib/lp/services/geoip/tests/__init__.py'
=== renamed file 'lib/canonical/launchpad/components/tests/test_request_country.py' => 'lib/lp/services/geoip/tests/test_doc.py'
--- lib/canonical/launchpad/components/tests/test_request_country.py	2010-08-20 20:31:18 +0000
+++ lib/lp/services/geoip/tests/test_doc.py	2010-09-19 21:32:44 +0000
@@ -1,19 +1,23 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2010 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
-"""Module docstring goes here."""
+"""Test GEOIP documentation."""
 
 __metaclass__ = type
 
+import os
 from doctest import DocTestSuite
-import unittest
+
+from canonical.testing.layers import LaunchpadFunctionalLayer
+from lp.services.testing import build_test_suite
+
+
+here = os.path.dirname(os.path.realpath(__file__))
 
 
 def test_suite():
-    import canonical.launchpad.components.request_country
-    return DocTestSuite(canonical.launchpad.components.request_country)
-
-if __name__ == '__main__':
-    DEFAULT = test_suite()
-    unittest.main(defaultTest='DEFAULT')
-
+    import lp.services.geoip.helpers
+    inline_doctest = DocTestSuite(lp.services.geoip.helpers)
+    suite = build_test_suite(here, {}, layer=LaunchpadFunctionalLayer)
+    suite.addTest(inline_doctest)
+    return suite

=== renamed file 'lib/canonical/launchpad/components/ftests/test_request_country.py' => 'lib/lp/services/geoip/tests/test_request_country.py'
--- lib/canonical/launchpad/components/ftests/test_request_country.py	2010-08-20 20:31:18 +0000
+++ lib/lp/services/geoip/tests/test_request_country.py	2010-09-19 21:32:44 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2010 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Functional tests for request_country"""
@@ -6,12 +6,12 @@
 
 import unittest
 
-from canonical.launchpad.components.request_country import request_country
 from canonical.launchpad.ftests import (
     ANONYMOUS,
     login,
     logout,
     )
+from lp.services.geoip.helpers import request_country
 from canonical.testing import LaunchpadFunctionalLayer
 
 

=== modified file 'lib/lp/services/job/tests/test_runner.py'
--- lib/lp/services/job/tests/test_runner.py	2010-08-20 20:31:18 +0000
+++ lib/lp/services/job/tests/test_runner.py	2010-09-19 21:32:44 +0000
@@ -149,7 +149,6 @@
 
     def test_runAll_skips_lease_failures(self):
         """Ensure runAll skips jobs whose leases can't be acquired."""
-        last_oops = errorlog.globalErrorUtility.getLastOopsReport()
         job_1, job_2 = self.makeTwoJobs()
         job_2.job.acquireLease()
         runner = JobRunner([job_1, job_2])
@@ -158,8 +157,7 @@
         self.assertEqual(JobStatus.WAITING, job_2.job.status)
         self.assertEqual([job_1], runner.completed_jobs)
         self.assertEqual([job_2], runner.incomplete_jobs)
-        new_last_oops = errorlog.globalErrorUtility.getLastOopsReport()
-        self.assertEqual(last_oops.id, new_last_oops.id)
+        self.assertEqual([], self.oopses)
 
     def test_runAll_reports_oopses(self):
         """When an error is encountered, report an oops and continue."""
@@ -177,7 +175,7 @@
         self.assertEqual(JobStatus.FAILED, job_1.job.status)
         self.assertEqual(JobStatus.COMPLETED, job_2.job.status)
         reporter = errorlog.globalErrorUtility
-        oops = reporter.getLastOopsReport()
+        oops = self.oopses[-1]
         self.assertIn('Fake exception.  Foobar, I say!', oops.tb_text)
         self.assertEqual(1, len(oops.req_vars))
         self.assertEqual("{'foo': 'bar'}", oops.req_vars[0][1])
@@ -195,7 +193,7 @@
         runner = JobRunner([job_1, job_2])
         runner.runAll()
         reporter = getUtility(IErrorReportingUtility)
-        oops = reporter.getLastOopsReport()
+        oops = self.oopses[-1]
         self.assertEqual(1, len(oops.req_vars))
         self.assertEqual("{'foo': 'bar'}", oops.req_vars[0][1])
 
@@ -231,7 +229,7 @@
         runner.runAll()
         (notification,) = pop_notifications()
         reporter = errorlog.globalErrorUtility
-        oops = reporter.getLastOopsReport()
+        oops = self.oopses[-1]
         self.assertIn(
             'Launchpad encountered an internal error during the following'
             ' operation: appending a string to a list.  It was logged with id'
@@ -257,10 +255,8 @@
         job_1.user_error_types = (ExampleError,)
         job_1.error_recipients = ['jrandom@xxxxxxxxxxx']
         runner = JobRunner([job_1, job_2])
-        reporter = errorlog.globalErrorUtility
-        old_oops = reporter.getLastOopsReport()
         runner.runAll()
-        self.assertNoNewOops(old_oops)
+        self.assertEqual([], self.oopses)
         notifications = pop_notifications()
         self.assertEqual(1, len(notifications))
         body = notifications[0].get_payload(decode=True)

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2010-09-09 20:12:02 +0000
+++ lib/lp/testing/factory.py	2010-09-19 21:32:44 +0000
@@ -182,6 +182,10 @@
 from lp.registry.interfaces.mailinglistsubscription import (
     MailingListAutoSubscribePolicy,
     )
+from lp.registry.interfaces.packaging import (
+    IPackagingUtil,
+    PackagingType,
+    )
 from lp.registry.interfaces.person import (
     IPerson,
     IPersonSet,
@@ -993,6 +997,29 @@
             removeSecurityProxy(branch).reviewer = reviewer
         return branch
 
+    def makePackagingLink(self, productseries=None, sourcepackagename=None,
+                          distroseries=None, packaging_type=None, owner=None,
+                          in_ubuntu=False):
+        if productseries is None:
+            productseries = self.makeProduct().development_focus
+        if sourcepackagename is None or isinstance(sourcepackagename, str):
+            sourcepackagename = self.makeSourcePackageName(sourcepackagename)
+        if distroseries is None:
+            distribution = None
+            if in_ubuntu:
+                distribution = getUtility(ILaunchpadCelebrities).ubuntu
+            distroseries = self.makeDistroSeries(distribution=distribution)
+        if packaging_type is None:
+            packaging_type = PackagingType.PRIME
+        if owner is None:
+            owner = self.makePerson()
+        return getUtility(IPackagingUtil).createPackaging(
+            productseries=productseries,
+            sourcepackagename=sourcepackagename,
+            distroseries=distroseries,
+            packaging=packaging_type,
+            owner=owner)
+
     def makePackageBranch(self, sourcepackage=None, distroseries=None,
                           sourcepackagename=None, **kwargs):
         """Make a package branch on an arbitrary package.

=== modified file 'lib/lp/testing/menu.py'
--- lib/lp/testing/menu.py	2010-09-12 11:43:36 +0000
+++ lib/lp/testing/menu.py	2010-09-19 21:32:44 +0000
@@ -11,7 +11,7 @@
 def check_menu_links(menu):
     context = menu.context
     for link in menu.iterlinks():
-        if link.target.startswith('/'):
+        if link.target.startswith(('/', 'http://')):
             # The context is not the context of this target.
             continue
         if '?' in link.target:

=== modified file 'lib/lp/translations/browser/translations.py'
--- lib/lp/translations/browser/translations.py	2010-08-24 10:45:57 +0000
+++ lib/lp/translations/browser/translations.py	2010-09-19 21:32:44 +0000
@@ -19,7 +19,6 @@
 
 from canonical.config import config
 from canonical.launchpad import helpers
-from canonical.launchpad.interfaces.geoip import IRequestPreferredLanguages
 from canonical.launchpad.interfaces.launchpad import (
     ILaunchpadCelebrities,
     IRosettaApplication,
@@ -35,6 +34,7 @@
 from canonical.launchpad.webapp.interfaces import ILaunchpadRoot
 from lp.registry.interfaces.person import IPersonSet
 from lp.registry.interfaces.product import IProductSet
+from lp.services.geoip.interfaces import IRequestPreferredLanguages
 from lp.services.propertycache import cachedproperty
 from lp.services.worlddata.interfaces.country import ICountry
 from lp.translations.publisher import TranslationsLayer

=== modified file 'lib/lp/translations/doc/preferred-languages.txt'
--- lib/lp/translations/doc/preferred-languages.txt	2009-10-22 11:55:51 +0000
+++ lib/lp/translations/doc/preferred-languages.txt	2010-09-19 21:32:44 +0000
@@ -3,17 +3,27 @@
 in ASCII we just skip them.
 
     >>> from canonical.launchpad.webapp.servers import LaunchpadTestRequest
-    >>> from canonical.launchpad.utilities.geoip import (
-    ...     RequestPreferredLanguages)
+    >>> from lp.services.geoip.model import RequestPreferredLanguages
 
     >>> langs = {'HTTP_ACCEPT_LANGUAGE': 'pt_BR, Espa\xf1ol'}
     >>> request = LaunchpadTestRequest(**langs)
     >>> [l.code
     ...  for l in RequestPreferredLanguages(request).getPreferredLanguages()]
     [u'pt_BR']
-  
+
     >>> langs = {'HTTP_ACCEPT_LANGUAGE': u'pt_BR, Espa\xf1ol'}
     >>> request = LaunchpadTestRequest(**langs)
     >>> [l.code
     ...  for l in RequestPreferredLanguages(request).getPreferredLanguages()]
     [u'pt_BR']
+
+The getPreferredLanguages() method returns unique codes.
+
+    >>> langs = {
+    ...     'HTTP_ACCEPT_LANGUAGE': 'en-US,en;q=0.9,de-CH;q=0.8,de;q=0.6,'
+    ...                             'en-GB;q=0.4,en-us;q=0.3,en;q=0.1'
+    ...     }
+    >>> request = LaunchpadTestRequest(**langs)
+    >>> [l.code
+    ...  for l in RequestPreferredLanguages(request).getPreferredLanguages()]
+    [u'en', u'en_GB', u'de']

=== modified file 'utilities/rocketfuel-mp-status'
--- utilities/rocketfuel-mp-status	2010-07-16 10:14:48 +0000
+++ utilities/rocketfuel-mp-status	2010-09-19 21:32:44 +0000
@@ -11,8 +11,11 @@
     '. ~/.rocketfuel-env.sh && echo $LP_TRUNK_NAME')
 projpath = commands.getoutput(
     '. ~/.rocketfuel-env.sh && echo $LP_PROJECT_PATH')
-os.chdir(projpath)
-branches = os.listdir('.')
+if len(sys.argv) > 1:
+    branches = sys.argv[1:]
+else:
+    os.chdir(projpath)
+    branches = os.listdir('.')
 lp_branches = launchpad.branches
 
 for lb in branches:

=== modified file 'versions.cfg'
--- versions.cfg	2010-09-07 08:17:36 +0000
+++ versions.cfg	2010-09-19 21:32:44 +0000
@@ -31,7 +31,7 @@
 lazr.delegates = 1.2.0
 lazr.enum = 1.1.2
 lazr.lifecycle = 1.1
-lazr.restful = 0.11.3
+lazr.restful = 0.13.0
 lazr.restfulclient = 0.10.0
 lazr.smtptest = 1.1
 lazr.testing = 0.1.1