← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/snappy-series-can-guess-distro-series into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snappy-series-can-guess-distro-series into lp:launchpad with lp:~cjwatson/launchpad/snap-without-distro-series as a prerequisite.

Commit message:
Support SnappySeries that can guess the distro series from snapcraft.yaml.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1812985 in Launchpad itself: "Support snapcraft base snaps"
  https://bugs.launchpad.net/launchpad/+bug/1812985

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snappy-series-can-guess-distro-series/+merge/362731

This lets us offer better defaults when creating or editing a Snap.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snappy-series-can-guess-distro-series into lp:launchpad.
=== modified file 'lib/lp/snappy/browser/snap.py'
--- lib/lp/snappy/browser/snap.py	2019-02-05 13:05:32 +0000
+++ lib/lp/snappy/browser/snap.py	2019-02-05 13:05:32 +0000
@@ -490,7 +490,9 @@
                 store_name = snapcraft_data.get('name')
 
         store_series = getUtility(ISnappySeriesSet).getAll().first()
-        if store_series.preferred_distro_series is not None:
+        if store_series.can_guess_distro_series:
+            distro_series = None
+        elif store_series.preferred_distro_series is not None:
             distro_series = store_series.preferred_distro_series
         else:
             distro_series = store_series.usable_distro_series.first()

=== modified file 'lib/lp/snappy/browser/tests/test_snap.py'
--- lib/lp/snappy/browser/tests/test_snap.py	2019-02-05 13:05:32 +0000
+++ lib/lp/snappy/browser/tests/test_snap.py	2019-02-05 13:05:32 +0000
@@ -32,6 +32,7 @@
     MatchesListwise,
     MatchesSetwise,
     MatchesStructure,
+    Not,
     )
 import transaction
 from zope.component import getUtility
@@ -226,6 +227,24 @@
             MatchesStructure.byEquality(
                 snappy_series=newest, distro_series=lts))
 
+    def test_initial_store_distro_series_can_guess_distro_series(self):
+        # If the latest snappy series supports guessing the distro series
+        # from snapcraft.yaml, then we default to that.
+        self.useFixture(BranchHostingFixture(blob=b""))
+        lts = self.factory.makeUbuntuDistroSeries(
+            version="16.04", status=SeriesStatus.CURRENT)
+        with admin_logged_in():
+            self.factory.makeSnappySeries(usable_distro_series=[lts])
+            newest = self.factory.makeSnappySeries(
+                preferred_distro_series=lts, can_guess_distro_series=True)
+        branch = self.factory.makeAnyBranch()
+        with person_logged_in(self.person):
+            view = create_initialized_view(branch, "+new-snap")
+        self.assertThat(
+            view.initial_values["store_distro_series"],
+            MatchesStructure(
+                snappy_series=Equals(newest), distro_series=Is(None)))
+
     def test_create_new_snap_not_logged_in(self):
         branch = self.factory.makeAnyBranch()
         self.assertRaises(
@@ -532,6 +551,26 @@
         self.assertContentEqual(
             ["386", "amd64"], [proc.name for proc in snap.processors])
 
+    def test_create_new_snap_guess_distro_series(self):
+        self.useFixture(BranchHostingFixture(blob=b""))
+        with admin_logged_in():
+            self.snappyseries.can_guess_distro_series = True
+        branch = self.factory.makeAnyBranch()
+        browser = self.getViewBrowser(
+            branch, view_name="+new-snap", user=self.person)
+        browser.getControl(name="field.name").value = "snap-name"
+        self.assertEqual(
+            [self.snappyseries.name],
+            browser.getControl(name="field.store_distro_series").value)
+        self.assertEqual(
+            self.snappyseries.name,
+            browser.getControl(name="field.store_distro_series").options[0])
+        browser.getControl("Create snap package").click()
+
+        content = find_main_content(browser.contents)
+        self.assertEqual("snap-name", extract_text(content.h1))
+        self.assertIsNone(find_tag_by_id(content, "distro_series"))
+
     def test_initial_name_extraction_bzr_success(self):
         self.useFixture(BranchHostingFixture(
             file_list={"snapcraft.yaml": "file-id"}, blob=b"name: test-snap"))
@@ -1404,6 +1443,31 @@
                 store_upload_tag,
                 soupmatchers.Tag("store name", "span", text=snap.store_name))))
 
+    def test_index_store_upload_no_distro_series(self):
+        # If the snap package is to be automatically uploaded to the store
+        # and is configured to guess an appropriate distro series from
+        # snapcraft.yaml, the index page shows details of this.
+        with admin_logged_in():
+            snappyseries = self.factory.makeSnappySeries(
+                usable_distro_series=[self.distroseries],
+                can_guess_distro_series=True)
+        snap = self.makeSnap(
+            distroseries=None, store_upload=True, store_series=snappyseries,
+            store_name=self.getUniqueString("store-name"))
+        view = create_initialized_view(snap, "+index")
+        store_upload_tag = soupmatchers.Tag(
+            "store upload", "div", attrs={"id": "store_upload"})
+        self.assertThat(view(), soupmatchers.HTMLContains(
+            Not(soupmatchers.Tag(
+                "distribution series", "dl", attrs={"id": "distro_series"})),
+            soupmatchers.Within(
+                store_upload_tag,
+                soupmatchers.Tag(
+                    "store series name", "span", text=snappyseries.title)),
+            soupmatchers.Within(
+                store_upload_tag,
+                soupmatchers.Tag("store name", "span", text=snap.store_name))))
+
     def setStatus(self, build, status):
         build.updateStatus(
             BuildStatus.BUILDING, date_started=build.date_created)

=== modified file 'lib/lp/snappy/interfaces/snappyseries.py'
--- lib/lp/snappy/interfaces/snappyseries.py	2019-01-30 18:00:09 +0000
+++ lib/lp/snappy/interfaces/snappyseries.py	2019-02-05 13:05:32 +0000
@@ -1,4 +1,4 @@
-# Copyright 2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2016-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Snappy series interfaces."""
@@ -86,7 +86,7 @@
     registrant = exported(PublicPersonChoice(
         title=_("Registrant"), required=True, readonly=True,
         vocabulary="ValidPersonOrTeam",
-        description=_("The person who registered this snap package.")))
+        description=_("The person who registered this snappy series.")))
 
 
 class ISnappySeriesEditableAttributes(Interface):
@@ -118,6 +118,12 @@
         value_type=Reference(schema=IDistroSeries),
         required=True, readonly=False))
 
+    can_guess_distro_series = exported(Bool(
+        title=_("Can guess distro series?"), required=True, readonly=False,
+        description=_(
+            "True if guessing a distro series from snapcraft.yaml is "
+            "supported for this snappy series.")))
+
 
 class ISnappySeries(ISnappySeriesView, ISnappySeriesEditableAttributes):
     """A series for snap packages in the store."""
@@ -134,7 +140,7 @@
     snappy_series = Reference(
         ISnappySeries, title=_("Snappy series"), readonly=True)
     distro_series = Reference(
-        IDistroSeries, title=_("Distro series"), readonly=True)
+        IDistroSeries, title=_("Distro series"), required=False, readonly=True)
     preferred = Bool(
         title=_("Preferred"),
         required=True, readonly=False,

=== modified file 'lib/lp/snappy/model/snappyseries.py'
--- lib/lp/snappy/model/snappyseries.py	2019-01-30 13:57:06 +0000
+++ lib/lp/snappy/model/snappyseries.py	2019-02-05 13:05:32 +0000
@@ -1,4 +1,4 @@
-# Copyright 2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2016-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Snappy series."""
@@ -138,18 +138,44 @@
                 link = SnappyDistroSeries(self, distro_series)
                 Store.of(self).add(link)
 
+    @cachedproperty
+    def _can_guess_distro_series(self):
+        return not Store.of(self).find(
+            SnappyDistroSeries,
+            SnappyDistroSeries.snappy_series == self,
+            SnappyDistroSeries.distro_series == None).is_empty()
+
+    @property
+    def can_guess_distro_series(self):
+        return self._can_guess_distro_series
+
+    @can_guess_distro_series.setter
+    def can_guess_distro_series(self, value):
+        store = Store.of(self)
+        current = store.find(
+            SnappyDistroSeries,
+            SnappyDistroSeries.snappy_series == self,
+            SnappyDistroSeries.distro_series == None).one()
+        if current is None and value is True:
+            store.add(SnappyDistroSeries(self, None))
+            get_property_cache(self)._can_guess_distro_series = True
+        elif current is not None and value is False:
+            store.remove(current)
+            get_property_cache(self)._can_guess_distro_series = False
+
 
 @implementer(ISnappyDistroSeries)
 class SnappyDistroSeries(Storm):
     """Link table between `SnappySeries` and `DistroSeries`."""
 
     __storm_table__ = 'SnappyDistroSeries'
-    __storm_primary__ = ('snappy_series_id', 'distro_series_id')
+
+    id = Int(primary=True)
 
     snappy_series_id = Int(name='snappy_series', allow_none=False)
     snappy_series = Reference(snappy_series_id, 'SnappySeries.id')
 
-    distro_series_id = Int(name='distro_series', allow_none=False)
+    distro_series_id = Int(name='distro_series', allow_none=True)
     distro_series = Reference(distro_series_id, 'DistroSeries.id')
 
     preferred = Bool(name='preferred', allow_none=False)
@@ -162,8 +188,11 @@
 
     @property
     def title(self):
-        return "%s, for %s" % (
-            self.distro_series.fullseriesname, self.snappy_series.title)
+        if self.distro_series is not None:
+            return "%s, for %s" % (
+                self.distro_series.fullseriesname, self.snappy_series.title)
+        else:
+            return self.snappy_series.title
 
 
 @implementer(ISnappySeriesSet)

=== modified file 'lib/lp/snappy/tests/test_snappyseries.py'
--- lib/lp/snappy/tests/test_snappyseries.py	2019-01-30 18:00:09 +0000
+++ lib/lp/snappy/tests/test_snappyseries.py	2019-02-05 13:05:32 +0000
@@ -1,4 +1,4 @@
-# Copyright 2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2016-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Test snappy series."""
@@ -86,6 +86,20 @@
         self.assertContentEqual([], snappy_series.usable_distro_series)
         self.assertIsNone(snappy_series.preferred_distro_series)
 
+    def test_can_guess_distro_series(self):
+        # Setting can_guess_distro_series indicates that this snappy series
+        # supports inferring the distro series from snapcraft.yaml.
+        snappy_series = self.factory.makeSnappySeries()
+        sds_set = getUtility(ISnappyDistroSeriesSet)
+        self.assertFalse(snappy_series.can_guess_distro_series)
+        self.assertIsNone(sds_set.getByBothSeries(snappy_series, None))
+        snappy_series.can_guess_distro_series = True
+        self.assertTrue(snappy_series.can_guess_distro_series)
+        self.assertIsNotNone(sds_set.getByBothSeries(snappy_series, None))
+        snappy_series.can_guess_distro_series = False
+        self.assertFalse(snappy_series.can_guess_distro_series)
+        self.assertIsNone(sds_set.getByBothSeries(snappy_series, None))
+
     def test_anonymous(self):
         # Anyone can view an `ISnappySeries`.
         series = self.factory.makeSnappySeries(name="dummy")
@@ -235,10 +249,21 @@
         self.assertThat(
             sds_set.getByBothSeries(snappy_serieses[0], dses[0]),
             MatchesStructure.byEquality(
-                snappy_series=snappy_serieses[0], distro_series=dses[0]))
+                snappy_series=snappy_serieses[0], distro_series=dses[0],
+                title="%s, for %s" % (
+                    dses[0].fullseriesname, snappy_serieses[0].title)))
         self.assertIsNone(sds_set.getByBothSeries(snappy_serieses[0], dses[1]))
         self.assertIsNone(sds_set.getByBothSeries(snappy_serieses[1], dses[0]))
         self.assertIsNone(sds_set.getByBothSeries(snappy_serieses[1], dses[1]))
+        self.assertIsNone(sds_set.getByBothSeries(snappy_serieses[0], None))
+        self.assertIsNone(sds_set.getByBothSeries(snappy_serieses[1], None))
+        snappy_serieses[0].can_guess_distro_series = True
+        self.assertThat(
+            sds_set.getByBothSeries(snappy_serieses[0], None),
+            MatchesStructure.byEquality(
+                snappy_series=snappy_serieses[0], distro_series=None,
+                title=snappy_serieses[0].title))
+        self.assertIsNone(sds_set.getByBothSeries(snappy_serieses[1], None))
 
     def test_getAll(self):
         sdses = list(IStore(SnappyDistroSeries).find(SnappyDistroSeries))

=== modified file 'lib/lp/snappy/vocabularies.py'
--- lib/lp/snappy/vocabularies.py	2017-01-04 20:52:12 +0000
+++ lib/lp/snappy/vocabularies.py	2019-02-05 13:05:32 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015-2017 Canonical Ltd.  This software is licensed under the GNU
+# Copyright 2015-2019 Canonical Ltd.  This software is licensed under the GNU
 # Affero General Public License version 3 (see the file LICENSE).
 
 """Snappy vocabularies."""
@@ -6,12 +6,20 @@
 __metaclass__ = type
 
 __all__ = [
+    'BuildableSnappyDistroSeriesVocabulary',
     'SnapDistroArchSeriesVocabulary',
+    'SnappyDistroSeriesVocabulary',
     'SnappySeriesVocabulary',
+    'SnapStoreChannel',
+    'SnapStoreChannelVocabulary',
     ]
 
 from lazr.restful.interfaces import IJSONPublishable
-from storm.locals import Desc
+from storm.expr import LeftJoin
+from storm.locals import (
+    Desc,
+    Not,
+    )
 from zope.component import getUtility
 from zope.interface import implementer
 from zope.schema.vocabulary import (
@@ -24,6 +32,10 @@
 from lp.registry.model.distroseries import DistroSeries
 from lp.registry.model.series import ACTIVE_STATUSES
 from lp.services.database.interfaces import IStore
+from lp.services.database.stormexpr import (
+    IsDistinctFrom,
+    NullsFirst,
+    )
 from lp.services.webapp.vocabulary import StormVocabularyBase
 from lp.snappy.interfaces.snap import ISnap
 from lp.snappy.interfaces.snapstoreclient import ISnapStoreClient
@@ -62,26 +74,35 @@
     """A vocabulary for searching snappy/distro series combinations."""
 
     _table = SnappyDistroSeries
-    _clauses = [
-        SnappyDistroSeries.snappy_series_id == SnappySeries.id,
-        SnappyDistroSeries.distro_series_id == DistroSeries.id,
-        DistroSeries.distributionID == Distribution.id,
+    _origin = [
+        SnappyDistroSeries,
+        LeftJoin(
+            SnappySeries,
+            SnappyDistroSeries.snappy_series_id == SnappySeries.id),
+        LeftJoin(
+            DistroSeries,
+            SnappyDistroSeries.distro_series_id == DistroSeries.id),
+        LeftJoin(Distribution, DistroSeries.distributionID == Distribution.id),
         ]
+    _clauses = []
 
     @property
     def _entries(self):
-        tables = [SnappyDistroSeries, SnappySeries, DistroSeries, Distribution]
-        entries = IStore(self._table).using(*tables).find(
+        entries = IStore(self._table).using(*self._origin).find(
             self._table, *self._clauses)
         return entries.order_by(
-            Distribution.display_name, Desc(DistroSeries.date_created),
+            NullsFirst(Distribution.display_name),
+            Desc(DistroSeries.date_created),
             Desc(SnappySeries.date_created))
 
     def toTerm(self, obj):
         """See `IVocabulary`."""
-        token = "%s/%s/%s" % (
-            obj.distro_series.distribution.name, obj.distro_series.name,
-            obj.snappy_series.name)
+        if obj.distro_series is None:
+            token = obj.snappy_series.name
+        else:
+            token = "%s/%s/%s" % (
+                obj.distro_series.distribution.name, obj.distro_series.name,
+                obj.snappy_series.name)
         return SimpleTerm(obj, token, obj.title)
 
     def __contains__(self, value):
@@ -96,15 +117,20 @@
 
     def getTermByToken(self, token):
         """See `IVocabularyTokenized`."""
-        try:
-            distribution_name, distro_series_name, snappy_series_name = (
-                token.split("/", 2))
-        except ValueError:
-            raise LookupError(token)
-        entry = IStore(self._table).find(
+        if "/" in token:
+            try:
+                distribution_name, distro_series_name, snappy_series_name = (
+                    token.split("/", 2))
+            except ValueError:
+                raise LookupError(token)
+        else:
+            distribution_name = None
+            distro_series_name = None
+            snappy_series_name = token
+        entry = IStore(self._table).using(*self._origin).find(
             self._table,
-            Distribution.name == distribution_name,
-            DistroSeries.name == distro_series_name,
+            Not(IsDistinctFrom(Distribution.name, distribution_name)),
+            Not(IsDistinctFrom(DistroSeries.name, distro_series_name)),
             SnappySeries.name == snappy_series_name,
             *self._clauses).one()
         if entry is None:

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2019-02-05 13:05:32 +0000
+++ lib/lp/testing/factory.py	2019-02-05 13:05:32 +0000
@@ -4824,7 +4824,8 @@
     def makeSnappySeries(self, registrant=None, name=None, display_name=None,
                          status=SeriesStatus.DEVELOPMENT,
                          preferred_distro_series=None, date_created=DEFAULT,
-                         usable_distro_series=None):
+                         usable_distro_series=None,
+                         can_guess_distro_series=False):
         """Make a new SnappySeries."""
         if registrant is None:
             registrant = self.makePerson()
@@ -4841,6 +4842,8 @@
             snappy_series.usable_distro_series = usable_distro_series
         elif preferred_distro_series is not None:
             snappy_series.usable_distro_series = [preferred_distro_series]
+        if can_guess_distro_series:
+            snappy_series.can_guess_distro_series = True
         IStore(snappy_series).flush()
         return snappy_series
 


Follow ups