← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~mwhudson/launchpad/cross-product-spec-links-bug-3552 into lp:launchpad/devel

 

Michael Hudson has proposed merging lp:~mwhudson/launchpad/cross-product-spec-links-bug-3552 into lp:launchpad/devel.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  #3552 No way to cross-link specs across products
  https://bugs.launchpad.net/bugs/3552


Hi,

This branch changes the way the vocabulary for specification dependency candidates works.  In particular, it allows you to create cross-pillar dependencies by entering the URL of a specification.

I had a pre-imp chat with Tim.

Cheers,
mwh
-- 
https://code.launchpad.net/~mwhudson/launchpad/cross-product-spec-links-bug-3552/+merge/33866
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~mwhudson/launchpad/cross-product-spec-links-bug-3552 into lp:launchpad/devel.
=== modified file 'lib/lp/blueprints/browser/specificationdependency.py'
--- lib/lp/blueprints/browser/specificationdependency.py	2010-08-26 03:02:29 +0000
+++ lib/lp/blueprints/browser/specificationdependency.py	2010-08-27 04:42:08 +0000
@@ -37,7 +37,9 @@
             "If another blueprint needs to be fully implemented "
             "before this feature can be started, then specify that "
             "dependency here so Launchpad knows about it and can "
-            "give you an accurate project plan."))
+            "give you an accurate project plan.  You can enter the "
+            "name of a blueprint that has the same target, or the "
+            "URL of any blueprint."))
 
 
 class SpecificationDependencyAddView(LaunchpadFormView):

=== added file 'lib/lp/blueprints/browser/tests/test_specificationdependency.py'
--- lib/lp/blueprints/browser/tests/test_specificationdependency.py	1970-01-01 00:00:00 +0000
+++ lib/lp/blueprints/browser/tests/test_specificationdependency.py	2010-08-27 04:42:08 +0000
@@ -0,0 +1,30 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""XXX: Module docstring goes here."""
+
+__metaclass__ = type
+
+import unittest
+
+from canonical.launchpad.webapp import canonical_url
+from canonical.testing import DatabaseFunctionalLayer
+from lp.testing import BrowserTestCase
+
+
+class TestAddDependency(BrowserTestCase):
+    layer = DatabaseFunctionalLayer
+
+    def test_add_dependency_by_url(self):
+        # It is possible to use the URL of a specification in the "Depends On"
+        # field of the form to add a dependency to a spec.
+        spec = self.factory.makeSpecification(owner=self.user)
+        dependency = self.factory.makeSpecification()
+        browser = self.getViewBrowser(spec, '+linkdependency')
+        browser.getControl('Depends On').value = canonical_url(dependency)
+        browser.getControl('Continue').click()
+        self.assertIn(dependency, spec.dependencies)
+
+
+def test_suite():
+    return unittest.TestLoader().loadTestsFromName(__name__)

=== modified file 'lib/lp/blueprints/vocabularies/specificationdependency.py'
--- lib/lp/blueprints/vocabularies/specificationdependency.py	2010-08-26 03:30:31 +0000
+++ lib/lp/blueprints/vocabularies/specificationdependency.py	2010-08-27 04:42:08 +0000
@@ -6,11 +6,16 @@
 __metaclass__ = type
 __all__ = ['SpecificationDepCandidatesVocabulary']
 
+from zope.component import getUtility
 from zope.interface import implements
 from zope.schema.vocabulary import SimpleTerm
 
 from canonical.database.sqlbase import quote_like
 from canonical.launchpad.helpers import shortlist
+from canonical.launchpad.webapp import (
+    canonical_url,
+    urlparse,
+    )
 from canonical.launchpad.webapp.vocabulary import (
     CountableIterator,
     IHugeVocabulary,
@@ -19,15 +24,26 @@
 
 from lp.blueprints.interfaces.specification import SpecificationFilter
 from lp.blueprints.model.specification import Specification
-
+from lp.registry.interfaces.pillar import IPillarNameSet
 
 class SpecificationDepCandidatesVocabulary(SQLObjectVocabularyBase):
     """Specifications that could be dependencies of this spec.
 
-    This includes only those specs that are not blocked by this spec
-    (directly or indirectly), unless they are already dependencies.
-
-    The current spec is not included.
+    This includes only those specs that are not blocked by this spec (directly
+    or indirectly), unless they are already dependencies.
+
+    This vocabulary has a bit of a split personality.
+
+    Tokens are *either*:
+
+     - the name of a spec, in which case it must be a spec on the same target
+       as the context, or
+     - the full URL of the spec, in which case it can be any spec at all.
+
+    For the purposes of enumeration and searching we only consider the first
+    sort of spec for now.  The URL form of token only matches precisely,
+    searching only looks for specs on the current target if the search term is
+    not a URL.
     """
 
     implements(IHugeVocabulary)
@@ -36,8 +52,8 @@
     _orderBy = 'name'
     displayname = 'Select a blueprint'
 
-    def _filter_specs(self, specs):
-        """Filter `specs` to remove invalid candidates.
+    def _is_valid_candidate(self, spec, check_target=False):
+        """Is `spec` a valid candidate spec for self.context?
 
         Invalid candidates are:
 
@@ -47,27 +63,64 @@
 
         Preventing the last category prevents loops in the dependency graph.
         """
+        if check_target and spec.target != self.context.target:
+            return False
+        return spec != self.context and spec not in self.context.all_blocked
+
+    def _filter_specs(self, specs, check_target=False):
+        """Filter `specs` to remove invalid candidates.
+
+        See `_is_valid_candidate` for what an invalid candidate is.
+        """
         # XXX intellectronica 2007-07-05: is 100 a reasonable count before
         # starting to warn?
-        speclist = shortlist(specs, 100)
-        return [spec for spec in speclist
-                if (spec != self.context and
-                    spec.target == self.context.target
-                    and spec not in self.context.all_blocked)]
+        return [spec for spec in shortlist(specs, 100)
+                if self._is_valid_candidate(spec, check_target)]
 
     def toTerm(self, obj):
-        return SimpleTerm(obj, obj.name, obj.title)
+        if obj.target == self.context.target:
+            token = obj.name
+        else:
+            token = canonical_url(obj)
+        return SimpleTerm(obj, token, obj.title)
+
+    def _spec_from_url(self, url):
+        """If `url` is the URL of a specification, return it.
+
+        This implementation is a little fuzzy and will return specs for URLs
+        that, for example, don't have the host name right.  This seems
+        unlikely to cause confusion in practice, and being too anal probably
+        would be confusing (e.g. not accepting edge URLs on lpnet).
+        """
+        scheme, netloc, path, params, args, fragment = urlparse(url)
+        if not scheme or not netloc:
+            # Not enough like a URL
+            return None
+        path_segments = path.strip('/').split('/')
+        if len(path_segments) != 3:
+            # Can't be a spec url
+            return None
+        pillar_name, plus_spec, spec_name = path_segments
+        if plus_spec != '+spec':
+            # Can't be a spec url
+            return None
+        pillar = getUtility(IPillarNameSet).getByName(
+            pillar_name, ignore_inactive=True)
+        if pillar is None:
+            return None
+        return pillar.getSpecification(spec_name)
 
     def getTermByToken(self, token):
         """See `zope.schema.interfaces.IVocabularyTokenized`.
 
-        The tokens for specifications are just the name of the spec.
+        The tokens for specifications are either the name of a spec on the
+        same target or a URL for a spec.
         """
-        spec = self.context.target.getSpecification(token)
-        if spec is not None:
-            filtered = self._filter_specs([spec])
-            if len(filtered) > 0:
-                return self.toTerm(filtered[0])
+        spec = self._spec_from_url(token)
+        if spec is None:
+            spec = self.context.target.getSpecification(token)
+        if spec and self._is_valid_candidate(spec):
+            return self.toTerm(spec)
         raise LookupError(token)
 
     def search(self, query):
@@ -79,6 +132,9 @@
         """
         if not query:
             return CountableIterator(0, [])
+        spec = self._spec_from_url(query)
+        if spec is not None and self._is_valid_candidate(spec):
+            return CountableIterator(1, [spec])
         quoted_query = quote_like(query)
         sql_query = ("""
             (Specification.name LIKE %s OR
@@ -87,18 +143,18 @@
             """
             % (quoted_query, quoted_query, quoted_query))
         all_specs = Specification.select(sql_query, orderBy=self._orderBy)
-        candidate_specs = self._filter_specs(all_specs)
+        candidate_specs = self._filter_specs(all_specs, check_target=True)
         return CountableIterator(len(candidate_specs), candidate_specs)
 
     @property
     def _all_specs(self):
         return self.context.target.specifications(
-            filter=[SpecificationFilter.ALL],
-            prejoin_people=False)
+            filter=[SpecificationFilter.ALL], prejoin_people=False)
 
     def __iter__(self):
-        return (self.toTerm(spec)
-                for spec in self._filter_specs(self._all_specs))
+        return (
+            self.toTerm(spec) for spec in self._filter_specs(self._all_specs))
 
     def __contains__(self, obj):
-        return obj in self._all_specs and len(self._filter_specs([obj])) > 0
+        return self._is_valid_candidate(obj)
+

=== modified file 'lib/lp/blueprints/vocabularies/tests/specificationdepcandidates.txt'
--- lib/lp/blueprints/vocabularies/tests/specificationdepcandidates.txt	2010-08-25 05:39:32 +0000
+++ lib/lp/blueprints/vocabularies/tests/specificationdepcandidates.txt	2010-08-27 04:42:08 +0000
@@ -22,26 +22,24 @@
     >>> sorted([spec.name for spec in specced_product.specifications()])
     [u'spec-a', u'spec-b', u'spec-c']
 
-The dependency candidates for spec_a are all blueprints for
-specced_product except for spec_a itself.
+
+Iterating over the vocabulary gives all blueprints for specced_product
+except for spec_a itself.
 
     >>> vocab = vocabulary_registry.get(
     ...     spec_a, "SpecificationDepCandidates")
     >>> sorted([term.value.name for term in vocab])
     [u'spec-b', u'spec-c']
 
-Dependency candidate come only from the same product of the blueprint
-they depend on.
+Blueprints for other targets are considered to be 'in' the vocabulary
+though.
 
     >>> unrelated_spec = factory.makeSpecification(
     ...     product=factory.makeProduct())
     >>> vocab = vocabulary_registry.get(
     ...     spec_a, "SpecificationDepCandidates")
     >>> unrelated_spec in vocab
-    False
-    >>> [term.value.product for term in vocab
-    ...  if term.value.product != specced_product]
-    []
+    True
 
 We mark spec_b as a dependency of spec_a and spec_c as a dependency of
 spec_b.

=== modified file 'lib/lp/blueprints/vocabularies/tests/test_specificationdependency.py'
--- lib/lp/blueprints/vocabularies/tests/test_specificationdependency.py	2010-08-26 22:07:41 +0000
+++ lib/lp/blueprints/vocabularies/tests/test_specificationdependency.py	2010-08-27 04:42:08 +0000
@@ -10,6 +10,7 @@
 
 from zope.schema.vocabulary import getVocabularyRegistry
 
+from canonical.launchpad.webapp import canonical_url
 from canonical.testing import DatabaseFunctionalLayer
 
 from lp.testing import TestCaseWithFactory
@@ -24,7 +25,7 @@
         return getVocabularyRegistry().get(
             spec, name='SpecificationDepCandidates')
 
-    def test_getTermByToken_product(self):
+    def test_getTermByToken_by_name_for_product(self):
         # Calling getTermByToken for a dependency vocab for a spec from a
         # product with the name of an acceptable candidate spec returns the
         # term for the candidate
@@ -35,7 +36,7 @@
         self.assertEqual(
             candidate, vocab.getTermByToken(candidate.name).value)
 
-    def test_getTermByToken_distro(self):
+    def test_getTermByToken_by_name_for_distro(self):
         # Calling getTermByToken for a dependency vocab for a spec from a
         # distribution with the name of an acceptable candidate spec returns
         # the term for the candidate
@@ -46,7 +47,55 @@
         self.assertEqual(
             candidate, vocab.getTermByToken(candidate.name).value)
 
-    def test_getTermByToken_disallows_blocked(self):
+    def test_getTermByToken_by_url_for_product(self):
+        # Calling getTermByToken with the full URL for a spec on a product
+        # returns that spec, irrespective of the context's target.
+        spec = self.factory.makeSpecification()
+        candidate = self.factory.makeSpecification(
+            product=self.factory.makeProduct())
+        vocab = self.getVocabularyForSpec(spec)
+        self.assertEqual(
+            candidate, vocab.getTermByToken(canonical_url(candidate)).value)
+
+    def test_getTermByToken_by_url_for_distro(self):
+        # Calling getTermByToken with the full URL for a spec on a
+        # distribution returns that spec, irrespective of the context's
+        # target.
+        spec = self.factory.makeSpecification()
+        candidate = self.factory.makeSpecification(
+            distribution=self.factory.makeDistribution())
+        vocab = self.getVocabularyForSpec(spec)
+        self.assertEqual(
+            candidate, vocab.getTermByToken(canonical_url(candidate)).value)
+
+    def test_getTermByToken_lookup_error_on_nonsense(self):
+        # getTermByToken with the a string that does not name a spec raises
+        # LookupError.
+        product = self.factory.makeProduct()
+        spec = self.factory.makeSpecification(product=product)
+        vocab = self.getVocabularyForSpec(spec)
+        self.assertRaises(
+            LookupError, vocab.getTermByToken, self.factory.getUniqueString())
+
+    def test_getTermByToken_lookup_error_on_url_with_invalid_pillar(self):
+        # getTermByToken with the a string that looks like a blueprint URL but
+        # has an invalid pillar name raises LookupError.
+        spec = self.factory.makeSpecification()
+        url = canonical_url(spec).replace(
+            spec.target.name, self.factory.getUniqueString())
+        vocab = self.getVocabularyForSpec(spec)
+        self.assertRaises(LookupError, vocab.getTermByToken, url)
+
+    def test_getTermByToken_lookup_error_on_url_with_invalid_spec_name(self):
+        # getTermByToken with the a string that looks like a blueprint URL but
+        # has an invalid spec name raises LookupError.
+        spec = self.factory.makeSpecification()
+        url = canonical_url(spec).replace(
+            spec.name, self.factory.getUniqueString())
+        vocab = self.getVocabularyForSpec(spec)
+        self.assertRaises(LookupError, vocab.getTermByToken, url)
+
+    def test_getTermByToken_by_name_disallows_blocked(self):
         # getTermByToken with the name of an candidate spec that is blocked by
         # the vocab's context raises LookupError.
         product = self.factory.makeProduct()
@@ -56,17 +105,74 @@
         vocab = self.getVocabularyForSpec(spec)
         self.assertRaises(LookupError, vocab.getTermByToken, candidate.name)
 
-    def test_getTermByToken_disallows_context(self):
+    def test_getTermByToken_by_url_disallows_blocked(self):
+        # getTermByToken with the URL of an candidate spec that is blocked by
+        # the vocab's context raises LookupError.
+        spec = self.factory.makeSpecification()
+        candidate = self.factory.makeSpecification()
+        candidate.createDependency(spec)
+        vocab = self.getVocabularyForSpec(spec)
+        self.assertRaises(
+            LookupError, vocab.getTermByToken, canonical_url(candidate))
+
+    def test_getTermByToken_by_name_disallows_context(self):
         # getTermByToken with the name of the vocab's context raises
         # LookupError.
         spec = self.factory.makeSpecification()
         vocab = self.getVocabularyForSpec(spec)
         self.assertRaises(LookupError, vocab.getTermByToken, spec.name)
 
-    def test_getTermByToken_disallows_spec_for_other_target(self):
+    def test_getTermByToken_by_url_disallows_context(self):
+        # getTermByToken with the URL of the vocab's context raises
+        # LookupError.
+        spec = self.factory.makeSpecification()
+        vocab = self.getVocabularyForSpec(spec)
+        self.assertRaises(
+            LookupError, vocab.getTermByToken, canonical_url(spec))
+
+    def test_getTermByToken_by_name_disallows_spec_for_other_target(self):
         # getTermByToken with the name of a spec with a different target
         # raises LookupError.
         spec = self.factory.makeSpecification()
         candidate = self.factory.makeSpecification()
         vocab = self.getVocabularyForSpec(spec)
         self.assertRaises(LookupError, vocab.getTermByToken, candidate.name)
+
+    def test_searchForTerms_by_url(self):
+        # Calling searchForTerms with the URL of a valid candidate spec
+        # returns just that spec.
+        spec = self.factory.makeSpecification()
+        candidate = self.factory.makeSpecification()
+        vocab = self.getVocabularyForSpec(spec)
+        results = vocab.searchForTerms(canonical_url(candidate))
+        self.assertEqual(1, len(results))
+        self.assertEqual(candidate, list(results)[0].value)
+
+    def test_searchForTerms_by_url_rejects_invalid(self):
+        # Calling searchForTerms with the URL of a invalid candidate spec
+        # returns an empty iterator.
+        spec = self.factory.makeSpecification()
+        candidate = self.factory.makeSpecification()
+        candidate.createDependency(spec)
+        vocab = self.getVocabularyForSpec(spec)
+        results = vocab.searchForTerms(canonical_url(candidate))
+        self.assertEqual(0, len(results))
+
+    def test_token_for_same_target_dep_is_name(self):
+        # The 'token' part of the term for a dependency candidate that has the
+        # same target is just the name of the candidate.
+        product = self.factory.makeProduct()
+        spec = self.factory.makeSpecification(product=product)
+        candidate = self.factory.makeSpecification(product=product)
+        vocab = self.getVocabularyForSpec(spec)
+        term = vocab.getTermByToken(candidate.name)
+        self.assertEqual(term.token, candidate.name)
+
+    def test_token_for_different_target_dep_is_url(self):
+        # The 'token' part of the term for a dependency candidate that has a
+        # different target is the canonical url of the candidate.
+        spec = self.factory.makeSpecification()
+        candidate = self.factory.makeSpecification()
+        vocab = self.getVocabularyForSpec(spec)
+        term = vocab.getTermByToken(canonical_url(candidate))
+        self.assertEqual(term.token, canonical_url(candidate))

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2010-08-26 22:44:30 +0000
+++ lib/lp/testing/factory.py	2010-08-27 04:42:08 +0000
@@ -1496,7 +1496,7 @@
         return mail
 
     def makeSpecification(self, product=None, title=None, distribution=None,
-                          name=None, summary=None,
+                          name=None, summary=None, owner=None,
                           status=SpecificationDefinitionStatus.NEW):
         """Create and return a new, arbitrary Blueprint.
 
@@ -1511,13 +1511,15 @@
             summary = self.getUniqueString('summary')
         if title is None:
             title = self.getUniqueString('title')
+        if owner is None:
+            owner = self.makePerson()
         return getUtility(ISpecificationSet).new(
             name=name,
             title=title,
             specurl=None,
             summary=summary,
             definition_status=status,
-            owner=self.makePerson(),
+            owner=owner,
             product=product,
             distribution=distribution)