Merge lp:~cjwatson/launchpad/snap-parse-architectures into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 18718
Proposed branch: lp:~cjwatson/launchpad/snap-parse-architectures
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/snap-initial-name-bzr
Diff against target: 420 lines (+407/-0)
2 files modified
lib/lp/snappy/adapters/buildarch.py (+176/-0)
lib/lp/snappy/adapters/tests/test_buildarch.py (+231/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/snap-parse-architectures
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+347998@code.launchpad.net

Commit message

Implement determine_architectures_to_build for snaps.

Description of the change

This is based on https://github.com/kyrofa/snapcraft-architectures-parser, and is the first step towards full build-on architectures support. I'm still working on the code to actually use this, but it's far enough along for me to be confident that the interface is the right shape.

The code is quite significantly refactored from Kyle's version, to make it easier to fit into Launchpad.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added directory 'lib/lp/snappy/adapters'
2=== added file 'lib/lp/snappy/adapters/__init__.py'
3=== added file 'lib/lp/snappy/adapters/buildarch.py'
4--- lib/lp/snappy/adapters/buildarch.py 1970-01-01 00:00:00 +0000
5+++ lib/lp/snappy/adapters/buildarch.py 2018-06-14 17:14:47 +0000
6@@ -0,0 +1,176 @@
7+# Copyright 2018 Canonical Ltd. This software is licensed under the
8+# GNU Affero General Public License version 3 (see the file LICENSE).
9+
10+from __future__ import absolute_import, print_function, unicode_literals
11+
12+__metaclass__ = type
13+__all__ = [
14+ 'determine_architectures_to_build',
15+ ]
16+
17+from collections import Counter
18+
19+import six
20+
21+from lp.services.helpers import english_list
22+
23+
24+class SnapArchitecturesParserError(Exception):
25+ """Base class for all exceptions in this module."""
26+
27+
28+class MissingPropertyError(SnapArchitecturesParserError):
29+ """Error for when an expected property is not present in the YAML."""
30+
31+ def __init__(self, prop):
32+ super(MissingPropertyError, self).__init__(
33+ "Architecture specification is missing the {!r} property".format(
34+ prop))
35+ self.property = prop
36+
37+
38+class IncompatibleArchitecturesStyleError(SnapArchitecturesParserError):
39+ """Error for when architectures mix incompatible styles."""
40+
41+ def __init__(self):
42+ super(IncompatibleArchitecturesStyleError, self).__init__(
43+ "'architectures' must either be a list of strings or dicts, not "
44+ "both")
45+
46+
47+class DuplicateBuildOnError(SnapArchitecturesParserError):
48+ """Error for when multiple `build-on`s include the same architecture."""
49+
50+ def __init__(self, duplicates):
51+ super(DuplicateBuildOnError, self).__init__(
52+ "{} {} present in the 'build-on' of multiple items".format(
53+ english_list(duplicates),
54+ "is" if len(duplicates) == 1 else "are"))
55+
56+
57+class UnsupportedBuildOnError(SnapArchitecturesParserError):
58+ """Error for when a requested architecture is not supported."""
59+
60+ def __init__(self, build_on):
61+ super(UnsupportedBuildOnError, self).__init__(
62+ "build-on specifies no supported architectures: {!r}".format(
63+ build_on))
64+ self.build_on = build_on
65+
66+
67+class SnapArchitecture:
68+ """A single entry in the snapcraft.yaml 'architectures' list."""
69+
70+ def __init__(self, build_on, run_on=None, build_error=None):
71+ """Create a new architecture entry.
72+
73+ :param build_on: string or list; build-on property from
74+ snapcraft.yaml.
75+ :param run_on: string or list; run-on property from snapcraft.yaml
76+ (defaults to build_on).
77+ :param build_error: string; build-error property from
78+ snapcraft.yaml.
79+ """
80+ self.build_on = (
81+ [build_on] if isinstance(build_on, six.string_types) else build_on)
82+ if run_on:
83+ self.run_on = (
84+ [run_on] if isinstance(run_on, six.string_types) else run_on)
85+ else:
86+ self.run_on = self.build_on
87+ self.build_error = build_error
88+
89+ @classmethod
90+ def from_dict(cls, properties):
91+ """Create a new architecture entry from a dict."""
92+ try:
93+ build_on = properties["build-on"]
94+ except KeyError:
95+ raise MissingPropertyError("build-on")
96+
97+ return cls(
98+ build_on=build_on, run_on=properties.get("run-on"),
99+ build_error=properties.get("build-error"))
100+
101+
102+class SnapBuildInstance:
103+ """A single instance of a snap that should be built.
104+
105+ It has two useful attributes:
106+
107+ - architecture: The architecture tag that should be used to build the
108+ snap.
109+ - required: Whether or not failure to build should cause the entire
110+ set to fail.
111+ """
112+
113+ def __init__(self, architecture, supported_architectures):
114+ """Construct a new `SnapBuildInstance`.
115+
116+ :param architecture: `SnapArchitecture` instance.
117+ :param supported_architectures: List of supported architectures,
118+ sorted by priority.
119+ """
120+ try:
121+ self.architecture = next(
122+ arch for arch in supported_architectures
123+ if arch in architecture.build_on)
124+ except StopIteration:
125+ raise UnsupportedBuildOnError(architecture.build_on)
126+
127+ self.required = architecture.build_error != "ignore"
128+
129+
130+def determine_architectures_to_build(snapcraft_data, supported_arches):
131+ """Return a set of architectures to build based on snapcraft.yaml.
132+
133+ :param snapcraft_data: A parsed snapcraft.yaml.
134+ :param supported_arches: An ordered list of all architecture tags that
135+ we can create builds for.
136+ :return: a set of `SnapBuildInstance`s.
137+ """
138+ architectures_list = snapcraft_data.get("architectures")
139+
140+ if architectures_list:
141+ # First, determine what style we're parsing. Is it a list of
142+ # strings or a list of dicts?
143+ if all(isinstance(a, six.string_types) for a in architectures_list):
144+ # If a list of strings (old style), then that's only a single
145+ # item.
146+ architectures = [SnapArchitecture(build_on=architectures_list)]
147+ elif all(isinstance(arch, dict) for arch in architectures_list):
148+ # If a list of dicts (new style), then that's multiple items.
149+ architectures = [
150+ SnapArchitecture.from_dict(a) for a in architectures_list]
151+ else:
152+ # If a mix of both, bail. We can't reasonably handle it.
153+ raise IncompatibleArchitecturesStyleError()
154+ else:
155+ # If no architectures are specified, build one for each supported
156+ # architecture.
157+ architectures = [
158+ SnapArchitecture(build_on=a) for a in supported_arches]
159+
160+ # Ensure that multiple `build-on` items don't include the same
161+ # architecture; this is ambiguous and forbidden by snapcraft. Checking
162+ # this here means that we don't get duplicate supported_arch results
163+ # below.
164+ build_ons = Counter()
165+ for arch in architectures:
166+ build_ons.update(arch.build_on)
167+ duplicates = {arch for arch, count in build_ons.items() if count > 1}
168+ if duplicates:
169+ raise DuplicateBuildOnError(duplicates)
170+
171+ architectures_to_build = set()
172+ for arch in architectures:
173+ try:
174+ architectures_to_build.add(
175+ SnapBuildInstance(arch, supported_arches))
176+ except UnsupportedBuildOnError:
177+ # Snaps are allowed to declare that they build on architectures
178+ # that Launchpad doesn't currently support (perhaps they're
179+ # upcoming, or perhaps they used to be supported). We just
180+ # ignore those.
181+ pass
182+ return architectures_to_build
183
184=== added directory 'lib/lp/snappy/adapters/tests'
185=== added file 'lib/lp/snappy/adapters/tests/__init__.py'
186=== added file 'lib/lp/snappy/adapters/tests/test_buildarch.py'
187--- lib/lp/snappy/adapters/tests/test_buildarch.py 1970-01-01 00:00:00 +0000
188+++ lib/lp/snappy/adapters/tests/test_buildarch.py 2018-06-14 17:14:47 +0000
189@@ -0,0 +1,231 @@
190+# Copyright 2018 Canonical Ltd. This software is licensed under the
191+# GNU Affero General Public License version 3 (see the file LICENSE).
192+
193+from __future__ import absolute_import, print_function, unicode_literals
194+
195+__metaclass__ = type
196+
197+from testscenarios import (
198+ load_tests_apply_scenarios,
199+ WithScenarios,
200+ )
201+from testtools.matchers import HasLength
202+
203+from lp.snappy.adapters.buildarch import (
204+ determine_architectures_to_build,
205+ SnapArchitecture,
206+ SnapBuildInstance,
207+ UnsupportedBuildOnError,
208+ )
209+from lp.testing import TestCase
210+
211+
212+class TestSnapArchitecture(WithScenarios, TestCase):
213+
214+ scenarios = [
215+ ("lists", {
216+ "architectures": {"build-on": ["amd64"], "run-on": ["amd64"]},
217+ "expected_build_on": ["amd64"],
218+ "expected_run_on": ["amd64"],
219+ "expected_build_error": None,
220+ }),
221+ ("strings", {
222+ "architectures": {"build-on": "amd64", "run-on": "amd64"},
223+ "expected_build_on": ["amd64"],
224+ "expected_run_on": ["amd64"],
225+ "expected_build_error": None,
226+ }),
227+ ("no run-on", {
228+ "architectures": {"build-on": ["amd64"]},
229+ "expected_build_on": ["amd64"],
230+ "expected_run_on": ["amd64"],
231+ "expected_build_error": None,
232+ }),
233+ ("not required", {
234+ "architectures": {
235+ "build-on": ["amd64"],
236+ "run-on": "amd64",
237+ "build-error": "ignore"},
238+ "expected_build_on": ["amd64"],
239+ "expected_run_on": ["amd64"],
240+ "expected_build_error": "ignore",
241+ }),
242+ ]
243+
244+ def test_architecture(self):
245+ architecture = SnapArchitecture.from_dict(self.architectures)
246+ self.assertEqual(self.expected_build_on, architecture.build_on)
247+ self.assertEqual(self.expected_run_on, architecture.run_on)
248+ self.assertEqual(self.expected_build_error, architecture.build_error)
249+
250+
251+class TestSnapBuildInstance(WithScenarios, TestCase):
252+
253+ # Single-item scenarios taken from the architectures document:
254+ # https://forum.snapcraft.io/t/architectures/4972
255+ scenarios = [
256+ ("i386", {
257+ "architecture": SnapArchitecture(
258+ build_on="i386", run_on=["amd64", "i386"]),
259+ "supported_architectures": ["amd64", "i386", "armhf"],
260+ "expected_architecture": "i386",
261+ "expected_required": True,
262+ }),
263+ ("amd64", {
264+ "architecture": SnapArchitecture(build_on="amd64", run_on="all"),
265+ "supported_architectures": ["amd64", "i386", "armhf"],
266+ "expected_architecture": "amd64",
267+ "expected_required": True,
268+ }),
269+ ("amd64 priority", {
270+ "architecture": SnapArchitecture(
271+ build_on=["amd64", "i386"], run_on="all"),
272+ "supported_architectures": ["amd64", "i386", "armhf"],
273+ "expected_architecture": "amd64",
274+ "expected_required": True,
275+ }),
276+ ("i386 priority", {
277+ "architecture": SnapArchitecture(
278+ build_on=["amd64", "i386"], run_on="all"),
279+ "supported_architectures": ["i386", "amd64", "armhf"],
280+ "expected_architecture": "i386",
281+ "expected_required": True,
282+ }),
283+ ("optional", {
284+ "architecture": SnapArchitecture(
285+ build_on="amd64", build_error="ignore"),
286+ "supported_architectures": ["amd64", "i386", "armhf"],
287+ "expected_architecture": "amd64",
288+ "expected_required": False,
289+ }),
290+ ]
291+
292+ def test_build_instance(self):
293+ instance = SnapBuildInstance(
294+ self.architecture, self.supported_architectures)
295+ self.assertEqual(self.expected_architecture, instance.architecture)
296+ self.assertEqual(self.expected_required, instance.required)
297+
298+
299+class TestSnapBuildInstanceError(TestCase):
300+
301+ def test_no_matching_arch_raises(self):
302+ architecture = SnapArchitecture(build_on="amd64", run_on="amd64")
303+ raised = self.assertRaises(
304+ UnsupportedBuildOnError, SnapBuildInstance, architecture, ["i386"])
305+ self.assertEqual(["amd64"], raised.build_on)
306+
307+
308+class TestDetermineArchitecturesToBuild(WithScenarios, TestCase):
309+
310+ # Scenarios taken from the architectures document:
311+ # https://forum.snapcraft.io/t/architectures/4972
312+ scenarios = [
313+ ("none", {
314+ "architectures": None,
315+ "supported_architectures": ["amd64", "i386", "armhf"],
316+ "expected": [
317+ {"architecture": "amd64", "required": True},
318+ {"architecture": "i386", "required": True},
319+ {"architecture": "armhf", "required": True},
320+ ],
321+ }),
322+ ("i386", {
323+ "architectures": [
324+ {"build-on": "i386", "run-on": ["amd64", "i386"]},
325+ ],
326+ "supported_architectures": ["amd64", "i386", "armhf"],
327+ "expected": [{"architecture": "i386", "required": True}],
328+ }),
329+ ("amd64", {
330+ "architectures": [{"build-on": "amd64", "run-on": "all"}],
331+ "supported_architectures": ["amd64", "i386", "armhf"],
332+ "expected": [{"architecture": "amd64", "required": True}],
333+ }),
334+ ("amd64 and i386", {
335+ "architectures": [
336+ {"build-on": "amd64", "run-on": "amd64"},
337+ {"build-on": "i386", "run-on": "i386"},
338+ ],
339+ "supported_architectures": ["amd64", "i386", "armhf"],
340+ "expected": [
341+ {"architecture": "amd64", "required": True},
342+ {"architecture": "i386", "required": True},
343+ ],
344+ }),
345+ ("amd64 and i386 shorthand", {
346+ "architectures": [
347+ {"build-on": "amd64"},
348+ {"build-on": "i386"},
349+ ],
350+ "supported_architectures": ["amd64", "i386", "armhf"],
351+ "expected": [
352+ {"architecture": "amd64", "required": True},
353+ {"architecture": "i386", "required": True},
354+ ],
355+ }),
356+ ("amd64, i386, and armhf", {
357+ "architectures": [
358+ {"build-on": "amd64", "run-on": "amd64"},
359+ {"build-on": "i386", "run-on": "i386"},
360+ {
361+ "build-on": "armhf",
362+ "run-on": "armhf",
363+ "build-error": "ignore",
364+ },
365+ ],
366+ "supported_architectures": ["amd64", "i386", "armhf"],
367+ "expected": [
368+ {"architecture": "amd64", "required": True},
369+ {"architecture": "i386", "required": True},
370+ {"architecture": "armhf", "required": False},
371+ ],
372+ }),
373+ ("amd64 priority", {
374+ "architectures": [
375+ {"build-on": ["amd64", "i386"], "run-on": "all"},
376+ ],
377+ "supported_architectures": ["amd64", "i386", "armhf"],
378+ "expected": [{"architecture": "amd64", "required": True}],
379+ }),
380+ ("i386 priority", {
381+ "architectures": [
382+ {"build-on": ["amd64", "i386"], "run-on": "all"},
383+ ],
384+ "supported_architectures": ["i386", "amd64", "armhf"],
385+ "expected": [{"architecture": "i386", "required": True}],
386+ }),
387+ ("old style i386 priority", {
388+ "architectures": ["amd64", "i386"],
389+ "supported_architectures": ["i386", "amd64", "armhf"],
390+ "expected": [{"architecture": "i386", "required": True}],
391+ }),
392+ ("old style amd64 priority", {
393+ "architectures": ["amd64", "i386"],
394+ "supported_architectures": ["amd64", "i386", "armhf"],
395+ "expected": [{"architecture": "amd64", "required": True}],
396+ }),
397+ ("more architectures listed than are supported", {
398+ "architectures": [
399+ {"build-on": "amd64"},
400+ {"build-on": "i386"},
401+ {"build-on": "armhf"},
402+ ],
403+ "supported_architectures": ["amd64", "i386"],
404+ "expected": [
405+ {"architecture": "amd64", "required": True},
406+ {"architecture": "i386", "required": True},
407+ ],
408+ })
409+ ]
410+
411+ def test_parser(self):
412+ snapcraft_data = {"architectures": self.architectures}
413+ build_instances = determine_architectures_to_build(
414+ snapcraft_data, self.supported_architectures)
415+ self.assertThat(build_instances, HasLength(len(self.expected)))
416+ for instance in build_instances:
417+ self.assertIn(instance.__dict__, self.expected)
418+
419+
420+load_tests = load_tests_apply_scenarios