Repository: canonical/snapcraft

Issue:
Develop appstream handler
Estimate: 3 days

Currently Snapcraft requires all its metadata (snap name, version, etc.) to be specified in the snapcraft.yaml. However, lots of parts already contain such information, whether from a setup.py, XML, AppStream, etc.

I’m considering this task to only be for existing metadata (name, version, summary, etc.). Adding support for an AppStream ID etc. is a separate task.
Develop appstream handler
Estimate: 3 days

Currently Snapcraft requires all its metadata (snap name, version, etc.) to be specified in the snapcraft.yaml. However, lots of parts already contain such information, whether from a setup.py, XML, AppStream, etc.

I’m considering this task to only be for existing metadata (name, version, summary, etc.). Adding support for an AppStream ID etc. is a separate task.


Discussion:

Resolving patch:
diff --git a/snapcraft/extractors/_errors.py b/snapcraft/extractors/_errors.py
--- a/snapcraft/extractors/_errors.py
+++ b/snapcraft/extractors/_errors.py
@@ -20,9 +20,19 @@
 class UnhandledFileError(MetadataExtractionError):
 
     fmt = (
-        "Failed to extract metadata from {file_path!r}: "
+        "Failed to extract metadata from {path!r}: "
         "This file is not handled by {extractor_name!r}."
     )
 
-    def __init__(self, file_path: str, extractor_name: str) -> None:
-        super().__init__(file_path=file_path, extractor_name=extractor_name)
+    def __init__(self, path: str, extractor_name: str) -> None:
+        super().__init__(path=path, extractor_name=extractor_name)
+
+
+class AppstreamFileParseError(MetadataExtractionError):
+
+    fmt = (
+        "Failed to extract metadata from {path!r}: "
+        "it's not a valid XML file.")
+
+    def __init__(self, path: str) -> None:
+        super().__init__(path=path)
diff --git a/snapcraft/extractors/appstream.py b/snapcraft/extractors/appstream.py
new file mode 100644
--- /dev/null
+++ b/snapcraft/extractors/appstream.py
@@ -0,0 +1,45 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright (C) 2017 Canonical Ltd
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from ._metadata import ExtractedMetadata
+from snapcraft.extractors import _errors
+
+from xml.etree.ElementTree import (
+    ElementTree,
+    ParseError
+)
+
+
+def extract(path: str) -> ExtractedMetadata:
+    if not path.endswith('.metainfo.xml'):
+        raise _errors.UnhandledFileError(path, 'appstream')
+
+    try:
+        tree = ElementTree().parse(path)
+    except ParseError as e:
+        raise _errors.AppstreamFileParseError(path) from e
+
+    summary = None
+    node = tree.find('summary')
+    if node is not None:
+        summary = node.text
+
+    description = None
+    node = tree.find('description')
+    if node is not None:
+        description = node.text
+
+    return ExtractedMetadata(summary=summary, description=description)


Test patch:
diff --git a/snapcraft/tests/integration/general/test_metadata_appstream.py b/snapcraft/tests/integration/general/test_metadata_appstream.py
new file mode 100644
--- /dev/null
+++ b/snapcraft/tests/integration/general/test_metadata_appstream.py
@@ -0,0 +1,78 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright (C) 2017 Canonical Ltd
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import textwrap
+import yaml
+
+from testtools.matchers import Equals
+
+from snapcraft.tests import (
+    fixture_setup,
+    integration
+)
+
+
+class AppstreamMetadataTestCase(integration.TestCase):
+
+    def setUp(self):
+        super().setUp()
+
+        with open('test.metainfo.xml', 'w') as appstream_file:
+            appstream_file.write(textwrap.dedent(
+                """<?xml version="1.0" encoding="UTF-8"?>
+                <component>
+                  <description>test-appstream-description</description>
+                  <summary>test-appstream-summary</summary>
+                </component>"""))
+
+    def test_metadata_extracted_from_appstream(self):
+        snapcraft_yaml = fixture_setup.SnapcraftYaml(
+            self.path, summary=None, description=None)
+        snapcraft_yaml.data['adopt-info'] = 'test-part'
+        snapcraft_yaml.update_part(
+            'test-part', {
+                'plugin': 'dump',
+                'parse-info': ['test.metainfo.xml']})
+        self.useFixture(snapcraft_yaml)
+
+        self.run_snapcraft('prime')
+        with open(
+                os.path.join('prime', 'meta', 'snap.yaml')) as snap_yaml_file:
+            snap_yaml = yaml.load(snap_yaml_file)
+        self.assertThat(
+            snap_yaml['description'], Equals('test-appstream-description'))
+        self.assertThat(
+            snap_yaml['summary'], Equals('test-appstream-summary'))
+
+    def test_specified_metadata_not_overwritten(self):
+        snapcraft_yaml = fixture_setup.SnapcraftYaml(
+            self.path, description=None)
+        snapcraft_yaml.data['adopt-info'] = 'test-part'
+        snapcraft_yaml.update_part(
+            'test-part', {
+                'plugin': 'dump',
+                'parse-info': ['test.metainfo.xml']})
+        self.useFixture(snapcraft_yaml)
+
+        self.run_snapcraft('prime')
+        with open(
+                os.path.join('prime', 'meta', 'snap.yaml')) as snap_yaml_file:
+            snap_yaml = yaml.load(snap_yaml_file)
+        self.assertThat(
+            snap_yaml['description'], Equals('test-appstream-description'))
+        self.assertThat(
+            snap_yaml['summary'], Equals('test-summary'))
diff --git a/snapcraft/tests/unit/extractors/test_appstream.py b/snapcraft/tests/unit/extractors/test_appstream.py
new file mode 100644
--- /dev/null
+++ b/snapcraft/tests/unit/extractors/test_appstream.py
@@ -0,0 +1,49 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright (C) 2017 Canonical Ltd
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import textwrap
+
+from snapcraft.extractors import appstream, ExtractedMetadata
+
+from testtools.matchers import Equals
+
+from snapcraft.tests import unit
+
+
+class AppstreamTestCase(unit.TestCase):
+
+    scenarios = [
+        ('summary', {
+            'key': 'summary',
+        }),
+        ('description', {
+            'key': 'description',
+        }),
+    ]
+
+    def test_appstream(self):
+        with open('foo.metainfo.xml', 'w') as f:
+            f.write(textwrap.dedent("""\
+                <?xml version="1.0" encoding="UTF-8"?>
+                <component>
+                  <{key}>test-{key}</{key}>
+                </component>""".format(key=self.key)))
+
+        kwargs = {self.key: 'test-{}'.format(self.key)}
+        expected = ExtractedMetadata(**kwargs)
+
+        self.assertThat(
+            appstream.extract('foo.metainfo.xml'), Equals(expected))