Repository: canonical/snapcraft

Issue:
Implement support for `common-id`
https://forum.snapcraft.io/t/support-for-appstream-id/2327/6


Discussion:

Resolving patch:
diff --git a/snapcraft/extractors/_metadata.py b/snapcraft/extractors/_metadata.py
--- a/snapcraft/extractors/_metadata.py
+++ b/snapcraft/extractors/_metadata.py
@@ -24,11 +24,13 @@ class ExtractedMetadata(yaml.YAMLObject):
     yaml_tag = u'!ExtractedMetadata'
 
     def __init__(
-            self, *, summary: str='',
+            self, *, common_id: str='', summary: str='',
             description: str='', icon: str='',
             desktop_file_paths: List[str]=None) -> None:
         """Create a new ExtractedMetadata instance.
 
+        :param str: common_id: The common identifier across multiple packaging
+            formats
         :param str summary: Extracted summary
         :param str description: Extracted description
         :param str icon: Extracted icon
@@ -37,6 +39,8 @@ def __init__(
 
         self._data = {}  # type: Dict[str, Union[str, List[str]]]
 
+        if common_id:
+            self._data['common_id'] = common_id
         if summary:
             self._data['summary'] = summary
         if description:
@@ -56,6 +60,15 @@ def update(self, other: 'ExtractedMetadata') -> None:
         """
         self._data.update(other.to_dict())
 
+    def get_common_id(self) -> str:
+        """Return extracted common_id.
+
+        :returns: Extracted common_id
+        :rtype: str
+        """
+        common_id = self._data.get('common_id')
+        return str(common_id) if common_id else None
+
     def get_summary(self) -> str:
         """Return extracted summary.
 
diff --git a/snapcraft/extractors/appstream.py b/snapcraft/extractors/appstream.py
--- a/snapcraft/extractors/appstream.py
+++ b/snapcraft/extractors/appstream.py
@@ -35,15 +35,9 @@ def extract(path: str) -> ExtractedMetadata:
     except ParseError as e:
         raise _errors.AppstreamFileParseError(path) from e
 
-    summary = None
-    node = tree.find('summary')
-    if node is not None:
-        summary = node.text.strip()
-
-    description = None
-    node = tree.find('description')
-    if node is not None:
-        description = node.text.strip()
+    common_id = _get_value_from_xml_element(tree, 'id')
+    summary = _get_value_from_xml_element(tree, 'summary')
+    description = _get_value_from_xml_element(tree, 'description')
 
     icon = None
     node = tree.find('icon')
@@ -66,8 +60,16 @@ def extract(path: str) -> ExtractedMetadata:
                 desktop_file_paths.append(desktop_file_path)
 
     return ExtractedMetadata(
-        summary=summary, description=description, icon=icon,
-        desktop_file_paths=desktop_file_paths)
+        common_id=common_id, summary=summary, description=description,
+        icon=icon, desktop_file_paths=desktop_file_paths)
+
+
+def _get_value_from_xml_element(tree, key) -> str:
+    node = tree.find(key)
+    if node is not None:
+        return node.text.strip()
+    else:
+        return None
 
 
 def _desktop_file_id_to_path(desktop_file_id: str) -> str:
diff --git a/snapcraft/internal/meta/_snap_packaging.py b/snapcraft/internal/meta/_snap_packaging.py
--- a/snapcraft/internal/meta/_snap_packaging.py
+++ b/snapcraft/internal/meta/_snap_packaging.py
@@ -157,14 +157,15 @@ def _adopt_info(
                     # Do not overwrite the icon file.
                     continue
             config_data[key] = value
-    if 'desktop_file_paths' in metadata_dict:
-        for desktop_file_path in metadata_dict['desktop_file_paths']:
-            app_name = _get_app_name_from_desktop_file_path(
-                config_data, desktop_file_path)
-            if app_name and not _desktop_file_exists(app_name):
+    if 'desktop_file_paths' in metadata_dict and 'common_id' in metadata_dict:
+        app_name = _get_app_name_from_common_id(
+            config_data, str(metadata_dict['common_id']))
+        if app_name and not _desktop_file_exists(app_name):
+            for desktop_file_path in metadata_dict['desktop_file_paths']:
                 if os.path.exists(desktop_file_path):
                     config_data['apps'][app_name]['desktop'] = (
                         desktop_file_path)
+                    break
 
 
 def _icon_file_exists() -> bool:
@@ -186,31 +187,20 @@ def _icon_file_exists() -> bool:
         return False
 
 
-def _get_app_name_from_desktop_file_path(
-        config_data: Dict[str, Any], desktop_file_path: str) -> str:
-    """Get the snap app name from a desktop file path.
+def _get_app_name_from_common_id(
+        config_data: Dict[str, Any], common_id: str) -> str:
+    """Get the snap app name with a common-id.
 
-    XXX We try to find a match between desktop file path and snap app name.
-    This will be much nicer once snapcraft supports appstream IDs:
-    https://forum.snapcraft.io/t/support-for-appstream-id/2327
-    --elopio - 20180108
-
-    :params desktop_file_path: The path to the desktop file.
-    :returns: The name of the snap app that corresponds to the desktop file.
+    :params dict config_data: Project values defined in snapcraft.yaml.
+    :params common_id: The common identifier across multiple packaging
+        formats.
+    :returns: The name of the snap app with the common-id.
 
     """
-    if 'applications' in desktop_file_path:
-        desktop_file_id = desktop_file_path[
-            desktop_file_path.find('applications') +
-            len('applications') + 1:
-        ].replace('/', '-')
-        desktop_file_id_parts = desktop_file_id.split('.')
-        if desktop_file_id_parts[-1] == 'desktop':
-            desktop_file_id_parts = desktop_file_id_parts[:-1]
-            app_id = desktop_file_id_parts[-1]
-            if ('apps' in config_data and
-                    app_id in config_data['apps'].keys()):
-                return app_id
+    if 'apps' in config_data:
+        for app in config_data['apps']:
+            if config_data['apps'][app].get('common-id') == common_id:
+                return app
     return None
 
 


Test patch:
diff --git a/tests/integration/general/test_metadata_appstream.py b/tests/integration/general/test_metadata_appstream.py
--- a/tests/integration/general/test_metadata_appstream.py
+++ b/tests/integration/general/test_metadata_appstream.py
@@ -37,6 +37,7 @@ def setUp(self):
             appstream_file.write(textwrap.dedent(
                 """<?xml version="1.0" encoding="UTF-8"?>
                 <component>
+                  <id>com.example.testid</id>
                   <description>test-appstream-description</description>
                   <summary>test-appstream-summary</summary>
                   <icon type="local">assets/test-icon.png</icon>
@@ -120,7 +121,10 @@ def test_desktop_extracted_from_appstream(self):
                 'plugin': 'dump',
                 'parse-info': ['test.metainfo.xml']})
         snapcraft_yaml.update_app(
-            'test-app', {'command': 'echo'})
+            'test-app', {
+                'command': 'echo',
+                'common-id': 'com.example.testid'
+            })
         self.useFixture(snapcraft_yaml)
 
         self.run_snapcraft('prime')
diff --git a/tests/unit/extractors/test_appstream.py b/tests/unit/extractors/test_appstream.py
--- a/tests/unit/extractors/test_appstream.py
+++ b/tests/unit/extractors/test_appstream.py
@@ -33,17 +33,25 @@ class AppstreamTestCase(unit.TestCase):
             'key': 'summary',
             'attributes': {},
             'value': 'test-summary',
+            'param_name': 'summary'
         }),
          ('description', {
              'key': 'description',
              'attributes': {},
              'value': 'test-description',
-
+             'param_name': 'description',
          }),
          ('local icon', {
             'key': 'icon',
             'attributes': {'type': 'local'},
+            'param_name': 'icon',
             'value': '/test/path',
+         }),
+         ('common id', {
+             'key': 'id',
+             'attributes': {},
+             'param_name': 'common_id',
+             'value': 'test-id'
          })],
         [('metainfo', {'file_extension': 'metainfo.xml'}),
          ('appdata', {'file_extension': 'appdata.xml'})]
@@ -64,7 +72,7 @@ def test_appstream(self):
                 </component>""".format(
                     key=self.key, value=self.value, attributes=attributes)))
 
-        kwargs = {self.key: self.value}
+        kwargs = {self.param_name: self.value}
         expected = ExtractedMetadata(**kwargs)
 
         self.assertThat(
diff --git a/tests/unit/pluginhandler/test_pluginhandler.py b/tests/unit/pluginhandler/test_pluginhandler.py
--- a/tests/unit/pluginhandler/test_pluginhandler.py
+++ b/tests/unit/pluginhandler/test_pluginhandler.py
@@ -854,6 +854,7 @@ def test_pull_state_with_extracted_metadata(self, repo_mock):
 
         def _fake_extractor(file_path):
             return snapcraft.extractors.ExtractedMetadata(
+                common_id='test_common_id',
                 summary='test summary',
                 description='test description',
                 icon='/test/path',
@@ -889,6 +890,7 @@ def _fake_extractor(file_path):
         for expected in ('metadata', 'files'):
             self.assertThat(state.extracted_metadata, Contains(expected))
         metadata = state.extracted_metadata['metadata']
+        self.assertThat(metadata.get_common_id(), Equals('test_common_id'))
         self.assertThat(metadata.get_summary(), Equals('test summary'))
         self.assertThat(metadata.get_description(), Equals('test description'))
         self.assertThat(metadata.get_icon(), Equals('/test/path'))
@@ -961,6 +963,7 @@ def test_build_state_with_extracted_metadata(self):
 
         def _fake_extractor(file_path):
             return snapcraft.extractors.ExtractedMetadata(
+                common_id='test_common_id',
                 summary='test summary',
                 description='test description',
                 icon='/test/path',
@@ -993,6 +996,7 @@ def _fake_extractor(file_path):
         for expected in ('metadata', 'files'):
             self.assertThat(state.extracted_metadata, Contains(expected))
         metadata = state.extracted_metadata['metadata']
+        self.assertThat(metadata.get_common_id(), Equals('test_common_id'))
         self.assertThat(metadata.get_summary(), Equals('test summary'))
         self.assertThat(metadata.get_description(), Equals('test description'))
         self.assertThat(metadata.get_icon(), Equals('/test/path'))
diff --git a/tests/unit/test_meta.py b/tests/unit/test_meta.py
--- a/tests/unit/test_meta.py
+++ b/tests/unit/test_meta.py
@@ -326,6 +326,7 @@ def test_create_meta_with_app(self):
             'app4': {'command': 'app.sh', 'plugs': ['network-server'],
                      'environment': {'XDG_SOMETHING': '$SNAP_USER_DATA',
                                      'LANG': 'C'}},
+            'app5': {'command': 'app.sh', 'common-id': 'test-common-id'}
         }
         self.config_data['plugs'] = {
             'network-server': {'interface': 'network-bind'}}
@@ -360,6 +361,10 @@ def test_create_meta_with_app(self):
                         'XDG_SOMETHING': '$SNAP_USER_DATA',
                         'LANG': 'C'}
                 },
+                'app5': {
+                    'command': 'command-app5.wrapper',
+                    'common-id': 'test-common-id'
+                }
             },
             'description': 'my description',
             'summary': 'my summary',