Merge lp:~cjwatson/launchpad/snap-channels-branch into lp:launchpad
- snap-channels-branch
- Merge into devel
Proposed by
Colin Watson
Status: | Merged | ||||
---|---|---|---|---|---|
Merged at revision: | 18635 | ||||
Proposed branch: | lp:~cjwatson/launchpad/snap-channels-branch | ||||
Merge into: | lp:launchpad | ||||
Diff against target: |
632 lines (+285/-65) 7 files modified
lib/lp/snappy/browser/tests/test_snap.py (+3/-2) lib/lp/snappy/browser/widgets/storechannels.py (+46/-23) lib/lp/snappy/browser/widgets/templates/storechannels.pt (+23/-2) lib/lp/snappy/browser/widgets/tests/test_storechannelswidget.py (+117/-22) lib/lp/snappy/interfaces/snap.py (+3/-1) lib/lp/snappy/validators/channels.py (+44/-7) lib/lp/snappy/validators/tests/test_channels.py (+49/-8) |
||||
To merge this branch: | bzr merge lp:~cjwatson/launchpad/snap-channels-branch | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
William Grant (community) | code | Approve | |
Review via email: mp+345171@code.launchpad.net |
Commit message
Allow branches in snap store channel names.
Description of the change
These were formerly disallowed except in stable, so didn't make much sense here when channels support was first implemented, but they're fine now.
To post a comment you must log in.
Revision history for this message
William Grant (wgrant) : | # |
review:
Approve
(code)
Revision history for this message
Colin Watson (cjwatson) : | # |
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'lib/lp/snappy/browser/tests/test_snap.py' |
2 | --- lib/lp/snappy/browser/tests/test_snap.py 2018-04-21 10:15:26 +0000 |
3 | +++ lib/lp/snappy/browser/tests/test_snap.py 2018-05-08 16:48:28 +0000 |
4 | @@ -1410,9 +1410,10 @@ |
5 | |
6 | def test_store_channels_display(self): |
7 | snap = self.factory.makeSnap( |
8 | - store_channels=["track/stable", "track/edge"]) |
9 | + store_channels=["track/stable/fix-123", "track/edge/fix-123"]) |
10 | view = create_initialized_view(snap, "+index") |
11 | - self.assertEqual("track/stable, track/edge", view.store_channels) |
12 | + self.assertEqual( |
13 | + "track/stable/fix-123, track/edge/fix-123", view.store_channels) |
14 | |
15 | |
16 | class TestSnapRequestBuildsView(BaseTestSnapView): |
17 | |
18 | === modified file 'lib/lp/snappy/browser/widgets/storechannels.py' |
19 | --- lib/lp/snappy/browser/widgets/storechannels.py 2017-03-31 14:44:33 +0000 |
20 | +++ lib/lp/snappy/browser/widgets/storechannels.py 2018-05-08 16:48:28 +0000 |
21 | @@ -1,4 +1,4 @@ |
22 | -# Copyright 2017 Canonical Ltd. This software is licensed under the |
23 | +# Copyright 2017-2018 Canonical Ltd. This software is licensed under the |
24 | # GNU Affero General Public License version 3 (see the file LICENSE). |
25 | |
26 | __metaclass__ = type |
27 | @@ -8,7 +8,10 @@ |
28 | ] |
29 | |
30 | from z3c.ptcompat import ViewPageTemplateFile |
31 | -from zope.formlib.interfaces import IInputWidget, WidgetInputError |
32 | +from zope.formlib.interfaces import ( |
33 | + IInputWidget, |
34 | + WidgetInputError, |
35 | + ) |
36 | from zope.formlib.utility import setUpWidget |
37 | from zope.formlib.widget import ( |
38 | BrowserWidget, |
39 | @@ -55,16 +58,22 @@ |
40 | if self._widgets_set_up: |
41 | return |
42 | fields = [ |
43 | - TextLine(__name__="track", title=u"Track", required=False, |
44 | - description=_( |
45 | - "Track defines a series for your software. " |
46 | - "If not specified, the default track ('latest') is " |
47 | - "assumed.") |
48 | - ), |
49 | - List(__name__="risks", title=u"Risk", required=False, |
50 | - value_type=Choice(vocabulary="SnapStoreChannel"), |
51 | - description=_( |
52 | - "Risks denote the stability of your software.")), |
53 | + TextLine( |
54 | + __name__="track", title=u"Track", required=False, |
55 | + description=_( |
56 | + "Track defines a series for your software. " |
57 | + "If not specified, the default track ('latest') is " |
58 | + "assumed.")), |
59 | + List( |
60 | + __name__="risks", title=u"Risk", required=False, |
61 | + value_type=Choice(vocabulary="SnapStoreChannel"), |
62 | + description=_("Risks denote the stability of your software.")), |
63 | + TextLine( |
64 | + __name__="branch", title=u"Branch", required=False, |
65 | + description=_( |
66 | + "Branches provide users with an easy way to test bug " |
67 | + "fixes. They are temporary and created on demand. If " |
68 | + "not specified, no branch is used.")), |
69 | ] |
70 | |
71 | self.risks_widget = CustomWidgetFactory(LabeledMultiCheckBoxWidget) |
72 | @@ -79,40 +88,48 @@ |
73 | risks_widget = getattr(self, 'risks_widget', None) |
74 | return risks_widget and bool(risks_widget.vocabulary) |
75 | |
76 | - def buildChannelName(self, track, risk): |
77 | - """Return channel name composed from given track and risk.""" |
78 | + def buildChannelName(self, track, risk, branch): |
79 | + """Return channel name composed from given track, risk, and branch.""" |
80 | channel = risk |
81 | if track and track != self._default_track: |
82 | - channel = track + self._separator + risk |
83 | + channel = self._separator.join((track, channel)) |
84 | + if branch: |
85 | + channel = self._separator.join((channel, branch)) |
86 | return channel |
87 | |
88 | def splitChannelName(self, channel): |
89 | - """Return extracted track and risk from given channel name.""" |
90 | + """Return extracted track, risk, and branch from given channel name.""" |
91 | try: |
92 | - track, risk = split_channel_name(channel) |
93 | + track, risk, branch = split_channel_name(channel) |
94 | except ValueError: |
95 | raise AssertionError("Not a valid value: %r" % channel) |
96 | - return track, risk |
97 | + return track, risk, branch |
98 | |
99 | def setRenderedValue(self, value): |
100 | """See `IWidget`.""" |
101 | self.setUpSubWidgets() |
102 | if value: |
103 | - # NOTE: atm target channels must belong to the same track |
104 | + # NOTE: atm target channels must belong to the same track and |
105 | + # branch |
106 | tracks = set() |
107 | + branches = set() |
108 | risks = [] |
109 | for channel in value: |
110 | - track, risk = self.splitChannelName(channel) |
111 | + track, risk, branch = self.splitChannelName(channel) |
112 | tracks.add(track) |
113 | risks.append(risk) |
114 | - if len(tracks) != 1: |
115 | + branches.add(branch) |
116 | + if len(tracks) != 1 or len(branches) != 1: |
117 | raise AssertionError("Not a valid value: %r" % value) |
118 | track = tracks.pop() |
119 | self.track_widget.setRenderedValue(track) |
120 | self.risks_widget.setRenderedValue(risks) |
121 | + branch = branches.pop() |
122 | + self.branch_widget.setRenderedValue(branch) |
123 | else: |
124 | self.track_widget.setRenderedValue(None) |
125 | self.risks_widget.setRenderedValue(None) |
126 | + self.branch_widget.setRenderedValue(None) |
127 | |
128 | def hasInput(self): |
129 | """See `IInputWidget`.""" |
130 | @@ -129,13 +146,19 @@ |
131 | def getInputValue(self): |
132 | """See `IInputWidget`.""" |
133 | self.setUpSubWidgets() |
134 | + track = self.track_widget.getInputValue() |
135 | risks = self.risks_widget.getInputValue() |
136 | - track = self.track_widget.getInputValue() |
137 | + branch = self.branch_widget.getInputValue() |
138 | if track and self._separator in track: |
139 | error_msg = "Track name cannot include '%s'." % self._separator |
140 | raise WidgetInputError( |
141 | self.name, self.label, LaunchpadValidationError(error_msg)) |
142 | - channels = [self.buildChannelName(track, risk) for risk in risks] |
143 | + if branch and self._separator in branch: |
144 | + error_msg = "Branch name cannot include '%s'." % self._separator |
145 | + raise WidgetInputError( |
146 | + self.name, self.label, LaunchpadValidationError(error_msg)) |
147 | + channels = [ |
148 | + self.buildChannelName(track, risk, branch) for risk in risks] |
149 | return channels |
150 | |
151 | def error(self): |
152 | |
153 | === modified file 'lib/lp/snappy/browser/widgets/templates/storechannels.pt' |
154 | --- lib/lp/snappy/browser/widgets/templates/storechannels.pt 2017-03-31 17:10:14 +0000 |
155 | +++ lib/lp/snappy/browser/widgets/templates/storechannels.pt 2018-05-08 16:48:28 +0000 |
156 | @@ -1,8 +1,19 @@ |
157 | +<tal:root |
158 | + xmlns:tal="http://xml.zope.org/namespaces/tal" |
159 | + xmlns:metal="http://xml.zope.org/namespaces/metal" |
160 | + omit-tag=""> |
161 | + |
162 | <table> |
163 | <tr> |
164 | <td> |
165 | - <p class="formHelp">Channels to release this snap package to after uploading it to the store. |
166 | - A channel is defined by a combination of an optional track and a risk, e.g. '2.1/stable', or 'stable'. |
167 | + <p class="formHelp"> |
168 | + Channels to release this snap package to after uploading it to the |
169 | + store. A channel is defined by a combination of an optional track, |
170 | + a risk, and an optional branch, e.g. '2.1/stable/fix-123', |
171 | + '2.1/stable', 'stable/fix-123', or 'stable'. |
172 | + <a href="https://docs.snapcraft.io/reference/channels" |
173 | + target="_blank" |
174 | + class="sprite maybe action-icon">(?)</a> |
175 | </p> |
176 | </td> |
177 | </tr> |
178 | @@ -29,7 +40,17 @@ |
179 | </p> |
180 | </td> |
181 | </tr> |
182 | + <tr> |
183 | + <td> |
184 | + <tal:widget define="widget nocall:view/branch_widget"> |
185 | + <metal:block |
186 | + use-macro="context/@@launchpad_widget_macros/launchpad_widget_row" /> |
187 | + </tal:widget> |
188 | + </td> |
189 | + </tr> |
190 | </table> |
191 | </td> |
192 | </tr> |
193 | </table> |
194 | + |
195 | +</tal:root> |
196 | |
197 | === modified file 'lib/lp/snappy/browser/widgets/tests/test_storechannelswidget.py' |
198 | --- lib/lp/snappy/browser/widgets/tests/test_storechannelswidget.py 2017-11-10 11:28:43 +0000 |
199 | +++ lib/lp/snappy/browser/widgets/tests/test_storechannelswidget.py 2018-05-08 16:48:28 +0000 |
200 | @@ -1,4 +1,4 @@ |
201 | -# Copyright 2017 Canonical Ltd. This software is licensed under the |
202 | +# Copyright 2017-2018 Canonical Ltd. This software is licensed under the |
203 | # GNU Affero General Public License version 3 (see the file LICENSE). |
204 | |
205 | from __future__ import absolute_import, print_function, unicode_literals |
206 | @@ -71,6 +71,7 @@ |
207 | self.assertIsInstance( |
208 | self.widget.risks_widget.vocabulary, SnapStoreChannelVocabulary) |
209 | self.assertTrue(self.widget.has_risks_vocabulary) |
210 | + self.assertIsNotNone(getattr(self.widget, "branch_widget", None)) |
211 | |
212 | def test_setUpSubWidgets_second_call(self): |
213 | # The setUpSubWidgets method exits early if a flag is set to |
214 | @@ -79,37 +80,63 @@ |
215 | self.widget.setUpSubWidgets() |
216 | self.assertIsNone(getattr(self.widget, "track_widget", None)) |
217 | self.assertIsNone(getattr(self.widget, "risks_widget", None)) |
218 | + self.assertIsNone(getattr(self.widget, "branch_widget", None)) |
219 | self.assertIsNone(self.widget.has_risks_vocabulary) |
220 | |
221 | - def test_buildChannelName_no_track(self): |
222 | - self.assertEqual("edge", self.widget.buildChannelName(None, "edge")) |
223 | + def test_buildChannelName_no_track_or_branch(self): |
224 | + self.assertEqual( |
225 | + "edge", self.widget.buildChannelName(None, "edge", None)) |
226 | |
227 | def test_buildChannelName_with_track(self): |
228 | self.assertEqual( |
229 | - "track/edge", self.widget.buildChannelName("track", "edge")) |
230 | - |
231 | - def test_splitChannelName_no_track(self): |
232 | - self.assertEqual((None, "edge"), self.widget.splitChannelName("edge")) |
233 | + "track/edge", self.widget.buildChannelName("track", "edge", None)) |
234 | + |
235 | + def test_buildChannelName_with_branch(self): |
236 | + self.assertEqual( |
237 | + "edge/fix-123", |
238 | + self.widget.buildChannelName(None, "edge", "fix-123")) |
239 | + |
240 | + def test_buildChannelName_with_track_and_branch(self): |
241 | + self.assertEqual( |
242 | + "track/edge/fix-123", |
243 | + self.widget.buildChannelName("track", "edge", "fix-123")) |
244 | + |
245 | + def test_splitChannelName_no_track_or_branch(self): |
246 | + self.assertEqual( |
247 | + (None, "edge", None), self.widget.splitChannelName("edge")) |
248 | |
249 | def test_splitChannelName_with_track(self): |
250 | self.assertEqual( |
251 | - ("track", "edge"), self.widget.splitChannelName("track/edge")) |
252 | + ("track", "edge", None), |
253 | + self.widget.splitChannelName("track/edge")) |
254 | + |
255 | + def test_splitChannelName_with_branch(self): |
256 | + self.assertEqual( |
257 | + (None, "edge", "fix-123"), |
258 | + self.widget.splitChannelName("edge/fix-123")) |
259 | + |
260 | + def test_splitChannelName_with_track_and_branch(self): |
261 | + self.assertEqual( |
262 | + ("track", "edge", "fix-123"), |
263 | + self.widget.splitChannelName("track/edge/fix-123")) |
264 | |
265 | def test_splitChannelName_invalid(self): |
266 | self.assertRaises( |
267 | - AssertionError, self.widget.splitChannelName, "track/edge/invalid") |
268 | + AssertionError, self.widget.splitChannelName, |
269 | + "track/edge/invalid/too-long") |
270 | |
271 | def test_setRenderedValue_empty(self): |
272 | self.widget.setRenderedValue([]) |
273 | self.assertIsNone(self.widget.track_widget._getCurrentValue()) |
274 | self.assertIsNone(self.widget.risks_widget._getCurrentValue()) |
275 | |
276 | - def test_setRenderedValue_no_track(self): |
277 | - # Channels do not include a track |
278 | + def test_setRenderedValue_no_track_or_branch(self): |
279 | + # Channels do not include a track or branch |
280 | risks = ['candidate', 'edge'] |
281 | self.widget.setRenderedValue(risks) |
282 | self.assertIsNone(self.widget.track_widget._getCurrentValue()) |
283 | self.assertEqual(risks, self.widget.risks_widget._getCurrentValue()) |
284 | + self.assertIsNone(self.widget.branch_widget._getCurrentValue()) |
285 | |
286 | def test_setRenderedValue_with_track(self): |
287 | # Channels including a track |
288 | @@ -118,12 +145,39 @@ |
289 | self.assertEqual('2.2', self.widget.track_widget._getCurrentValue()) |
290 | self.assertEqual( |
291 | ['candidate', 'edge'], self.widget.risks_widget._getCurrentValue()) |
292 | + self.assertIsNone(self.widget.branch_widget._getCurrentValue()) |
293 | + |
294 | + def test_setRenderedValue_with_branch(self): |
295 | + # Channels including a branch |
296 | + channels = ['candidate/fix-123', 'edge/fix-123'] |
297 | + self.widget.setRenderedValue(channels) |
298 | + self.assertIsNone(self.widget.track_widget._getCurrentValue()) |
299 | + self.assertEqual( |
300 | + ['candidate', 'edge'], self.widget.risks_widget._getCurrentValue()) |
301 | + self.assertEqual( |
302 | + 'fix-123', self.widget.branch_widget._getCurrentValue()) |
303 | + |
304 | + def test_setRenderedValue_with_track_and_branch(self): |
305 | + # Channels including a track and branch |
306 | + channels = ['2.2/candidate/fix-123', '2.2/edge/fix-123'] |
307 | + self.widget.setRenderedValue(channels) |
308 | + self.assertEqual('2.2', self.widget.track_widget._getCurrentValue()) |
309 | + self.assertEqual( |
310 | + ['candidate', 'edge'], self.widget.risks_widget._getCurrentValue()) |
311 | + self.assertEqual( |
312 | + 'fix-123', self.widget.branch_widget._getCurrentValue()) |
313 | |
314 | def test_setRenderedValue_invalid_value(self): |
315 | - # Multiple channels, different tracks, unsupported |
316 | - channels = ['2.2/candidate', '2.1/edge'] |
317 | - self.assertRaises( |
318 | - AssertionError, self.widget.setRenderedValue, channels) |
319 | + # Multiple channels, different tracks or branches, unsupported |
320 | + self.assertRaises( |
321 | + AssertionError, self.widget.setRenderedValue, |
322 | + ['2.2/candidate', '2.1/edge']) |
323 | + self.assertRaises( |
324 | + AssertionError, self.widget.setRenderedValue, |
325 | + ['candidate/fix-123', 'edge/fix-124']) |
326 | + self.assertRaises( |
327 | + AssertionError, self.widget.setRenderedValue, |
328 | + ['2.2/candidate', 'edge/fix-123']) |
329 | |
330 | def test_hasInput_false(self): |
331 | # hasInput is false when there is no risk set in the form data. |
332 | @@ -143,6 +197,7 @@ |
333 | form = { |
334 | "field.channels.track": "", |
335 | "field.channels.risks": ["invalid"], |
336 | + "field.channels.branch": "", |
337 | } |
338 | self.widget.request = LaunchpadTestRequest(form=form) |
339 | self.assertFalse(self.widget.hasValidInput()) |
340 | @@ -152,6 +207,7 @@ |
341 | form = { |
342 | "field.channels.track": "track", |
343 | "field.channels.risks": ["stable", "beta"], |
344 | + "field.channels.branch": "branch", |
345 | } |
346 | self.widget.request = LaunchpadTestRequest(form=form) |
347 | self.assertTrue(self.widget.hasValidInput()) |
348 | @@ -164,24 +220,62 @@ |
349 | |
350 | def test_getInputValue_invalid_track(self): |
351 | # An error is raised when the track includes a '/'. |
352 | - form = {"field.channels.track": "tra/ck", |
353 | - "field.channels.risks": ["beta"]} |
354 | + form = { |
355 | + "field.channels.track": "tra/ck", |
356 | + "field.channels.risks": ["beta"], |
357 | + "field.channels.branch": "", |
358 | + } |
359 | self.assertGetInputValueError(form, "Track name cannot include '/'.") |
360 | |
361 | - def test_getInputValue_no_track(self): |
362 | + def test_getInputValue_invalid_branch(self): |
363 | + # An error is raised when the branch includes a '/'. |
364 | + form = { |
365 | + "field.channels.track": "", |
366 | + "field.channels.risks": ["beta"], |
367 | + "field.channels.branch": "bra/nch", |
368 | + } |
369 | + self.assertGetInputValueError(form, "Branch name cannot include '/'.") |
370 | + |
371 | + def test_getInputValue_no_track_or_branch(self): |
372 | self.widget.request = LaunchpadTestRequest( |
373 | - form={"field.channels.track": "", |
374 | - "field.channels.risks": ["beta", "edge"]}) |
375 | + form={ |
376 | + "field.channels.track": "", |
377 | + "field.channels.risks": ["beta", "edge"], |
378 | + "field.channels.branch": "", |
379 | + }) |
380 | expected = ["beta", "edge"] |
381 | self.assertEqual(expected, self.widget.getInputValue()) |
382 | |
383 | def test_getInputValue_with_track(self): |
384 | self.widget.request = LaunchpadTestRequest( |
385 | - form={"field.channels.track": "track", |
386 | - "field.channels.risks": ["beta", "edge"]}) |
387 | + form={ |
388 | + "field.channels.track": "track", |
389 | + "field.channels.risks": ["beta", "edge"], |
390 | + "field.channels.branch": "", |
391 | + }) |
392 | expected = ["track/beta", "track/edge"] |
393 | self.assertEqual(expected, self.widget.getInputValue()) |
394 | |
395 | + def test_getInputValue_with_branch(self): |
396 | + self.widget.request = LaunchpadTestRequest( |
397 | + form={ |
398 | + "field.channels.track": "", |
399 | + "field.channels.risks": ["beta", "edge"], |
400 | + "field.channels.branch": "fix-123", |
401 | + }) |
402 | + expected = ["beta/fix-123", "edge/fix-123"] |
403 | + self.assertEqual(expected, self.widget.getInputValue()) |
404 | + |
405 | + def test_getInputValue_with_track_and_branch(self): |
406 | + self.widget.request = LaunchpadTestRequest( |
407 | + form={ |
408 | + "field.channels.track": "track", |
409 | + "field.channels.risks": ["beta", "edge"], |
410 | + "field.channels.branch": "fix-123", |
411 | + }) |
412 | + expected = ["track/beta/fix-123", "track/edge/fix-123"] |
413 | + self.assertEqual(expected, self.widget.getInputValue()) |
414 | + |
415 | def test_call(self): |
416 | # The __call__ method sets up the widgets. |
417 | markup = self.widget() |
418 | @@ -192,5 +286,6 @@ |
419 | expected_ids = [ |
420 | "field.channels.risks.%d" % i for i in range(len(self.risks))] |
421 | expected_ids.append("field.channels.track") |
422 | + expected_ids.append("field.channels.branch") |
423 | ids = [field["id"] for field in fields] |
424 | self.assertContentEqual(expected_ids, ids) |
425 | |
426 | === modified file 'lib/lp/snappy/interfaces/snap.py' |
427 | --- lib/lp/snappy/interfaces/snap.py 2018-04-21 10:01:22 +0000 |
428 | +++ lib/lp/snappy/interfaces/snap.py 2018-05-08 16:48:28 +0000 |
429 | @@ -583,7 +583,9 @@ |
430 | description=_( |
431 | "Channels to release this snap package to after uploading it to " |
432 | "the store. A channel is defined by a combination of an optional " |
433 | - " track and a risk, e.g. '2.1/stable', or 'stable'."))) |
434 | + " track, a risk, and an optional branch, e.g. " |
435 | + "'2.1/stable/fix-123', '2.1/stable', 'stable/fix-123', or " |
436 | + "'stable'."))) |
437 | |
438 | |
439 | class ISnapAdminAttributes(Interface): |
440 | |
441 | === modified file 'lib/lp/snappy/validators/channels.py' |
442 | --- lib/lp/snappy/validators/channels.py 2017-03-27 19:28:36 +0000 |
443 | +++ lib/lp/snappy/validators/channels.py 2018-05-08 16:48:28 +0000 |
444 | @@ -1,10 +1,12 @@ |
445 | -# Copyright 2017 Canonical Ltd. This software is licensed under the |
446 | +# Copyright 2017-2018 Canonical Ltd. This software is licensed under the |
447 | # GNU Affero General Public License version 3 (see the file LICENSE). |
448 | |
449 | """Validators for the .store_channels attribute.""" |
450 | |
451 | __metaclass__ = type |
452 | |
453 | +from zope.schema.vocabulary import getVocabularyRegistry |
454 | + |
455 | from lp import _ |
456 | from lp.app.validators import LaunchpadValidationError |
457 | from lp.services.webapp.escaping import ( |
458 | @@ -17,17 +19,42 @@ |
459 | channel_components_delimiter = '/' |
460 | |
461 | |
462 | +def _is_risk(component): |
463 | + """Does this channel component identify a risk?""" |
464 | + vocabulary = getVocabularyRegistry().get(None, "SnapStoreChannel") |
465 | + try: |
466 | + vocabulary.getTermByToken(component) |
467 | + except LookupError: |
468 | + return False |
469 | + else: |
470 | + return True |
471 | + |
472 | + |
473 | def split_channel_name(channel): |
474 | - """Return extracted track and risk from given channel name.""" |
475 | + """Return extracted track, risk, and branch from given channel name.""" |
476 | components = channel.split(channel_components_delimiter) |
477 | - if len(components) == 2: |
478 | - track, risk = components |
479 | + if len(components) == 3: |
480 | + track, risk, branch = components |
481 | + elif len(components) == 2: |
482 | + # Identify risk to determine if this is track/risk or risk/branch. |
483 | + if _is_risk(components[0]): |
484 | + if _is_risk(components[1]): |
485 | + raise ValueError( |
486 | + "Branch name cannot match a risk name: %r" % channel) |
487 | + track = None |
488 | + risk, branch = components |
489 | + elif _is_risk(components[1]): |
490 | + track, risk = components |
491 | + branch = None |
492 | + else: |
493 | + raise ValueError("No valid risk provided: %r" % channel) |
494 | elif len(components) == 1: |
495 | track = None |
496 | risk = components[0] |
497 | + branch = None |
498 | else: |
499 | raise ValueError("Invalid channel name: %r" % channel) |
500 | - return track, risk |
501 | + return track, risk, branch |
502 | |
503 | |
504 | def channels_validator(channels): |
505 | @@ -35,19 +62,29 @@ |
506 | LaunchpadValidationError. |
507 | """ |
508 | tracks = set() |
509 | + branches = set() |
510 | for name in channels: |
511 | try: |
512 | - track, risk = split_channel_name(name) |
513 | + track, risk, branch = split_channel_name(name) |
514 | except ValueError: |
515 | message = _( |
516 | "Invalid channel name '${name}'. Channel names must be of the " |
517 | - "form 'track/risk' or 'risk'.", |
518 | + "form 'track/risk/branch', 'track/risk', 'risk/branch', or " |
519 | + "'risk'.", |
520 | mapping={'name': html_escape(name)}) |
521 | raise LaunchpadValidationError(structured(message)) |
522 | tracks.add(track) |
523 | + branches.add(branch) |
524 | + |
525 | + # XXX cjwatson 2018-05-08: These are slightly arbitrary restrictions, |
526 | + # but they make the UI much simpler. |
527 | |
528 | if len(tracks) != 1: |
529 | message = _("Channels must belong to the same track.") |
530 | raise LaunchpadValidationError(structured(message)) |
531 | |
532 | + if len(branches) != 1: |
533 | + message = _("Channels must belong to the same branch.") |
534 | + raise LaunchpadValidationError(structured(message)) |
535 | + |
536 | return True |
537 | |
538 | === modified file 'lib/lp/snappy/validators/tests/test_channels.py' |
539 | --- lib/lp/snappy/validators/tests/test_channels.py 2017-10-20 13:35:42 +0000 |
540 | +++ lib/lp/snappy/validators/tests/test_channels.py 2018-05-08 16:48:28 +0000 |
541 | @@ -1,4 +1,4 @@ |
542 | -# Copyright 2017 Canonical Ltd. This software is licensed under the |
543 | +# Copyright 2017-2018 Canonical Ltd. This software is licensed under the |
544 | # GNU Affero General Public License version 3 (see the file LICENSE). |
545 | |
546 | from __future__ import absolute_import, print_function, unicode_literals |
547 | @@ -6,11 +6,14 @@ |
548 | __metaclass__ = type |
549 | |
550 | from lp.app.validators import LaunchpadValidationError |
551 | +from lp.snappy.interfaces.snapstoreclient import ISnapStoreClient |
552 | from lp.snappy.validators.channels import ( |
553 | channels_validator, |
554 | split_channel_name, |
555 | ) |
556 | from lp.testing import TestCaseWithFactory |
557 | +from lp.testing.fakemethod import FakeMethod |
558 | +from lp.testing.fixture import ZopeUtilityFixture |
559 | from lp.testing.layers import LaunchpadFunctionalLayer |
560 | |
561 | |
562 | @@ -18,17 +21,50 @@ |
563 | |
564 | layer = LaunchpadFunctionalLayer |
565 | |
566 | - def test_split_channel_name_no_track(self): |
567 | - self.assertEqual((None, "edge"), split_channel_name("edge")) |
568 | + def setUp(self): |
569 | + super(TestChannelsValidator, self).setUp() |
570 | + self.risks = [ |
571 | + {"name": "stable", "display_name": "Stable"}, |
572 | + {"name": "candidate", "display_name": "Candidate"}, |
573 | + {"name": "beta", "display_name": "Beta"}, |
574 | + {"name": "edge", "display_name": "Edge"}, |
575 | + ] |
576 | + snap_store_client = FakeMethod() |
577 | + snap_store_client.listChannels = FakeMethod(result=self.risks) |
578 | + self.useFixture( |
579 | + ZopeUtilityFixture(snap_store_client, ISnapStoreClient)) |
580 | + |
581 | + def test_split_channel_name_no_track_or_branch(self): |
582 | + self.assertEqual((None, "edge", None), split_channel_name("edge")) |
583 | |
584 | def test_split_channel_name_with_track(self): |
585 | - self.assertEqual(("track", "edge"), split_channel_name("track/edge")) |
586 | - |
587 | - def test_split_channel_name_invalid(self): |
588 | - self.assertRaises(ValueError, split_channel_name, "track/edge/invalid") |
589 | + self.assertEqual( |
590 | + ("track", "edge", None), split_channel_name("track/edge")) |
591 | + |
592 | + def test_split_channel_name_with_branch(self): |
593 | + self.assertEqual( |
594 | + (None, "edge", "fix-123"), split_channel_name("edge/fix-123")) |
595 | + |
596 | + def test_split_channel_name_with_track_and_branch(self): |
597 | + self.assertEqual( |
598 | + ("track", "edge", "fix-123"), |
599 | + split_channel_name("track/edge/fix-123")) |
600 | + |
601 | + def test_split_channel_name_no_risk(self): |
602 | + self.assertRaises(ValueError, split_channel_name, "track/fix-123") |
603 | + |
604 | + def test_split_channel_name_ambiguous_risk(self): |
605 | + self.assertRaises(ValueError, split_channel_name, "edge/stable") |
606 | + |
607 | + def test_split_channel_name_too_many_components(self): |
608 | + self.assertRaises( |
609 | + ValueError, split_channel_name, "track/edge/invalid/too-long") |
610 | |
611 | def test_channels_validator_valid(self): |
612 | + self.assertTrue( |
613 | + channels_validator(['1.1/beta/fix-123', '1.1/edge/fix-123'])) |
614 | self.assertTrue(channels_validator(['1.1/beta', '1.1/edge'])) |
615 | + self.assertTrue(channels_validator(['beta/fix-123', 'edge/fix-123'])) |
616 | self.assertTrue(channels_validator(['beta', 'edge'])) |
617 | |
618 | def test_channels_validator_multiple_tracks(self): |
619 | @@ -36,7 +72,12 @@ |
620 | LaunchpadValidationError, channels_validator, |
621 | ['1.1/stable', '2.1/edge']) |
622 | |
623 | + def test_channels_validator_multiple_branches(self): |
624 | + self.assertRaises( |
625 | + LaunchpadValidationError, channels_validator, |
626 | + ['stable/fix-123', 'edge/fix-124']) |
627 | + |
628 | def test_channels_validator_invalid_channel(self): |
629 | self.assertRaises( |
630 | LaunchpadValidationError, channels_validator, |
631 | - ['1.1/stable/invalid']) |
632 | + ['1.1/stable/invalid/too-long']) |