Merge lp:~cjwatson/launchpad/snap-initial-name-bzr into lp:launchpad

Proposed by Colin Watson on 2018-05-17
Status: Needs review
Proposed branch: lp:~cjwatson/launchpad/snap-initial-name-bzr
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/better-bzr-mp-diffs
Diff against target: 1044 lines (+564/-106)
9 files modified
lib/lp/code/interfaces/branch.py (+9/-0)
lib/lp/code/model/branch.py (+63/-0)
lib/lp/code/model/tests/test_branch.py (+152/-1)
lib/lp/code/tests/helpers.py (+21/-2)
lib/lp/snappy/browser/snap.py (+13/-30)
lib/lp/snappy/browser/tests/test_snap.py (+69/-72)
lib/lp/snappy/interfaces/snap.py (+26/-0)
lib/lp/snappy/model/snap.py (+59/-1)
lib/lp/snappy/tests/test_snap.py (+152/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/snap-initial-name-bzr
Reviewer Review Type Date Requested Status
Launchpad code reviewers 2018-05-17 Pending
Review via email: mp+345757@code.launchpad.net

Commit message

Extract initial Snap.store_name from snapcraft.yaml for Bazaar as well as Git.

Description of the change

The potential timeout issues will be at least as bad as with git, but BranchHostingClient has all the same sort of timeout management code as we put together for GitHostingClient, which should mitigate that.

To post a comment you must log in.
18669. By Colin Watson on 2018-06-12

Fix TestSnapViewsFeatureFlag and TestSnapAddView using BranchHostingFixture.

18670. By Colin Watson on 2018-06-14

Merge better-bzr-mp-diffs.

Unmerged revisions

18670. By Colin Watson on 2018-06-14

Merge better-bzr-mp-diffs.

18669. By Colin Watson on 2018-06-12

Fix TestSnapViewsFeatureFlag and TestSnapAddView using BranchHostingFixture.

18668. By Colin Watson on 2018-05-17

Extract initial Snap.store_name from snapcraft.yaml for Bazaar as well as Git.

18667. By Colin Watson on 2018-05-16

Push snapcraft.yaml fetching and parsing down to SnapSet.getSnapcraftYaml.

18666. By Colin Watson on 2018-05-16

Minor simplification.

18665. By Colin Watson on 2018-05-16

Update copyright.

18664. By Colin Watson on 2018-05-16

Proxy loggerhead branch diffs through the webapp, allowing AJAX MP revision diffs to work for private branches.

18663. By Colin Watson on 2018-05-16

Allow BranchHostingClient.getDiff to get the diff for a single revision (i.e. against its parent).

18662. By Colin Watson on 2018-05-16

Fix direction of diffs produced by BranchHostingClient.

18661. By Colin Watson on 2018-05-16

Improve IBranchHostingClient docstrings.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/code/interfaces/branch.py'
2--- lib/lp/code/interfaces/branch.py 2018-06-14 17:09:31 +0000
3+++ lib/lp/code/interfaces/branch.py 2018-06-14 17:09:31 +0000
4@@ -765,6 +765,15 @@
5 :param launchbag: `ILaunchBag`.
6 """
7
8+ def getBlob(filename, revision_id=None):
9+ """Get a blob by file name from this branch.
10+
11+ :param filename: Relative path of a file in the branch.
12+ :param revision_id: An optional revision ID. Defaults to the last
13+ scanned revision ID of the branch.
14+ :return: The blob content as a byte string.
15+ """
16+
17 def getDiff(new, old):
18 """Get the diff between two revisions in this branch.
19
20
21=== modified file 'lib/lp/code/model/branch.py'
22--- lib/lp/code/model/branch.py 2018-06-14 17:09:31 +0000
23+++ lib/lp/code/model/branch.py 2018-06-14 17:09:31 +0000
24@@ -10,12 +10,15 @@
25
26 from datetime import datetime
27 from functools import partial
28+import json
29 import operator
30+import os.path
31
32 from bzrlib import urlutils
33 from bzrlib.revision import NULL_REVISION
34 from lazr.lifecycle.event import ObjectCreatedEvent
35 import pytz
36+from six.moves.urllib_parse import urlsplit
37 from sqlobject import (
38 ForeignKey,
39 IntCol,
40@@ -84,6 +87,7 @@
41 )
42 from lp.code.errors import (
43 AlreadyLatestFormat,
44+ BranchFileNotFound,
45 BranchMergeProposalExists,
46 BranchTargetError,
47 BranchTypeError,
48@@ -172,10 +176,12 @@
49 ArrayAgg,
50 ArrayIntersects,
51 )
52+from lp.services.features import getFeatureFlag
53 from lp.services.helpers import shortlist
54 from lp.services.job.interfaces.job import JobStatus
55 from lp.services.job.model.job import Job
56 from lp.services.mail.notificationrecipientset import NotificationRecipientSet
57+from lp.services.memcache.interfaces import IMemcacheClient
58 from lp.services.propertycache import (
59 cachedproperty,
60 get_property_cache,
61@@ -783,6 +789,63 @@
62 RevisionAuthor, revisions, ['revision_author_id'])
63 return DecoratedResultSet(result, pre_iter_hook=eager_load)
64
65+ def getBlob(self, filename, revision_id=None, enable_memcache=None,
66+ logger=None):
67+ """See `IBranch`."""
68+ hosting_client = getUtility(IBranchHostingClient)
69+ if enable_memcache is None:
70+ enable_memcache = not getFeatureFlag(
71+ u'code.bzr.blob.disable_memcache')
72+ if revision_id is None:
73+ revision_id = self.last_scanned_id
74+ if revision_id is None:
75+ # revision_id may still be None if the branch scanner hasn't
76+ # scanned this branch yet. In this case, we won't be able to
77+ # guarantee that subsequent calls to this method within the same
78+ # transaction with revision_id=None will see the same revision,
79+ # and we won't be able to cache file lists. Neither is fatal,
80+ # and this should be relatively rare.
81+ enable_memcache = False
82+ dirname = os.path.dirname(filename)
83+ unset = object()
84+ file_list = unset
85+ if enable_memcache:
86+ memcache_client = getUtility(IMemcacheClient)
87+ instance_name = urlsplit(
88+ config.codehosting.internal_bzr_api_endpoint).hostname
89+ memcache_key = '%s:bzr-file-list:%s:%s:%s' % (
90+ instance_name, self.id, revision_id, dirname)
91+ if not isinstance(memcache_key, bytes):
92+ memcache_key = memcache_key.encode('UTF-8')
93+ cached_file_list = memcache_client.get(memcache_key)
94+ if cached_file_list is not None:
95+ try:
96+ file_list = json.loads(cached_file_list)
97+ except Exception:
98+ logger.exception(
99+ 'Cannot load cached file list for %s:%s:%s; deleting' %
100+ (self.unique_name, revision_id, dirname))
101+ memcache_client.delete(memcache_key)
102+ if file_list is unset:
103+ try:
104+ inventory = hosting_client.getInventory(
105+ self.unique_name, dirname, rev=revision_id)
106+ file_list = {
107+ entry['filename']: entry['file_id']
108+ for entry in inventory['filelist']}
109+ except BranchFileNotFound:
110+ file_list = None
111+ if enable_memcache:
112+ # Cache the file list in case there's a request for another
113+ # file in the same directory.
114+ memcache_client.set(memcache_key, json.dumps(file_list))
115+ file_id = (file_list or {}).get(os.path.basename(filename))
116+ if file_id is None:
117+ raise BranchFileNotFound(
118+ self.unique_name, filename=filename, rev=revision_id)
119+ return hosting_client.getBlob(
120+ self.unique_name, file_id, rev=revision_id)
121+
122 def getDiff(self, new, old=None):
123 """See `IBranch`."""
124 hosting_client = getUtility(IBranchHostingClient)
125
126=== modified file 'lib/lp/code/model/tests/test_branch.py'
127--- lib/lp/code/model/tests/test_branch.py 2018-03-06 00:59:06 +0000
128+++ lib/lp/code/model/tests/test_branch.py 2018-06-14 17:09:31 +0000
129@@ -11,6 +11,7 @@
130 datetime,
131 timedelta,
132 )
133+import json
134
135 from bzrlib.branch import Branch
136 from bzrlib.bzrdir import BzrDir
137@@ -61,6 +62,7 @@
138 AlreadyLatestFormat,
139 BranchCreatorNotMemberOfOwnerTeam,
140 BranchCreatorNotOwner,
141+ BranchFileNotFound,
142 BranchTargetError,
143 CannotDeleteBranch,
144 CannotUpgradeNonHosted,
145@@ -109,7 +111,10 @@
146 from lp.code.model.branchrevision import BranchRevision
147 from lp.code.model.codereviewcomment import CodeReviewComment
148 from lp.code.model.revision import Revision
149-from lp.code.tests.helpers import add_revision_to_branch
150+from lp.code.tests.helpers import (
151+ add_revision_to_branch,
152+ BranchHostingFixture,
153+ )
154 from lp.codehosting.safe_open import BadUrl
155 from lp.codehosting.vfs.branchfs import get_real_branch_path
156 from lp.registry.enums import (
157@@ -136,6 +141,7 @@
158 block_on_job,
159 monitor_celery,
160 )
161+from lp.services.memcache.interfaces import IMemcacheClient
162 from lp.services.osutils import override_environ
163 from lp.services.propertycache import clear_property_cache
164 from lp.services.webapp.authorization import check_permission
165@@ -159,6 +165,7 @@
166 )
167 from lp.testing.dbuser import dbuser
168 from lp.testing.factory import LaunchpadObjectFactory
169+from lp.testing.fakemethod import FakeMethod
170 from lp.testing.layers import (
171 CeleryBranchWriteJobLayer,
172 CeleryBzrsyncdJobLayer,
173@@ -3293,6 +3300,150 @@
174 self.assertRaises(BadUrl, db_stacked.getBzrBranch)
175
176
177+class TestBranchGetBlob(TestCaseWithFactory):
178+
179+ layer = DatabaseFunctionalLayer
180+
181+ def test_default_rev_unscanned(self):
182+ branch = self.factory.makeBranch()
183+ hosting_fixture = self.useFixture(BranchHostingFixture(
184+ file_list={'README.txt': 'some-file-id'}, blob=b'Some text'))
185+ blob = branch.getBlob('src/README.txt')
186+ self.assertEqual('Some text', blob)
187+ self.assertEqual(
188+ [((branch.unique_name, 'src'), {'rev': None})],
189+ hosting_fixture.getInventory.calls)
190+ self.assertEqual(
191+ [((branch.unique_name, 'some-file-id'), {'rev': None})],
192+ hosting_fixture.getBlob.calls)
193+ self.assertEqual({}, getUtility(IMemcacheClient)._cache)
194+
195+ def test_default_rev_scanned(self):
196+ branch = self.factory.makeBranch()
197+ removeSecurityProxy(branch).last_scanned_id = 'scanned-id'
198+ hosting_fixture = self.useFixture(BranchHostingFixture(
199+ file_list={'README.txt': 'some-file-id'}, blob=b'Some text'))
200+ blob = branch.getBlob('src/README.txt')
201+ self.assertEqual('Some text', blob)
202+ self.assertEqual(
203+ [((branch.unique_name, 'src'), {'rev': 'scanned-id'})],
204+ hosting_fixture.getInventory.calls)
205+ self.assertEqual(
206+ [((branch.unique_name, 'some-file-id'), {'rev': 'scanned-id'})],
207+ hosting_fixture.getBlob.calls)
208+ key = (
209+ 'bazaar.launchpad.dev:bzr-file-list:%s:scanned-id:src' % branch.id)
210+ self.assertEqual(
211+ json.dumps({'README.txt': 'some-file-id'}),
212+ getUtility(IMemcacheClient).get(key.encode('UTF-8')))
213+
214+ def test_with_rev(self):
215+ branch = self.factory.makeBranch()
216+ hosting_fixture = self.useFixture(BranchHostingFixture(
217+ file_list={'README.txt': 'some-file-id'}, blob=b'Some text'))
218+ blob = branch.getBlob('src/README.txt', revision_id='some-rev')
219+ self.assertEqual('Some text', blob)
220+ self.assertEqual(
221+ [((branch.unique_name, 'src'), {'rev': 'some-rev'})],
222+ hosting_fixture.getInventory.calls)
223+ self.assertEqual(
224+ [((branch.unique_name, 'some-file-id'), {'rev': 'some-rev'})],
225+ hosting_fixture.getBlob.calls)
226+ key = 'bazaar.launchpad.dev:bzr-file-list:%s:some-rev:src' % branch.id
227+ self.assertEqual(
228+ json.dumps({'README.txt': 'some-file-id'}),
229+ getUtility(IMemcacheClient).get(key.encode('UTF-8')))
230+
231+ def test_cached_inventory(self):
232+ branch = self.factory.makeBranch()
233+ hosting_fixture = self.useFixture(BranchHostingFixture(
234+ blob=b'Some text'))
235+ key = 'bazaar.launchpad.dev:bzr-file-list:%s:some-rev:src' % branch.id
236+ getUtility(IMemcacheClient).set(
237+ key.encode('UTF-8'), json.dumps({'README.txt': 'some-file-id'}))
238+ blob = branch.getBlob('src/README.txt', revision_id='some-rev')
239+ self.assertEqual('Some text', blob)
240+ self.assertEqual([], hosting_fixture.getInventory.calls)
241+ self.assertEqual(
242+ [((branch.unique_name, 'some-file-id'), {'rev': 'some-rev'})],
243+ hosting_fixture.getBlob.calls)
244+
245+ def test_disable_memcache(self):
246+ self.useFixture(FeatureFixture(
247+ {'code.bzr.blob.disable_memcache': 'on'}))
248+ branch = self.factory.makeBranch()
249+ hosting_fixture = self.useFixture(BranchHostingFixture(
250+ file_list={'README.txt': 'some-file-id'}, blob=b'Some text'))
251+ key = 'bazaar.launchpad.dev:bzr-file-list:%s:some-rev:src' % branch.id
252+ getUtility(IMemcacheClient).set(key.encode('UTF-8'), '{}')
253+ blob = branch.getBlob('src/README.txt', revision_id='some-rev')
254+ self.assertEqual('Some text', blob)
255+ self.assertEqual(
256+ [((branch.unique_name, 'src'), {'rev': 'some-rev'})],
257+ hosting_fixture.getInventory.calls)
258+ self.assertEqual(
259+ '{}', getUtility(IMemcacheClient).get(key.encode('UTF-8')))
260+
261+ def test_file_at_root_of_branch(self):
262+ branch = self.factory.makeBranch()
263+ hosting_fixture = self.useFixture(BranchHostingFixture(
264+ file_list={'README.txt': 'some-file-id'}, blob=b'Some text'))
265+ blob = branch.getBlob('README.txt', revision_id='some-rev')
266+ self.assertEqual('Some text', blob)
267+ self.assertEqual(
268+ [((branch.unique_name, ''), {'rev': 'some-rev'})],
269+ hosting_fixture.getInventory.calls)
270+ self.assertEqual(
271+ [((branch.unique_name, 'some-file-id'), {'rev': 'some-rev'})],
272+ hosting_fixture.getBlob.calls)
273+ key = 'bazaar.launchpad.dev:bzr-file-list:%s:some-rev:' % branch.id
274+ self.assertEqual(
275+ json.dumps({'README.txt': 'some-file-id'}),
276+ getUtility(IMemcacheClient).get(key.encode('UTF-8')))
277+
278+ def test_file_not_in_directory(self):
279+ branch = self.factory.makeBranch()
280+ hosting_fixture = self.useFixture(BranchHostingFixture(file_list={}))
281+ self.assertRaises(
282+ BranchFileNotFound, branch.getBlob,
283+ 'src/README.txt', revision_id='some-rev')
284+ self.assertEqual(
285+ [((branch.unique_name, 'src'), {'rev': 'some-rev'})],
286+ hosting_fixture.getInventory.calls)
287+ self.assertEqual([], hosting_fixture.getBlob.calls)
288+ key = 'bazaar.launchpad.dev:bzr-file-list:%s:some-rev:src' % branch.id
289+ self.assertEqual(
290+ '{}', getUtility(IMemcacheClient).get(key.encode('UTF-8')))
291+
292+ def test_missing_directory(self):
293+ branch = self.factory.makeBranch()
294+ hosting_fixture = self.useFixture(BranchHostingFixture())
295+ hosting_fixture.getInventory = FakeMethod(
296+ failure=BranchFileNotFound(
297+ branch.unique_name, filename='src', rev='some-rev'))
298+ self.assertRaises(
299+ BranchFileNotFound, branch.getBlob,
300+ 'src/README.txt', revision_id='some-rev')
301+ self.assertEqual(
302+ [((branch.unique_name, 'src'), {'rev': 'some-rev'})],
303+ hosting_fixture.getInventory.calls)
304+ self.assertEqual([], hosting_fixture.getBlob.calls)
305+ key = 'bazaar.launchpad.dev:bzr-file-list:%s:some-rev:src' % branch.id
306+ self.assertEqual(
307+ 'null', getUtility(IMemcacheClient).get(key.encode('UTF-8')))
308+
309+ def test_cached_missing_directory(self):
310+ branch = self.factory.makeBranch()
311+ hosting_fixture = self.useFixture(BranchHostingFixture())
312+ key = 'bazaar.launchpad.dev:bzr-file-list:%s:some-rev:src' % branch.id
313+ getUtility(IMemcacheClient).set(key.encode('UTF-8'), 'null')
314+ self.assertRaises(
315+ BranchFileNotFound, branch.getBlob,
316+ 'src/README.txt', revision_id='some-rev')
317+ self.assertEqual([], hosting_fixture.getInventory.calls)
318+ self.assertEqual([], hosting_fixture.getBlob.calls)
319+
320+
321 class TestBranchUnscan(TestCaseWithFactory):
322
323 layer = DatabaseFunctionalLayer
324
325=== modified file 'lib/lp/code/tests/helpers.py'
326--- lib/lp/code/tests/helpers.py 2018-06-14 17:09:31 +0000
327+++ lib/lp/code/tests/helpers.py 2018-06-14 17:09:31 +0000
328@@ -306,14 +306,33 @@
329 class BranchHostingFixture(fixtures.Fixture):
330 """A fixture that temporarily registers a fake Bazaar hosting client."""
331
332- def __init__(self, diff=None, inventory=None, blob=None):
333+ def __init__(self, diff=None, inventory=None, file_list=None, blob=None,
334+ disable_memcache=True):
335 self.create = FakeMethod()
336 self.getDiff = FakeMethod(result=diff or {})
337- self.getInventory = FakeMethod(result=inventory or {})
338+ if inventory is None:
339+ if file_list is not None:
340+ # Simple common case.
341+ inventory = {
342+ "filelist": [
343+ {"filename": filename, "file_id": file_id}
344+ for filename, file_id in file_list.items()],
345+ }
346+ else:
347+ inventory = {"filelist": []}
348+ self.getInventory = FakeMethod(result=inventory)
349 self.getBlob = FakeMethod(result=blob)
350+ self.disable_memcache = disable_memcache
351
352 def _setUp(self):
353 self.useFixture(ZopeUtilityFixture(self, IBranchHostingClient))
354+ if self.disable_memcache:
355+ # Most tests that involve Branch.getBlob don't want to cache the
356+ # result: doing so requires more time-consuming test setup and
357+ # makes it awkward to repeat the same call with different
358+ # responses. For convenience, we make it easy to disable that
359+ # here.
360+ self.memcache_fixture = self.useFixture(MemcacheFixture())
361
362
363 class GitHostingFixture(fixtures.Fixture):
364
365=== modified file 'lib/lp/snappy/browser/snap.py'
366--- lib/lp/snappy/browser/snap.py 2018-04-21 10:01:22 +0000
367+++ lib/lp/snappy/browser/snap.py 2018-06-14 17:09:31 +0000
368@@ -23,7 +23,6 @@
369 copy_field,
370 use_template,
371 )
372-import yaml
373 from zope.component import getUtility
374 from zope.error.interfaces import IErrorReportingUtility
375 from zope.interface import Interface
376@@ -44,6 +43,7 @@
377 from lp.app.browser.lazrjs import InlinePersonEditPickerWidget
378 from lp.app.browser.tales import format_link
379 from lp.app.enums import PRIVATE_INFORMATION_TYPES
380+from lp.app.errors import NotFoundError
381 from lp.app.interfaces.informationtype import IInformationType
382 from lp.app.widgets.itemswidgets import (
383 LabeledMultiCheckBoxWidget,
384@@ -52,10 +52,6 @@
385 )
386 from lp.buildmaster.interfaces.processor import IProcessorSet
387 from lp.code.browser.widgets.gitref import GitRefWidget
388-from lp.code.errors import (
389- GitRepositoryBlobNotFound,
390- GitRepositoryScanFault,
391- )
392 from lp.code.interfaces.gitref import IGitRef
393 from lp.registry.enums import VCSType
394 from lp.registry.interfaces.pocket import PackagePublishingPocket
395@@ -83,6 +79,8 @@
396 from lp.snappy.browser.widgets.storechannels import StoreChannelsWidget
397 from lp.snappy.interfaces.snap import (
398 CannotAuthorizeStoreUploads,
399+ CannotFetchSnapcraftYaml,
400+ CannotParseSnapcraftYaml,
401 ISnap,
402 ISnapSet,
403 NoSuchSnap,
404@@ -424,34 +422,19 @@
405 @property
406 def initial_values(self):
407 store_name = None
408- if self.has_snappy_distro_series and IGitRef.providedBy(self.context):
409+ if self.has_snappy_distro_series:
410 # Try to extract Snap store name from snapcraft.yaml file.
411 try:
412- paths = (
413- 'snap/snapcraft.yaml',
414- 'snapcraft.yaml',
415- '.snapcraft.yaml',
416- )
417- for i, path in enumerate(paths):
418- try:
419- blob = self.context.repository.getBlob(
420- path, self.context.name)
421- break
422- except GitRepositoryBlobNotFound:
423- if i == len(paths) - 1:
424- raise
425- # Beware of unsafe yaml.load()!
426- store_name = yaml.safe_load(blob).get('name')
427- except GitRepositoryScanFault:
428- log.exception("Failed to get Snap manifest from Git %s",
429- self.context.unique_name)
430- except (AttributeError, yaml.YAMLError):
431- # Ignore parsing errors from invalid, user-supplied YAML
432+ snapcraft_data = getUtility(ISnapSet).getSnapcraftYaml(
433+ self.context, logger=log)
434+ except (NotFoundError, CannotFetchSnapcraftYaml,
435+ CannotParseSnapcraftYaml):
436 pass
437- except Exception as e:
438- log.exception(
439- "Failed to extract name from Snap manifest at Git %s: %s",
440- self.context.unique_name, unicode(e))
441+ else:
442+ try:
443+ store_name = snapcraft_data.get('name')
444+ except AttributeError:
445+ pass
446
447 store_series = getUtility(ISnappySeriesSet).getAll().first()
448 if store_series.preferred_distro_series is not None:
449
450=== modified file 'lib/lp/snappy/browser/tests/test_snap.py'
451--- lib/lp/snappy/browser/tests/test_snap.py 2018-05-31 10:23:03 +0000
452+++ lib/lp/snappy/browser/tests/test_snap.py 2018-06-14 17:09:31 +0000
453@@ -21,7 +21,6 @@
454
455 from fixtures import FakeLogger
456 from mechanize import LinkNotFoundError
457-import mock
458 from pymacaroons import Macaroon
459 import pytz
460 import responses
461@@ -40,10 +39,13 @@
462 from lp.buildmaster.enums import BuildStatus
463 from lp.buildmaster.interfaces.processor import IProcessorSet
464 from lp.code.errors import (
465- GitRepositoryBlobNotFound,
466+ BranchHostingFault,
467 GitRepositoryScanFault,
468 )
469-from lp.code.tests.helpers import GitHostingFixture
470+from lp.code.tests.helpers import (
471+ BranchHostingFixture,
472+ GitHostingFixture,
473+ )
474 from lp.registry.enums import PersonVisibility
475 from lp.registry.interfaces.pocket import PackagePublishingPocket
476 from lp.registry.interfaces.series import SeriesStatus
477@@ -128,6 +130,7 @@
478 def test_private_feature_flag_disabled(self):
479 # Without a private_snap feature flag, we will not create Snaps for
480 # private contexts.
481+ self.useFixture(BranchHostingFixture())
482 self.snap_store_client = FakeMethod()
483 self.snap_store_client.listChannels = FakeMethod(result=[])
484 self.useFixture(
485@@ -197,6 +200,7 @@
486 def test_initial_store_distro_series(self):
487 # The initial store_distro_series uses the preferred distribution
488 # series for the latest snappy series.
489+ self.useFixture(BranchHostingFixture(blob=b""))
490 lts = self.factory.makeUbuntuDistroSeries(
491 version="16.04", status=SeriesStatus.CURRENT)
492 current = self.factory.makeUbuntuDistroSeries(
493@@ -221,6 +225,7 @@
494 no_login=True)
495
496 def test_create_new_snap_bzr(self):
497+ self.useFixture(BranchHostingFixture(blob=b""))
498 branch = self.factory.makeAnyBranch()
499 source_display = branch.display_name
500 browser = self.getViewBrowser(
501@@ -257,7 +262,7 @@
502 MatchesTagText(content, "store_upload"))
503
504 def test_create_new_snap_git(self):
505- self.useFixture(GitHostingFixture(blob=""))
506+ self.useFixture(GitHostingFixture(blob=b""))
507 [git_ref] = self.factory.makeGitRefs()
508 source_display = git_ref.display_name
509 browser = self.getViewBrowser(
510@@ -295,6 +300,7 @@
511
512 def test_create_new_snap_users_teams_as_owner_options(self):
513 # Teams that the user is in are options for the snap package owner.
514+ self.useFixture(BranchHostingFixture(blob=b""))
515 self.factory.makeTeam(
516 name="test-team", displayname="Test Team", members=[self.person])
517 branch = self.factory.makeAnyBranch()
518@@ -307,6 +313,7 @@
519
520 def test_create_new_snap_public(self):
521 # Public owner implies public snap.
522+ self.useFixture(BranchHostingFixture(blob=b""))
523 branch = self.factory.makeAnyBranch()
524
525 browser = self.getViewBrowser(
526@@ -339,6 +346,7 @@
527
528 def test_create_new_snap_private(self):
529 # Private teams will automatically create private snaps.
530+ self.useFixture(BranchHostingFixture(blob=b""))
531 login_person(self.person)
532 self.factory.makeTeam(
533 name='super-private', owner=self.person,
534@@ -360,6 +368,7 @@
535
536 def test_create_new_snap_build_source_tarball(self):
537 # We can create a new snap and ask for it to build a source tarball.
538+ self.useFixture(BranchHostingFixture(blob=b""))
539 branch = self.factory.makeAnyBranch()
540 browser = self.getViewBrowser(
541 branch, view_name="+new-snap", user=self.person)
542@@ -375,6 +384,7 @@
543 def test_create_new_snap_auto_build(self):
544 # Creating a new snap and asking for it to be automatically built
545 # sets all the appropriate fields.
546+ self.useFixture(BranchHostingFixture(blob=b""))
547 branch = self.factory.makeAnyBranch()
548 archive = self.factory.makeArchive()
549 browser = self.getViewBrowser(
550@@ -405,6 +415,7 @@
551 # Creating a new snap and asking for it to be automatically uploaded
552 # to the store sets all the appropriate fields and redirects to SSO
553 # for authorization.
554+ self.useFixture(BranchHostingFixture(blob=b""))
555 branch = self.factory.makeAnyBranch()
556 view_url = canonical_url(branch, view_name="+new-snap")
557 browser = self.getNonRedirectingBrowser(url=view_url, user=self.person)
558@@ -460,6 +471,7 @@
559 self.assertEqual(expected_args, parse_qs(parsed_location[3]))
560
561 def test_create_new_snap_display_processors(self):
562+ self.useFixture(BranchHostingFixture(blob=b""))
563 branch = self.factory.makeAnyBranch()
564 self.setUpDistroSeries()
565 browser = self.getViewBrowser(
566@@ -473,6 +485,7 @@
567
568 def test_create_new_snap_display_restricted_processors(self):
569 # A restricted processor is shown disabled in the UI.
570+ self.useFixture(BranchHostingFixture(blob=b""))
571 branch = self.factory.makeAnyBranch()
572 distroseries = self.setUpDistroSeries()
573 proc_armhf = self.factory.makeProcessor(
574@@ -487,6 +500,7 @@
575 processors, ["386", "amd64", "hppa"], ["armhf"])
576
577 def test_create_new_snap_processors(self):
578+ self.useFixture(BranchHostingFixture(blob=b""))
579 branch = self.factory.makeAnyBranch()
580 self.setUpDistroSeries()
581 browser = self.getViewBrowser(
582@@ -500,74 +514,57 @@
583 self.assertContentEqual(
584 ["386", "amd64"], [proc.name for proc in snap.processors])
585
586- def test_initial_name_extraction_git_snap_snapcraft_yaml(self):
587- def getBlob(filename, *args, **kwargs):
588- if filename == "snap/snapcraft.yaml":
589- return "name: test-snap"
590- else:
591- raise GitRepositoryBlobNotFound("dummy", filename)
592-
593- [git_ref] = self.factory.makeGitRefs()
594- git_ref.repository.getBlob = getBlob
595- view = create_initialized_view(git_ref, "+new-snap")
596- initial_values = view.initial_values
597- self.assertIn('store_name', initial_values)
598- self.assertEqual('test-snap', initial_values['store_name'])
599-
600- def test_initial_name_extraction_git_plain_snapcraft_yaml(self):
601- def getBlob(filename, *args, **kwargs):
602- if filename == "snapcraft.yaml":
603- return "name: test-snap"
604- else:
605- raise GitRepositoryBlobNotFound("dummy", filename)
606-
607- [git_ref] = self.factory.makeGitRefs()
608- git_ref.repository.getBlob = getBlob
609- view = create_initialized_view(git_ref, "+new-snap")
610- initial_values = view.initial_values
611- self.assertIn('store_name', initial_values)
612- self.assertEqual('test-snap', initial_values['store_name'])
613-
614- def test_initial_name_extraction_git_dot_snapcraft_yaml(self):
615- def getBlob(filename, *args, **kwargs):
616- if filename == ".snapcraft.yaml":
617- return "name: test-snap"
618- else:
619- raise GitRepositoryBlobNotFound("dummy", filename)
620-
621- [git_ref] = self.factory.makeGitRefs()
622- git_ref.repository.getBlob = getBlob
623- view = create_initialized_view(git_ref, "+new-snap")
624- initial_values = view.initial_values
625- self.assertIn('store_name', initial_values)
626- self.assertEqual('test-snap', initial_values['store_name'])
627-
628- def test_initial_name_extraction_git_repo_error(self):
629- [git_ref] = self.factory.makeGitRefs()
630- git_ref.repository.getBlob = FakeMethod(failure=GitRepositoryScanFault)
631- view = create_initialized_view(git_ref, "+new-snap")
632- initial_values = view.initial_values
633- self.assertIn('store_name', initial_values)
634- self.assertIsNone(initial_values['store_name'])
635-
636- def test_initial_name_extraction_git_invalid_data(self):
637- for invalid_result in (None, 123, '', '[][]', '#name:test', ']'):
638- [git_ref] = self.factory.makeGitRefs()
639- git_ref.repository.getBlob = FakeMethod(result=invalid_result)
640- view = create_initialized_view(git_ref, "+new-snap")
641- initial_values = view.initial_values
642- self.assertIn('store_name', initial_values)
643- self.assertIsNone(initial_values['store_name'])
644-
645- def test_initial_name_extraction_git_safe_yaml(self):
646- [git_ref] = self.factory.makeGitRefs()
647- git_ref.repository.getBlob = FakeMethod(result='Malicious YAML!')
648- view = create_initialized_view(git_ref, "+new-snap")
649- with mock.patch('yaml.load') as unsafe_load:
650- with mock.patch('yaml.safe_load') as safe_load:
651- view.initial_values
652- self.assertEqual(0, unsafe_load.call_count)
653- self.assertEqual(1, safe_load.call_count)
654+ def test_initial_name_extraction_bzr_success(self):
655+ self.useFixture(BranchHostingFixture(
656+ file_list={"snapcraft.yaml": "file-id"}, blob=b"name: test-snap"))
657+ branch = self.factory.makeBranch()
658+ view = create_initialized_view(branch, "+new-snap")
659+ initial_values = view.initial_values
660+ self.assertIn('store_name', initial_values)
661+ self.assertEqual('test-snap', initial_values['store_name'])
662+
663+ def test_initial_name_extraction_bzr_error(self):
664+ self.useFixture(BranchHostingFixture()).getInventory = FakeMethod(
665+ failure=BranchHostingFault)
666+ branch = self.factory.makeBranch()
667+ view = create_initialized_view(branch, "+new-snap")
668+ initial_values = view.initial_values
669+ self.assertIn('store_name', initial_values)
670+ self.assertIsNone(initial_values['store_name'])
671+
672+ def test_initial_name_extraction_bzr_no_name(self):
673+ self.useFixture(BranchHostingFixture(
674+ file_list={"snapcraft.yaml": "file-id"}, blob=b"some: nonsense"))
675+ branch = self.factory.makeBranch()
676+ view = create_initialized_view(branch, "+new-snap")
677+ initial_values = view.initial_values
678+ self.assertIn('store_name', initial_values)
679+ self.assertIsNone(initial_values['store_name'])
680+
681+ def test_initial_name_extraction_git_success(self):
682+ self.useFixture(GitHostingFixture(blob=b"name: test-snap"))
683+ [git_ref] = self.factory.makeGitRefs()
684+ view = create_initialized_view(git_ref, "+new-snap")
685+ initial_values = view.initial_values
686+ self.assertIn('store_name', initial_values)
687+ self.assertEqual('test-snap', initial_values['store_name'])
688+
689+ def test_initial_name_extraction_git_error(self):
690+ self.useFixture(GitHostingFixture()).getBlob = FakeMethod(
691+ failure=GitRepositoryScanFault)
692+ [git_ref] = self.factory.makeGitRefs()
693+ view = create_initialized_view(git_ref, "+new-snap")
694+ initial_values = view.initial_values
695+ self.assertIn('store_name', initial_values)
696+ self.assertIsNone(initial_values['store_name'])
697+
698+ def test_initial_name_extraction_git_no_name(self):
699+ self.useFixture(GitHostingFixture(blob=b"some: nonsense"))
700+ [git_ref] = self.factory.makeGitRefs()
701+ view = create_initialized_view(git_ref, "+new-snap")
702+ initial_values = view.initial_values
703+ self.assertIn('store_name', initial_values)
704+ self.assertIsNone(initial_values['store_name'])
705
706
707 class TestSnapAdminView(BaseTestSnapView):
708
709=== modified file 'lib/lp/snappy/interfaces/snap.py'
710--- lib/lp/snappy/interfaces/snap.py 2018-05-07 05:25:27 +0000
711+++ lib/lp/snappy/interfaces/snap.py 2018-06-14 17:09:31 +0000
712@@ -9,7 +9,9 @@
713 'BadSnapSearchContext',
714 'BadSnapSource',
715 'CannotAuthorizeStoreUploads',
716+ 'CannotFetchSnapcraftYaml',
717 'CannotModifySnapProcessor',
718+ 'CannotParseSnapcraftYaml',
719 'CannotRequestAutoBuilds',
720 'DuplicateSnapName',
721 'ISnap',
722@@ -237,6 +239,14 @@
723 "because %s is not set." % field)
724
725
726+class CannotFetchSnapcraftYaml(Exception):
727+ """Launchpad cannot fetch this snap package's snapcraft.yaml."""
728+
729+
730+class CannotParseSnapcraftYaml(Exception):
731+ """Launchpad cannot parse this snap package's snapcraft.yaml."""
732+
733+
734 class ISnapView(Interface):
735 """`ISnap` attributes that require launchpad.View permission."""
736
737@@ -787,6 +797,22 @@
738 def preloadDataForSnaps(snaps, user):
739 """Load the data related to a list of snap packages."""
740
741+ def getSnapcraftYaml(context, logger=None):
742+ """Fetch a package's snapcraft.yaml from code hosting, if possible.
743+
744+ :param context: Either an `ISnap` or the source branch for a snap
745+ package.
746+ :param logger: An optional logger.
747+
748+ :return: The package's parsed snapcraft.yaml.
749+ :raises NotFoundError: if this package has no snapcraft.yaml.
750+ :raises CannotFetchSnapcraftYaml: if it was not possible to fetch
751+ snapcraft.yaml from the code hosting backend for some other
752+ reason.
753+ :raises CannotParseSnapcraftYaml: if the fetched snapcraft.yaml
754+ cannot be parsed.
755+ """
756+
757 def makeAutoBuilds(logger=None):
758 """Create and return automatic builds for stale snap packages.
759
760
761=== modified file 'lib/lp/snappy/model/snap.py'
762--- lib/lp/snappy/model/snap.py 2018-04-21 10:01:22 +0000
763+++ lib/lp/snappy/model/snap.py 2018-06-14 17:09:31 +0000
764@@ -32,6 +32,7 @@
765 Storm,
766 Unicode,
767 )
768+import yaml
769 from zope.component import (
770 getAdapter,
771 getUtility,
772@@ -42,7 +43,10 @@
773
774 from lp.app.browser.tales import DateTimeFormatterAPI
775 from lp.app.enums import PRIVATE_INFORMATION_TYPES
776-from lp.app.errors import IncompatibleArguments
777+from lp.app.errors import (
778+ IncompatibleArguments,
779+ NotFoundError,
780+ )
781 from lp.app.interfaces.security import IAuthorization
782 from lp.buildmaster.enums import BuildStatus
783 from lp.buildmaster.interfaces.buildqueue import IBuildQueueSet
784@@ -50,6 +54,12 @@
785 from lp.buildmaster.model.buildfarmjob import BuildFarmJob
786 from lp.buildmaster.model.buildqueue import BuildQueue
787 from lp.buildmaster.model.processor import Processor
788+from lp.code.errors import (
789+ BranchFileNotFound,
790+ BranchHostingFault,
791+ GitRepositoryBlobNotFound,
792+ GitRepositoryScanFault,
793+ )
794 from lp.code.interfaces.branch import IBranch
795 from lp.code.interfaces.branchcollection import (
796 IAllBranches,
797@@ -110,7 +120,9 @@
798 BadSnapSearchContext,
799 BadSnapSource,
800 CannotAuthorizeStoreUploads,
801+ CannotFetchSnapcraftYaml,
802 CannotModifySnapProcessor,
803+ CannotParseSnapcraftYaml,
804 CannotRequestAutoBuilds,
805 DuplicateSnapName,
806 ISnap,
807@@ -923,6 +935,52 @@
808 list(getUtility(IPersonSet).getPrecachedPersonsFromIDs(
809 person_ids, need_validity=True))
810
811+ def getSnapcraftYaml(self, context, logger=None):
812+ """See `ISnapSet`."""
813+ if ISnap.providedBy(context):
814+ context = context.source
815+ try:
816+ paths = (
817+ "snap/snapcraft.yaml",
818+ "snapcraft.yaml",
819+ ".snapcraft.yaml",
820+ )
821+ for path in paths:
822+ try:
823+ if IBranch.providedBy(context):
824+ blob = context.getBlob(path)
825+ else:
826+ blob = context.repository.getBlob(path, context.name)
827+ break
828+ except (BranchFileNotFound, GitRepositoryBlobNotFound):
829+ pass
830+ else:
831+ msg = "Cannot find snapcraft.yaml in %s"
832+ if logger is not None:
833+ logger.exception(msg, context.unique_name)
834+ raise NotFoundError(msg % context.unique_name)
835+ except (BranchHostingFault, GitRepositoryScanFault) as e:
836+ msg = "Failed to get snap manifest from %s"
837+ if logger is not None:
838+ logger.exception(msg, context.unique_name)
839+ raise CannotFetchSnapcraftYaml(
840+ "%s: %s" % (msg % context.unique_name, e))
841+
842+ try:
843+ snapcraft_data = yaml.safe_load(blob)
844+ except Exception as e:
845+ # Don't bother logging parsing errors from user-supplied YAML.
846+ raise CannotParseSnapcraftYaml(
847+ "Cannot parse snapcraft.yaml from %s: %s" %
848+ (context.unique_name, e))
849+
850+ if not isinstance(snapcraft_data, dict):
851+ raise CannotParseSnapcraftYaml(
852+ "The top level of snapcraft.yaml from %s is not a mapping" %
853+ context.unique_name)
854+
855+ return snapcraft_data
856+
857 @staticmethod
858 def _findStaleSnaps():
859 """See `ISnapSet`."""
860
861=== modified file 'lib/lp/snappy/tests/test_snap.py'
862--- lib/lp/snappy/tests/test_snap.py 2018-05-31 10:23:03 +0000
863+++ lib/lp/snappy/tests/test_snap.py 2018-06-14 17:09:31 +0000
864@@ -14,6 +14,7 @@
865 import json
866 from urlparse import urlsplit
867
868+from fixtures import MockPatch
869 from lazr.lifecycle.event import ObjectModifiedEvent
870 from pymacaroons import Macaroon
871 import pytz
872@@ -43,6 +44,16 @@
873 from lp.buildmaster.interfaces.processor import IProcessorSet
874 from lp.buildmaster.model.buildfarmjob import BuildFarmJob
875 from lp.buildmaster.model.buildqueue import BuildQueue
876+from lp.code.errors import (
877+ BranchFileNotFound,
878+ BranchHostingFault,
879+ GitRepositoryBlobNotFound,
880+ GitRepositoryScanFault,
881+ )
882+from lp.code.tests.helpers import (
883+ BranchHostingFixture,
884+ GitHostingFixture,
885+ )
886 from lp.registry.enums import PersonVisibility
887 from lp.registry.interfaces.distribution import IDistributionSet
888 from lp.registry.interfaces.pocket import PackagePublishingPocket
889@@ -62,7 +73,9 @@
890 from lp.services.webapp.interfaces import OAuthPermission
891 from lp.snappy.interfaces.snap import (
892 BadSnapSearchContext,
893+ CannotFetchSnapcraftYaml,
894 CannotModifySnapProcessor,
895+ CannotParseSnapcraftYaml,
896 ISnap,
897 ISnapSet,
898 ISnapView,
899@@ -975,6 +988,145 @@
900 [snaps[0], snaps[2], snaps[4], snaps[6]],
901 getUtility(ISnapSet).findByURLPrefixes(prefixes, owner=owners[0]))
902
903+ def test_getSnapcraftYaml_bzr_snap_snapcraft_yaml(self):
904+ def getInventory(unique_name, dirname, *args, **kwargs):
905+ if dirname == "snap":
906+ return {"filelist": [{
907+ "filename": "snapcraft.yaml", "file_id": "some-file-id",
908+ }]}
909+ else:
910+ raise BranchFileNotFound("dummy", dirname)
911+
912+ self.useFixture(BranchHostingFixture(
913+ blob=b"name: test-snap")).getInventory = getInventory
914+ branch = self.factory.makeBranch()
915+ self.assertEqual(
916+ {"name": "test-snap"},
917+ getUtility(ISnapSet).getSnapcraftYaml(branch))
918+
919+ def test_getSnapcraftYaml_bzr_plain_snapcraft_yaml(self):
920+ def getInventory(unique_name, dirname, *args, **kwargs):
921+ if dirname == "":
922+ return {"filelist": [{
923+ "filename": "snapcraft.yaml", "file_id": "some-file-id",
924+ }]}
925+ else:
926+ raise BranchFileNotFound("dummy", dirname)
927+
928+ self.useFixture(BranchHostingFixture(
929+ blob=b"name: test-snap")).getInventory = getInventory
930+ branch = self.factory.makeBranch()
931+ self.assertEqual(
932+ {"name": "test-snap"},
933+ getUtility(ISnapSet).getSnapcraftYaml(branch))
934+
935+ def test_getSnapcraftYaml_bzr_dot_snapcraft_yaml(self):
936+ def getInventory(unique_name, dirname, *args, **kwargs):
937+ if dirname == "":
938+ return {"filelist": [{
939+ "filename": ".snapcraft.yaml", "file_id": "some-file-id",
940+ }]}
941+ else:
942+ raise BranchFileNotFound("dummy", dirname)
943+
944+ self.useFixture(BranchHostingFixture(
945+ blob=b"name: test-snap")).getInventory = getInventory
946+ branch = self.factory.makeBranch()
947+ self.assertEqual(
948+ {"name": "test-snap"},
949+ getUtility(ISnapSet).getSnapcraftYaml(branch))
950+
951+ def test_getSnapcraftYaml_bzr_error(self):
952+ self.useFixture(BranchHostingFixture()).getInventory = FakeMethod(
953+ failure=BranchHostingFault)
954+ branch = self.factory.makeBranch()
955+ self.assertRaises(
956+ CannotFetchSnapcraftYaml,
957+ getUtility(ISnapSet).getSnapcraftYaml, branch)
958+
959+ def test_getSnapcraftYaml_git_snap_snapcraft_yaml(self):
960+ def getBlob(path, filename, *args, **kwargs):
961+ if filename == "snap/snapcraft.yaml":
962+ return b"name: test-snap"
963+ else:
964+ raise GitRepositoryBlobNotFound("dummy", filename)
965+
966+ self.useFixture(GitHostingFixture()).getBlob = getBlob
967+ [git_ref] = self.factory.makeGitRefs()
968+ self.assertEqual(
969+ {"name": "test-snap"},
970+ getUtility(ISnapSet).getSnapcraftYaml(git_ref))
971+
972+ def test_getSnapcraftYaml_git_plain_snapcraft_yaml(self):
973+ def getBlob(path, filename, *args, **kwargs):
974+ if filename == "snapcraft.yaml":
975+ return b"name: test-snap"
976+ else:
977+ raise GitRepositoryBlobNotFound("dummy", filename)
978+
979+ self.useFixture(GitHostingFixture()).getBlob = getBlob
980+ [git_ref] = self.factory.makeGitRefs()
981+ self.assertEqual(
982+ {"name": "test-snap"},
983+ getUtility(ISnapSet).getSnapcraftYaml(git_ref))
984+
985+ def test_getSnapcraftYaml_git_dot_snapcraft_yaml(self):
986+ def getBlob(path, filename, *args, **kwargs):
987+ if filename == ".snapcraft.yaml":
988+ return b"name: test-snap"
989+ else:
990+ raise GitRepositoryBlobNotFound("dummy", filename)
991+
992+ self.useFixture(GitHostingFixture()).getBlob = getBlob
993+ [git_ref] = self.factory.makeGitRefs()
994+ self.assertEqual(
995+ {"name": "test-snap"},
996+ getUtility(ISnapSet).getSnapcraftYaml(git_ref))
997+
998+ def test_getSnapcraftYaml_git_error(self):
999+ self.useFixture(GitHostingFixture()).getBlob = FakeMethod(
1000+ failure=GitRepositoryScanFault)
1001+ [git_ref] = self.factory.makeGitRefs()
1002+ self.assertRaises(
1003+ CannotFetchSnapcraftYaml,
1004+ getUtility(ISnapSet).getSnapcraftYaml, git_ref)
1005+
1006+ def test_getSnapcraftYaml_snap_bzr(self):
1007+ self.useFixture(BranchHostingFixture(
1008+ file_list={"snapcraft.yaml": "some-file-id"},
1009+ blob=b"name: test-snap"))
1010+ branch = self.factory.makeBranch()
1011+ snap = self.factory.makeSnap(branch=branch)
1012+ self.assertEqual(
1013+ {"name": "test-snap"}, getUtility(ISnapSet).getSnapcraftYaml(snap))
1014+
1015+ def test_getSnapcraftYaml_snap_git(self):
1016+ self.useFixture(GitHostingFixture(blob=b"name: test-snap"))
1017+ [git_ref] = self.factory.makeGitRefs()
1018+ snap = self.factory.makeSnap(git_ref=git_ref)
1019+ self.assertEqual(
1020+ {"name": "test-snap"}, getUtility(ISnapSet).getSnapcraftYaml(snap))
1021+
1022+ def test_getSnapcraftYaml_invalid_data(self):
1023+ hosting_fixture = self.useFixture(GitHostingFixture())
1024+ for invalid_result in (None, 123, b"", b"[][]", b"#name:test", b"]"):
1025+ [git_ref] = self.factory.makeGitRefs()
1026+ hosting_fixture.getBlob = FakeMethod(result=invalid_result)
1027+ self.assertRaises(
1028+ CannotParseSnapcraftYaml,
1029+ getUtility(ISnapSet).getSnapcraftYaml, git_ref)
1030+
1031+ def test_getSnapcraftYaml_safe_yaml(self):
1032+ self.useFixture(GitHostingFixture(blob=b"Malicious YAML!"))
1033+ [git_ref] = self.factory.makeGitRefs()
1034+ unsafe_load = self.useFixture(MockPatch("yaml.load"))
1035+ safe_load = self.useFixture(MockPatch("yaml.safe_load"))
1036+ self.assertRaises(
1037+ CannotParseSnapcraftYaml,
1038+ getUtility(ISnapSet).getSnapcraftYaml, git_ref)
1039+ self.assertEqual(0, unsafe_load.mock.call_count)
1040+ self.assertEqual(1, safe_load.mock.call_count)
1041+
1042 def test__findStaleSnaps(self):
1043 # Stale; not built automatically.
1044 self.factory.makeSnap(is_stale=True)