← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/restore-dsp-picker into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/restore-dsp-picker into lp:launchpad with lp:~cjwatson/launchpad/remove-bpn-vocabulary as a prerequisite.

Commit message:
Restore DistributionSourcePackage vocabulary, this time with working support for official branches and with its previous performance problems fixed.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/restore-dsp-picker/+merge/297117

Restore DistributionSourcePackage vocabulary.

This reverts https://code.launchpad.net/~stevenk/launchpad/destroy-dsp_picker-ff/+merge/128138, but this time we have working support for official branches (thanks to previous work to get those into DistributionSourcePackageCache), and I've spent some quality time with EXPLAIN ANALYZE to make sure that it performs well.  The core query is <60ms hot for the pathological case of "linux".
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/restore-dsp-picker into lp:launchpad.
=== modified file 'lib/lp/app/widgets/launchpadtarget.py'
--- lib/lp/app/widgets/launchpadtarget.py	2015-10-26 14:54:43 +0000
+++ lib/lp/app/widgets/launchpadtarget.py	2016-06-10 22:19:03 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -34,6 +34,7 @@
     IDistributionSourcePackage,
     )
 from lp.registry.interfaces.product import IProduct
+from lp.services.features import getFeatureFlag
 from lp.services.webapp.interfaces import (
     IAlwaysSubmittedWidget,
     IMultiLineWidgetLayout,
@@ -57,6 +58,12 @@
     def setUpSubWidgets(self):
         if self._widgets_set_up:
             return
+        if bool(getFeatureFlag('disclosure.dsp_picker.enabled')):
+            # Replace the default field with a field that uses the better
+            # vocabulary.
+            package_vocab = 'DistributionSourcePackage'
+        else:
+            package_vocab = 'BinaryAndSourcePackageName'
         fields = [
             Choice(
                 __name__='product', title=u'Project',
@@ -67,7 +74,7 @@
                 default=getUtility(ILaunchpadCelebrities).ubuntu),
             Choice(
                 __name__='package', title=u"Package",
-                required=False, vocabulary='BinaryAndSourcePackageName'),
+                required=False, vocabulary=package_vocab),
             ]
         self.distribution_widget = CustomWidgetFactory(
             LaunchpadDropdownWidget)
@@ -137,6 +144,9 @@
                         " Launchpad" % entered_name))
                 raise self._error
             if self.package_widget.hasInput():
+                if bool(getFeatureFlag('disclosure.dsp_picker.enabled')):
+                    self.package_widget.vocabulary.setDistribution(
+                        distribution)
                 try:
                     package_name = self.package_widget.getInputValue()
                     if package_name is None:

=== modified file 'lib/lp/app/widgets/tests/test_launchpadtarget.py'
--- lib/lp/app/widgets/tests/test_launchpadtarget.py	2015-07-08 16:05:11 +0000
+++ lib/lp/app/widgets/tests/test_launchpadtarget.py	2016-06-10 22:19:03 +0000
@@ -1,4 +1,4 @@
-# Copyright 2011-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2011-2016 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -20,9 +20,11 @@
 from lp.app.validators import LaunchpadValidationError
 from lp.app.widgets.launchpadtarget import LaunchpadTargetWidget
 from lp.registry.vocabularies import (
+    DistributionSourcePackageVocabulary,
     DistributionVocabulary,
     ProductVocabulary,
     )
+from lp.services.features.testing import FeatureFixture
 from lp.services.webapp.escaping import html_escape
 from lp.services.webapp.servers import LaunchpadTestRequest
 from lp.soyuz.model.binaryandsourcepackagename import (
@@ -123,6 +125,15 @@
         self.assertIs(None, getattr(self.widget, 'package_widget', None))
         self.assertIs(None, getattr(self.widget, 'product_widget', None))
 
+    def test_setUpSubWidgets_dsp_picker_feature_flag(self):
+        # The DistributionSourcePackageVocabulary is used when the
+        # disclosure.dsp_picker.enabled is true.
+        with FeatureFixture({u"disclosure.dsp_picker.enabled": u"on"}):
+            self.widget.setUpSubWidgets()
+        self.assertIsInstance(
+            self.widget.package_widget.context.vocabulary,
+            DistributionSourcePackageVocabulary)
+
     def test_setUpOptions_default_package_checked(self):
         # The radio button options are composed of the setup widgets with
         # the package widget set as the default.
@@ -184,6 +195,24 @@
         self.widget.request = LaunchpadTestRequest(form=self.form)
         self.assertEqual(self.package, self.widget.getInputValue())
 
+    def test_getInputValue_package_spn_dsp_picker_feature_flag(self):
+        # The field value is the package when the package radio button
+        # is selected and the package sub field has a official dsp.
+        self.widget.request = LaunchpadTestRequest(form=self.form)
+        with FeatureFixture({u"disclosure.dsp_picker.enabled": u"on"}):
+            self.widget.setUpSubWidgets()
+            self.assertEqual(self.package, self.widget.getInputValue())
+
+    def test_getInputValue_package_dsp_dsp_picker_feature_flag(self):
+        # The field value is the package when the package radio button
+        # is selected and the package sub field has valid input.
+        form = self.form
+        form['field.target.package'] = 'fnord/snarf'
+        self.widget.request = LaunchpadTestRequest(form=form)
+        with FeatureFixture({u"disclosure.dsp_picker.enabled": u"on"}):
+            self.widget.setUpSubWidgets()
+            self.assertEqual(self.package, self.widget.getInputValue())
+
     def test_getInputValue_package_invalid(self):
         # An error is raised when the package is not published in the distro.
         form = self.form

=== modified file 'lib/lp/bugs/browser/bugalsoaffects.py'
--- lib/lp/bugs/browser/bugalsoaffects.py	2016-01-26 15:47:37 +0000
+++ lib/lp/bugs/browser/bugalsoaffects.py	2016-06-10 22:19:03 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2014 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -81,6 +81,7 @@
     License,
     )
 from lp.registry.model.product import Product
+from lp.services.features import getFeatureFlag
 from lp.services.fields import StrippedTextLine
 from lp.services.propertycache import cachedproperty
 from lp.services.webapp import canonical_url
@@ -342,13 +343,25 @@
         self.next_url = canonical_url(task_added)
 
 
+class IAddDistroBugTaskForm(IAddBugTaskForm):
+
+    sourcepackagename = Choice(
+        title=_("Source Package Name"), required=False,
+        description=_("The source package in which the bug occurs. "
+                      "Leave blank if you are not sure."),
+        vocabulary='DistributionSourcePackage')
+
+
 class DistroBugTaskCreationStep(BugTaskCreationStep):
     """Specialized BugTaskCreationStep for reporting a bug in a distribution.
     """
 
     @property
     def schema(self):
-        return IAddBugTaskForm
+        if bool(getFeatureFlag('disclosure.dsp_picker.enabled')):
+            return IAddDistroBugTaskForm
+        else:
+            return IAddBugTaskForm
 
     custom_widget(
         'sourcepackagename', BugTaskAlsoAffectsSourcePackageNameWidget)

=== modified file 'lib/lp/bugs/browser/tests/test_bugalsoaffects.py'
--- lib/lp/bugs/browser/tests/test_bugalsoaffects.py	2016-06-10 22:19:03 +0000
+++ lib/lp/bugs/browser/tests/test_bugalsoaffects.py	2016-06-10 22:19:03 +0000
@@ -5,6 +5,7 @@
 
 from zope.security.proxy import removeSecurityProxy
 
+from lp.services.features.testing import FeatureFixture
 from lp.services.webapp import canonical_url
 from lp.soyuz.enums import PackagePublishingStatus
 from lp.testing import TestCaseWithFactory
@@ -40,6 +41,37 @@
         browser.getControl('Continue').click()
         self.assertEqual([], get_feedback_messages(browser.contents))
 
+    def test_bug_alsoaffects_spn_exists_dsp_picker_feature_flag(self):
+        # If the distribution source package for an spn is official,
+        # there is no error.
+        bug = self.factory.makeBug()
+        distribution, dsp = self.factory.makeDSPCache(
+            distro_name=self.distribution.name, package_name='snarf',
+            make_distro=False)
+        with FeatureFixture({u"disclosure.dsp_picker.enabled": u"on"}):
+            browser = self.openBugPage(bug)
+            browser.getLink(url='+distrotask').click()
+            browser.getControl('Distribution').value = [distribution.name]
+            browser.getControl('Source Package Name').value = (
+                dsp.sourcepackagename.name)
+            browser.getControl('Continue').click()
+        self.assertEqual([], get_feedback_messages(browser.contents))
+
+    def test_bug_alsoaffects_dsp_exists_dsp_picker_feature_flag(self):
+        # If the distribution source package is official, there is no error.
+        bug = self.factory.makeBug()
+        distribution, dsp = self.factory.makeDSPCache(
+            distro_name=self.distribution.name, package_name='snarf',
+            make_distro=False)
+        with FeatureFixture({u"disclosure.dsp_picker.enabled": u"on"}):
+            browser = self.openBugPage(bug)
+            browser.getLink(url='+distrotask').click()
+            browser.getControl('Distribution').value = [distribution.name]
+            browser.getControl('Source Package Name').value = (
+                '%s/%s' % (distribution.name, dsp.name))
+            browser.getControl('Continue').click()
+        self.assertEqual([], get_feedback_messages(browser.contents))
+
     def test_bug_alsoaffects_spn_not_exists_with_published_binaries(self):
         # When the distribution has published binaries, we search both
         # source and binary package names.

=== modified file 'lib/lp/bugs/browser/widgets/bugtask.py'
--- lib/lp/bugs/browser/widgets/bugtask.py	2016-01-26 15:47:37 +0000
+++ lib/lp/bugs/browser/widgets/bugtask.py	2016-06-10 22:19:03 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Widgets related to IBugTask."""
@@ -67,6 +67,7 @@
     )
 from lp.bugs.vocabularies import UsesBugsDistributionVocabulary
 from lp.registry.interfaces.distribution import IDistributionSet
+from lp.services.features import getFeatureFlag
 from lp.services.fields import URIField
 from lp.services.webapp import canonical_url
 from lp.services.webapp.escaping import html_escape
@@ -510,6 +511,16 @@
         cached_value = self.cached_values.get(input)
         if cached_value:
             return cached_value
+        if bool(getFeatureFlag('disclosure.dsp_picker.enabled')):
+            try:
+                self.context.vocabulary.setDistribution(distribution)
+                return self.context.vocabulary.getTermByToken(input).value
+            except NotFoundError:
+                raise ConversionError(
+                    "Launchpad doesn't know of any source package named"
+                    " '%s' in %s." % (input, distribution.displayname))
+        # Else the untrusted SPN vocab was used so it needs secondary
+        # verification.
         try:
             source = distribution.guessPublishedSourcePackageName(input)
         except NotFoundError:

=== added file 'lib/lp/registry/tests/test_distributionsourcepackage_vocabulary.py'
--- lib/lp/registry/tests/test_distributionsourcepackage_vocabulary.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/tests/test_distributionsourcepackage_vocabulary.py	2016-06-10 22:19:03 +0000
@@ -0,0 +1,331 @@
+# Copyright 2011-2016 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test the Distribution Source Package vocabulary."""
+
+__metaclass__ = type
+
+from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.registry.vocabularies import DistributionSourcePackageVocabulary
+from lp.services.webapp.vocabulary import IHugeVocabulary
+from lp.testing import (
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+
+
+class TestDistributionSourcePackageVocabulary(TestCaseWithFactory):
+    """Test that the vocabulary behaves as expected."""
+    layer = DatabaseFunctionalLayer
+
+    def test_provides_IHugeVocabulary(self):
+        vocabulary = DistributionSourcePackageVocabulary(
+            self.factory.makeDistribution())
+        self.assertProvides(vocabulary, IHugeVocabulary)
+
+    def test_init_IDistribution(self):
+        # When the context is adaptable to IDistribution, it also provides
+        # the distribution.
+        dsp = self.factory.makeDistributionSourcePackage(
+            sourcepackagename='foo')
+        vocabulary = DistributionSourcePackageVocabulary(dsp)
+        self.assertEqual(dsp, vocabulary.context)
+        self.assertEqual(dsp.distribution, vocabulary.distribution)
+        self.assertEqual(dsp, vocabulary.dsp)
+
+    def test_init_dsp_bugtask(self):
+        # A DSP bugtask can be the context.
+        dsp = self.factory.makeDistributionSourcePackage(
+            sourcepackagename='foo')
+        bugtask = self.factory.makeBugTask(target=dsp)
+        vocabulary = DistributionSourcePackageVocabulary(bugtask)
+        self.assertEqual(bugtask, vocabulary.context)
+        self.assertEqual(dsp.distribution, vocabulary.distribution)
+        self.assertEqual(dsp, vocabulary.dsp)
+
+    def test_init_dsp_question(self):
+        # A DSP bugtask can be the context.
+        dsp = self.factory.makeDistributionSourcePackage(
+            sourcepackagename='foo')
+        question = self.factory.makeQuestion(
+            target=dsp, owner=dsp.distribution.owner)
+        vocabulary = DistributionSourcePackageVocabulary(question)
+        self.assertEqual(question, vocabulary.context)
+        self.assertEqual(dsp.distribution, vocabulary.distribution)
+        self.assertEqual(dsp, vocabulary.dsp)
+
+    def test_init_no_distribution(self):
+        # The distribution is None if the context cannot be adapted to a
+        # distribution.
+        project = self.factory.makeProduct()
+        vocabulary = DistributionSourcePackageVocabulary(project)
+        self.assertEqual(project, vocabulary.context)
+        self.assertIsNone(vocabulary.distribution)
+        self.assertIsNone(vocabulary.dsp)
+
+    def test_setDistribution(self):
+        # Callsites can set the distribution after the vocabulary was
+        # instantiated.
+        new_distro = self.factory.makeDistribution(name='fnord')
+        vocabulary = DistributionSourcePackageVocabulary(None)
+        vocabulary.setDistribution(new_distro)
+        self.assertEqual(new_distro, vocabulary.distribution)
+
+    def test_getDistributionAndPackageName_distro_and_package(self):
+        # getDistributionAndPackageName returns a tuple of distribution and
+        # package name when the text contains both.
+        new_distro = self.factory.makeDistribution(name='fnord')
+        vocabulary = DistributionSourcePackageVocabulary(None)
+        distribution, package_name = vocabulary.getDistributionAndPackageName(
+            'fnord/pting')
+        self.assertEqual(new_distro, distribution)
+        self.assertEqual('pting', package_name)
+
+    def test_getDistributionAndPackageName_default_distro_and_package(self):
+        # getDistributionAndPackageName returns a tuple of the default
+        # distribution and package name when the text is just a package
+        # name.
+        default_distro = self.factory.makeDistribution(name='fnord')
+        vocabulary = DistributionSourcePackageVocabulary(default_distro)
+        distribution, package_name = vocabulary.getDistributionAndPackageName(
+            'pting')
+        self.assertEqual(default_distro, distribution)
+        self.assertEqual('pting', package_name)
+
+    def test_getDistributionAndPackageName_bad_distro_and_package(self):
+        # getDistributionAndPackageName returns a tuple of the default
+        # distribution and package name when the distro in the text cannot
+        # be matched to a real distro.
+        default_distro = self.factory.makeDistribution(name='fnord')
+        vocabulary = DistributionSourcePackageVocabulary(default_distro)
+        distribution, package_name = vocabulary.getDistributionAndPackageName(
+            'misspelled/pting')
+        self.assertEqual(default_distro, distribution)
+        self.assertEqual('pting', package_name)
+
+    def test_contains_true_without_init(self):
+        # The vocabulary contains official DSPs.
+        dsp = self.factory.makeDistributionSourcePackage(with_db=True)
+        vocabulary = DistributionSourcePackageVocabulary(None)
+        self.assertIn(dsp, vocabulary)
+
+    def test_contains_true_with_init(self):
+        # The vocabulary contains the DSP passed to init when it is not
+        # official.
+        dsp = self.factory.makeDistributionSourcePackage(with_db=False)
+        vocabulary = DistributionSourcePackageVocabulary(dsp)
+        self.assertIn(dsp, vocabulary)
+
+    def test_contains_false_without_init(self):
+        # The vocabulary does not contain DSPs that are not official that
+        # were not passed to init.
+        dsp = self.factory.makeDistributionSourcePackage(with_db=False)
+        vocabulary = DistributionSourcePackageVocabulary(None)
+        self.assertNotIn(dsp, vocabulary)
+
+    def test_toTerm_raises_error(self):
+        # An error is raised for DSP/SPNs that are not official and are not
+        # in the vocabulary.
+        dsp = self.factory.makeDistributionSourcePackage(
+            sourcepackagename='foo')
+        vocabulary = DistributionSourcePackageVocabulary(dsp.distribution)
+        self.assertRaises(LookupError, vocabulary.toTerm, dsp.name)
+
+    def test_toTerm_none_raises_error(self):
+        # An error is raised for an SPN that does not exist.
+        vocabulary = DistributionSourcePackageVocabulary(None)
+        self.assertRaises(LookupError, vocabulary.toTerm, 'nonexistent')
+
+    def test_toTerm_spn_and_default_distribution(self):
+        # The vocabulary's distribution is used when only a SPN is passed.
+        spph = self.factory.makeSourcePackagePublishingHistory()
+        dsp = spph.distroseries.distribution.getSourcePackage(
+            spph.sourcepackagerelease.sourcepackagename)
+        vocabulary = DistributionSourcePackageVocabulary(dsp.distribution)
+        term = vocabulary.toTerm(dsp.sourcepackagename)
+        expected_token = '%s/%s' % (dsp.distribution.name, dsp.name)
+        self.assertEqual(expected_token, term.token)
+        self.assertEqual(expected_token, term.title)
+        self.assertEqual(dsp, term.value)
+
+    def test_toTerm_spn_and_distribution(self):
+        # The distribution is used with the spn if it is passed.
+        spph = self.factory.makeSourcePackagePublishingHistory()
+        dsp = spph.distroseries.distribution.getSourcePackage(
+            spph.sourcepackagerelease.sourcepackagename)
+        vocabulary = DistributionSourcePackageVocabulary(None)
+        term = vocabulary.toTerm(dsp.sourcepackagename, dsp.distribution)
+        expected_token = '%s/%s' % (dsp.distribution.name, dsp.name)
+        self.assertEqual(expected_token, term.token)
+        self.assertEqual(expected_token, term.title)
+        self.assertEqual(dsp, term.value)
+
+    def test_toTerm_dsp(self):
+        # The DSP's distribution is used when a DSP is passed.
+        spph = self.factory.makeSourcePackagePublishingHistory()
+        dsp = spph.distroseries.distribution.getSourcePackage(
+            spph.sourcepackagerelease.sourcepackagename)
+        vocabulary = DistributionSourcePackageVocabulary(dsp)
+        term = vocabulary.toTerm(dsp)
+        expected_token = '%s/%s' % (dsp.distribution.name, dsp.name)
+        self.assertEqual(expected_token, term.token)
+        self.assertEqual(expected_token, term.title)
+        self.assertEqual(dsp, term.value)
+
+    def test_toTerm_dsp_and_binary_names(self):
+        # The DSP can be passed with a string on binary names that will be
+        # cached as a list in DSP.binary_names.
+        spph = self.factory.makeSourcePackagePublishingHistory()
+        dsp = spph.distroseries.distribution.getSourcePackage(
+            spph.sourcepackagerelease.sourcepackagename)
+        vocabulary = DistributionSourcePackageVocabulary(dsp)
+        term = vocabulary.toTerm((dsp, 'one two'))
+        expected_token = '%s/%s' % (dsp.distribution.name, dsp.name)
+        self.assertEqual(expected_token, term.token)
+        self.assertEqual(expected_token, term.title)
+        self.assertEqual(dsp, term.value)
+        self.assertEqual(['one', 'two'], term.value.binary_names)
+
+    def test_getTermByToken_error(self):
+        # An error is raised if the token does not match a official DSP.
+        dsp = self.factory.makeDistributionSourcePackage(
+            sourcepackagename='foo')
+        vocabulary = DistributionSourcePackageVocabulary(dsp.distribution)
+        token = '%s/%s' % (dsp.distribution.name, dsp.name)
+        self.assertRaises(LookupError, vocabulary.getTermByToken, token)
+
+    def test_getTermByToken_token(self):
+        # The term is returned if it matches an official DSP.
+        spph = self.factory.makeSourcePackagePublishingHistory()
+        dsp = spph.distroseries.distribution.getSourcePackage(
+            spph.sourcepackagerelease.sourcepackagename)
+        vocabulary = DistributionSourcePackageVocabulary(dsp.distribution)
+        token = '%s/%s' % (dsp.distribution.name, dsp.name)
+        term = vocabulary.getTermByToken(token)
+        self.assertEqual(dsp, term.value)
+
+    def test_searchForTerms_without_distribution(self):
+        # An empty result set is returned if the vocabulary has no
+        # distribution and the search does not provide distribution
+        # information.
+        spph = self.factory.makeSourcePackagePublishingHistory()
+        dsp = spph.distroseries.distribution.getSourcePackage(
+            spph.sourcepackagerelease.sourcepackagename)
+        vocabulary = DistributionSourcePackageVocabulary(dsp.name)
+        results = vocabulary.searchForTerms(dsp.name)
+        self.assertEqual(0, results.count())
+
+    def test_searchForTerms_None(self):
+        # Searching for nothing gets you that.
+        vocabulary = DistributionSourcePackageVocabulary(
+            self.factory.makeDistribution())
+        results = vocabulary.searchForTerms()
+        self.assertEqual(0, results.count())
+
+    def test_searchForTerms_exact_official_source_name(self):
+        # Exact source name matches are found.
+        self.factory.makeDSPCache('fnord', 'snarf')
+        vocabulary = DistributionSourcePackageVocabulary(None)
+        results = vocabulary.searchForTerms(query='fnord/snarf')
+        terms = list(results)
+        self.assertEqual(1, len(terms))
+        self.assertEqual('fnord/snarf', terms[0].token)
+
+    def test_searchForTerms_similar_official_source_name(self):
+        # Partial source name matches are found.
+        self.factory.makeDSPCache('fnord', 'pting-snarf-ack')
+        vocabulary = DistributionSourcePackageVocabulary(None)
+        results = vocabulary.searchForTerms(query='fnord/snarf')
+        terms = list(results)
+        self.assertEqual(1, len(terms))
+        self.assertEqual('fnord/pting-snarf-ack', terms[0].token)
+
+    def test_searchForTerms_exact_binary_name(self):
+        # Exact binary name matches are found.
+        self.factory.makeDSPCache(
+            'fnord', 'snarf', binary_names='pting-dev pting ack')
+        vocabulary = DistributionSourcePackageVocabulary(None)
+        results = vocabulary.searchForTerms(query='fnord/pting')
+        terms = list(results)
+        self.assertEqual(1, len(terms))
+        self.assertEqual('fnord/snarf', terms[0].token)
+
+    def test_searchForTerms_similar_binary_name(self):
+        # Partial binary name matches are found.
+        self.factory.makeDSPCache(
+            'fnord', 'snarf', binary_names='thrpp pting-dev ack')
+        vocabulary = DistributionSourcePackageVocabulary(None)
+        results = vocabulary.searchForTerms(query='fnord/pting')
+        terms = list(results)
+        self.assertEqual(1, len(terms))
+        self.assertEqual('fnord/snarf', terms[0].token)
+
+    def test_searchForTerms_exact_unofficial_source_name(self):
+        # Unofficial source packages are not found by search.
+        self.factory.makeDSPCache('fnord', 'snarf', official=False)
+        vocabulary = DistributionSourcePackageVocabulary(None)
+        results = vocabulary.searchForTerms(query='fnord/snarf')
+        terms = list(results)
+        self.assertEqual(0, len(terms))
+
+    def test_searchForTerms_similar_unofficial_binary_name(self):
+        # Unofficial binary packages are not found by search.
+        self.factory.makeDSPCache(
+            'fnord', 'snarf', official=False, binary_names='thrpp pting ack')
+        vocabulary = DistributionSourcePackageVocabulary(None)
+        results = vocabulary.searchForTerms(query='fnord/pting')
+        terms = list(results)
+        self.assertEqual(0, len(terms))
+
+    def test_searchForTerms_match_official_source_package_branch(self):
+        # An official package that is only a branch can be matched by source
+        # name if it was set as an official branch in another distro.
+        sp = self.factory.makeSourcePackage(sourcepackagename='snarf')
+        branch = self.factory.makePackageBranch(sourcepackage=sp)
+        with person_logged_in(sp.distribution.owner):
+            sp.setBranch(PackagePublishingPocket.RELEASE, branch, branch.owner)
+        distribution = self.factory.makeDistribution(name='pting')
+        self.factory.makeDistributionSourcePackage(
+            distribution=distribution, sourcepackagename='snarf',
+            with_db=True)
+        vocabulary = DistributionSourcePackageVocabulary(None)
+        results = vocabulary.searchForTerms(query='pting/snarf')
+        terms = list(results)
+        self.assertEqual(1, len(terms))
+        self.assertEqual('pting/snarf', terms[0].token)
+
+    def test_searchForTerms_ranking(self):
+        # Exact matches are ranked higher than similar matches.
+        self.factory.makeDSPCache('fnord', 'snarf')
+        self.factory.makeDSPCache('fnord', 'snarf-server', make_distro=False)
+        self.factory.makeDSPCache(
+            'fnord', 'pting-devel', binary_names='snarf', make_distro=False)
+        self.factory.makeDSPCache(
+            'fnord', 'pting-client', binary_names='snarf-common',
+            make_distro=False)
+        vocabulary = DistributionSourcePackageVocabulary(None)
+        results = vocabulary.searchForTerms(query='fnord/snarf')
+        terms = list(results)
+        self.assertEqual(4, len(terms))
+        self.assertEqual('fnord/snarf', terms[0].token)
+        self.assertEqual('fnord/pting-devel', terms[1].token)
+        self.assertEqual('fnord/snarf-server', terms[2].token)
+        self.assertEqual('fnord/pting-client', terms[3].token)
+
+    def test_searchForTerms_partner_archive(self):
+        # Packages in partner archives are searched.
+        self.factory.makeDSPCache('fnord', 'snarf', archive='partner')
+        vocabulary = DistributionSourcePackageVocabulary(None)
+        results = vocabulary.searchForTerms(query='fnord/snarf')
+        terms = list(results)
+        self.assertEqual(1, len(terms))
+        self.assertEqual('fnord/snarf', terms[0].token)
+
+    def test_searchForTerms_ppa_archive(self):
+        # Packages in PPAs are ignored.
+        self.factory.makeDSPCache(
+            'fnord', 'snarf', official=False, archive='ppa')
+        vocabulary = DistributionSourcePackageVocabulary(None)
+        results = vocabulary.searchForTerms(query='fnord/snarf')
+        self.assertEqual(0, results.count())

=== modified file 'lib/lp/registry/vocabularies.py'
--- lib/lp/registry/vocabularies.py	2016-06-10 22:19:03 +0000
+++ lib/lp/registry/vocabularies.py	2016-06-10 22:19:03 +0000
@@ -31,6 +31,7 @@
     'CommercialProjectsVocabulary',
     'DistributionOrProductOrProjectGroupVocabulary',
     'DistributionOrProductVocabulary',
+    'DistributionSourcePackageVocabulary',
     'DistributionVocabulary',
     'DistroSeriesDerivationVocabulary',
     'DistroSeriesDifferencesVocabulary',
@@ -63,7 +64,10 @@
 
 
 from operator import attrgetter
+import re
 
+from lazr.restful.interfaces import IReference
+from lazr.restful.utils import safe_hasattr
 from sqlobject import (
     AND,
     CONTAINSSTRING,
@@ -82,6 +86,7 @@
     With,
     )
 from storm.info import ClassAlias
+from storm.store import EmptyResultSet
 from zope.component import getUtility
 from zope.interface import implementer
 from zope.schema.interfaces import IVocabularyTokenized
@@ -95,15 +100,20 @@
     removeSecurityProxy,
     )
 
+from lp.answers.interfaces.question import IQuestion
 from lp.app.browser.tales import DateTimeFormatterAPI
 from lp.blueprints.interfaces.specification import ISpecification
+from lp.bugs.interfaces.bugtask import IBugTask
 from lp.code.interfaces.branch import IBranch
 from lp.registry.enums import (
     EXCLUSIVE_TEAM_POLICY,
     PersonVisibility,
     )
 from lp.registry.interfaces.accesspolicy import IAccessPolicySource
-from lp.registry.interfaces.distribution import IDistribution
+from lp.registry.interfaces.distribution import (
+    IDistribution,
+    IDistributionSet,
+    )
 from lp.registry.interfaces.distributionsourcepackage import (
     IDistributionSourcePackage,
     )
@@ -135,7 +145,11 @@
 from lp.registry.interfaces.productseries import IProductSeries
 from lp.registry.interfaces.projectgroup import IProjectGroup
 from lp.registry.interfaces.sourcepackage import ISourcePackage
+from lp.registry.interfaces.sourcepackagename import ISourcePackageName
 from lp.registry.model.distribution import Distribution
+from lp.registry.model.distributionsourcepackage import (
+    DistributionSourcePackageInDatabase,
+    )
 from lp.registry.model.distroseries import DistroSeries
 from lp.registry.model.distroseriesdifference import DistroSeriesDifference
 from lp.registry.model.distroseriesparent import DistroSeriesParent
@@ -167,7 +181,10 @@
     SQLBase,
     sqlvalues,
     )
-from lp.services.database.stormexpr import Case
+from lp.services.database.stormexpr import (
+    Case,
+    RegexpMatch,
+    )
 from lp.services.helpers import (
     ensure_unicode,
     shortlist,
@@ -2005,3 +2022,174 @@
         # Package names are always lowercase.
         return super(SourcePackageNameVocabulary, self).getTermByToken(
             token.lower())
+
+
+@implementer(IHugeVocabulary)
+class DistributionSourcePackageVocabulary(FilteredVocabularyBase):
+
+    displayname = 'Select a package'
+    step_title = 'Search by name or distro/name'
+
+    def __init__(self, context):
+        self.context = context
+        if IReference.providedBy(context):
+            target = context.context.target
+        elif IBugTask.providedBy(context) or IQuestion.providedBy(context):
+            target = context.target
+        else:
+            target = context
+        try:
+            self.distribution = IDistribution(target)
+        except TypeError:
+            self.distribution = None
+        if IDistributionSourcePackage.providedBy(target):
+            self.dsp = target
+        else:
+            self.dsp = None
+
+    def __contains__(self, spn_or_dsp):
+        if spn_or_dsp == self.dsp:
+            # Historic values are always valid. The DSP used to
+            # initialize the vocabulary is always included.
+            return True
+        try:
+            self.toTerm(spn_or_dsp)
+            return True
+        except LookupError:
+            return False
+
+    def __iter__(self):
+        pass
+
+    def __len__(self):
+        pass
+
+    def setDistribution(self, distribution):
+        """Set the distribution after the vocabulary was instantiated."""
+        self.distribution = distribution
+
+    def getDistributionAndPackageName(self, text):
+        "Return the distribution and package name from the parsed text."
+        # Match the toTerm() format, but also use it to select a distribution.
+        distribution = None
+        if '/' in text:
+            distro_name, text = text.split('/', 1)
+            distribution = getUtility(IDistributionSet).getByName(distro_name)
+        if distribution is None:
+            distribution = self.distribution
+        return distribution, text
+
+    def toTerm(self, spn_or_dsp, distribution=None):
+        """See `IVocabulary`."""
+        dsp = None
+        binary_names = None
+        if isinstance(spn_or_dsp, tuple):
+            # The DSP in DB was passed with its binary_names.
+            spn_or_dsp, binary_names = spn_or_dsp
+            if binary_names is not None:
+                binary_names = binary_names.split()
+        if IDistributionSourcePackage.providedBy(spn_or_dsp):
+            dsp = spn_or_dsp
+            distribution = spn_or_dsp.distribution
+        elif (not ISourcePackageName.providedBy(spn_or_dsp) and
+            safe_hasattr(spn_or_dsp, 'distribution')
+            and safe_hasattr(spn_or_dsp, 'sourcepackagename')):
+            # We use the hasattr checks rather than adaptation because the
+            # DistributionSourcePackageInDatabase object is a little bit
+            # broken and does not provide any interface.
+            distribution = spn_or_dsp.distribution
+            dsp = distribution.getSourcePackage(spn_or_dsp.sourcepackagename)
+        else:
+            distribution = distribution or self.distribution
+            if distribution is not None and spn_or_dsp is not None:
+                dsp = distribution.getSourcePackage(spn_or_dsp)
+        if dsp is not None and (dsp == self.dsp or dsp.is_official):
+            if binary_names:
+                # Search already did the hard work of looking up binary names.
+                cache = get_property_cache(dsp)
+                cache.binary_names = binary_names
+            token = '%s/%s' % (dsp.distribution.name, dsp.name)
+            return SimpleTerm(dsp, token, token)
+        raise LookupError(distribution, spn_or_dsp)
+
+    def getTerm(self, spn_or_dsp):
+        """See `IBaseVocabulary`."""
+        return self.toTerm(spn_or_dsp)
+
+    def getTermByToken(self, token):
+        """See `IVocabularyTokenized`."""
+        distribution, package_name = self.getDistributionAndPackageName(token)
+        return self.toTerm(package_name, distribution)
+
+    def searchForTerms(self, query=None, vocab_filter=None):
+        """See `IHugeVocabulary`."""
+        if not query:
+            return EmptyResultSet()
+        distribution, query = self.getDistributionAndPackageName(query)
+        if distribution is None:
+            # This could failover to ubuntu, but that is non-obvious. The
+            # Python widget must set the default distribution and the JS
+            # widget must encourage the <distro>/<package> search format.
+            return EmptyResultSet()
+
+        query = unicode(query)
+        query_re = re.escape(query)
+        store = IStore(DistributionSourcePackageInDatabase)
+        # Construct the searchable text that could live in the DSP table.
+        # Limit the results to ensure the user could see all the batches.
+        # Rank only what is returned: exact source name, exact binary
+        # name, partial source name, and lastly partial binary name.
+        searchable_dspc_cte = With("SearchableDSPC", Select(
+            (DistributionSourcePackageCache.sourcepackagenameID,
+             DistributionSourcePackageCache.binpkgnames),
+            where=And(
+                Or(
+                    DistributionSourcePackageCache.name.contains_string(query),
+                    DistributionSourcePackageCache.binpkgnames.contains_string(
+                        query),
+                    ),
+                Or(
+                    DistributionSourcePackageCache.archiveID.is_in(
+                        distribution.all_distro_archive_ids),
+                    DistributionSourcePackageCache.archive == None),
+                Or(
+                    DistributionSourcePackageCache.name == query,
+                    DistributionSourcePackageCache.distribution ==
+                        distribution),
+                ),
+            tables=DistributionSourcePackageCache))
+        rank = Case(
+            when=(
+                # name == query
+                (SourcePackageName.name == query, 100),
+                (RegexpMatch(SQL("SearchableDSPC.binpkgnames"),
+                             r'(^| )%s( |$)' % query_re), 90),
+                # name.startswith(query + "-")
+                (SourcePackageName.name.startswith(query + "-"), 80),
+                (RegexpMatch(SQL("SearchableDSPC.binpkgnames"),
+                             r'(^| )%s-' % query_re), 70),
+                # name.startswith(query)
+                (SourcePackageName.name.startswith(query), 60),
+                (RegexpMatch(SQL("SearchableDSPC.binpkgnames"),
+                             r'(^| )%s' % query_re), 50),
+                # name.contains_string("-" + query)
+                (SourcePackageName.name.contains_string("-" + query), 40),
+                (RegexpMatch(SQL("SearchableDSPC.binpkgnames"),
+                             r'-%s' % query_re), 30),
+                ),
+            else_=1)
+        # It might be possible to return the source name and binary names to
+        # reduce the work of the picker adapter.
+        results = store.with_(searchable_dspc_cte).using(
+            DistributionSourcePackageInDatabase, SourcePackageName,
+            "SearchableDSPC").find(
+                (DistributionSourcePackageInDatabase,
+                 SQL("SearchableDSPC.binpkgnames")),
+                DistributionSourcePackageInDatabase.distribution ==
+                    distribution,
+                DistributionSourcePackageInDatabase.sourcepackagename_id ==
+                    SourcePackageName.id,
+                SourcePackageName.id ==
+                    SQL("SearchableDSPC.sourcepackagename"))
+        results.order_by(Desc(rank), SourcePackageName.name)
+        return CountableIterator(results.count(), results, self.toTerm)

=== modified file 'lib/lp/registry/vocabularies.zcml'
--- lib/lp/registry/vocabularies.zcml	2016-02-04 00:59:43 +0000
+++ lib/lp/registry/vocabularies.zcml	2016-06-10 22:19:03 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -520,6 +520,21 @@
     <allow interface="lp.services.webapp.vocabulary.ICountableIterator"/>
   </class>
 
+  <securedutility
+    name="DistributionSourcePackage"
+    component="lp.registry.vocabularies.DistributionSourcePackageVocabulary"
+    provides="zope.schema.interfaces.IVocabularyFactory"
+    >
+    <allow interface="zope.schema.interfaces.IVocabularyFactory"/>
+  </securedutility>
+
+  <class class="lp.registry.vocabularies.DistributionSourcePackageVocabulary">
+    <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary"/>
+    <require
+        permission="zope.Public"
+        attributes="setDistribution"/>
+  </class>
+
     <class
         class="lp.services.webapp.vocabulary.VocabularyFilterAll">
         <require

=== modified file 'lib/lp/services/database/stormexpr.py'
--- lib/lp/services/database/stormexpr.py	2016-06-10 22:19:03 +0000
+++ lib/lp/services/database/stormexpr.py	2016-06-10 22:19:03 +0000
@@ -20,6 +20,7 @@
     'NullCount',
     'NullsFirst',
     'NullsLast',
+    'RegexpMatch',
     'rank_by_fti',
     'TryAdvisoryLock',
     'Unnest',
@@ -40,6 +41,7 @@
     EXPR,
     Expr,
     In,
+    Like,
     NamedFunc,
     Or,
     SQL,
@@ -258,6 +260,14 @@
     return "".join(tokens)
 
 
+class RegexpMatch(BinaryOper):
+    __slots__ = ()
+    oper = " ~ "
+
+
+compile.set_precedence(compile.get_precedence(Like), RegexpMatch)
+
+
 def get_where_for_reference(reference, other):
     """Generate a column comparison expression for a reference property.
 

=== modified file 'lib/lp/services/features/flags.py'
--- lib/lp/services/features/flags.py	2016-01-25 12:21:31 +0000
+++ lib/lp/services/features/flags.py	2016-06-10 22:19:03 +0000
@@ -148,6 +148,13 @@
      '',
      '',
      ''),
+    ('disclosure.dsp_picker.enabled',
+     'boolean',
+     'Enables the use of the new DistributionSourcePackage vocabulary for '
+     'the source and binary package name pickers.',
+     '',
+     '',
+     ''),
     ('bugs.autoconfirm.enabled_distribution_names',
      'space delimited',
      ('Enables auto-confirming bugtasks for distributions (and their '

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2016-06-02 04:54:37 +0000
+++ lib/lp/testing/factory.py	2016-06-10 22:19:03 +0000
@@ -181,7 +181,6 @@
     )
 from lp.registry.interfaces.distroseriesparent import IDistroSeriesParentSet
 from lp.registry.interfaces.gpg import IGPGKeySet
-from lp.registry.model.gpgkey import GPGServiceKey
 from lp.registry.interfaces.mailinglist import (
     IMailingListSet,
     MailingListStatus,
@@ -224,6 +223,7 @@
     SSHKeyType,
     )
 from lp.registry.model.commercialsubscription import CommercialSubscription
+from lp.registry.model.gpgkey import GPGServiceKey
 from lp.registry.model.karma import KarmaTotalCache
 from lp.registry.model.milestone import Milestone
 from lp.registry.model.suitesourcepackage import SuiteSourcePackage
@@ -4198,13 +4198,15 @@
                 sourcepackagename=dsp.sourcepackagename,
                 archive=archive)
         with dbuser('statistician'):
-            cache = IStore(DistributionSourcePackageCache).find(
-                DistributionSourcePackageCache,
-                DistributionSourcePackageCache.distribution == distribution,
-                DistributionSourcePackageCache.archive == archive,
-                DistributionSourcePackageCache.sourcepackagename ==
-                    dsp.sourcepackagename).one()
-            cache.binpkgnames = binary_names
+            if official:
+                cache = IStore(DistributionSourcePackageCache).find(
+                    DistributionSourcePackageCache,
+                    DistributionSourcePackageCache.distribution ==
+                        distribution,
+                    DistributionSourcePackageCache.archive == archive,
+                    DistributionSourcePackageCache.sourcepackagename ==
+                        dsp.sourcepackagename).one()
+                cache.binpkgnames = binary_names
         return distribution, dsp
 
     def makeEmailMessage(self, body=None, sender=None, to=None,


Follow ups