Skip to content

Commit 2fe8846

Browse files
committed
feat(metadata-linter): impl new linter
1. added the new linter 2. lints based on ranks 3. added unit tests and spread tests for the new linter 4. added a new LinterResult value named INFO 5. fix spread tests accordingly Signed-off-by: Soumyadeep Ghosh <[email protected]>
1 parent b511004 commit 2fe8846

File tree

45 files changed

+745
-7
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+745
-7
lines changed

docs/how-to/crafting/configure-package-information.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ The placeholder values looks similar to this:
4141
my-part:
4242
plugin: nil
4343
44+
apps:
45+
my-app:
46+
command: usr/bin/my-app
47+
common-id: org.test.myapp
48+
4449
When crafting a snap, fill these keys as follows:
4550

4651
.. For help on choosing a name and registering it on the Snap Store, see `Registering your app name <>`_.
@@ -86,6 +91,11 @@ When crafting a snap, fill these keys as follows:
8691
case where your snap needs higher levels of system access, like a traditional
8792
unsandboxed package, you can :ref:`enable classic confinement
8893
<how-to-enable-classic-confinement>`.
94+
* - ``common-id``
95+
- Set a common id for your app(s). This common id is a reverse dns id used as
96+
an appstream id of that app. This id useful in appstream centric stores. Please
97+
look into `the documentation
98+
<https://www.freedesktop.org/software/appstream/docs/chap-Metadata.html#tag-id-generic>`_ for more info.
8999

90100

91101
.. _configure-package-information-reuse-information:

docs/how-to/debugging/disable-a-linter.rst

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Disable a linter
66
You can disable a :ref:`linter <reference-linters>` for a snap by listing it in the
77
``lint.ignore`` key.
88

9-
For example, to disable both built-in linters, add this to your project file:
9+
For example, to disable all built-in linters, add this to your project file:
1010

1111
.. code-block:: yaml
1212
:caption: snapcraft.yaml
@@ -15,9 +15,10 @@ For example, to disable both built-in linters, add this to your project file:
1515
ignore:
1616
- classic
1717
- library
18+
- metadata
1819
1920
20-
.. _how-to-disable-a-linter-ignore-specific-files:
21+
.. _how-to-disable-a-linter-ignore-specific-files-and-field:
2122

2223
Ignore specific files
2324
---------------------
@@ -26,9 +27,9 @@ To disable a linter for a specific file, you can list it under a linter's entry
2627
``lint.ignore`` key. The path is relative to the snap directory tree, and supports
2728
wildcard characters (*).
2829

29-
In the following example, the ``classic`` linter is disabled entirely, and the
30+
In the following example, the ``classic`` linter is disabled entirely, the
3031
``library`` linter won't run for the files in ``usr/lib`` that match the specified
31-
pattern:
32+
pattern and the ``title`` field will be ignored by the ``metadata`` linter:
3233

3334
.. code-block:: yaml
3435
:caption: snapcraft.yaml
@@ -38,3 +39,5 @@ pattern:
3839
- classic
3940
- library:
4041
- usr/lib/**/libfoo.so*
42+
- metadata:
43+
- title

docs/how-to/debugging/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Debugging
1010
debug-a-snap
1111
use-the-classic-linter
1212
use-the-library-linter
13+
use-the-metadata-linter
1314
disable-a-linter
1415
debug-with-gdb
1516
Classic confinement <debug-classic-confinement>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
.. _how-to-use-the-metadata-linter:
2+
3+
Use the metadata linter
4+
=======================
5+
6+
The following guidelines describe how to address issues flagged by the metadata linter.
7+
8+
To resolve a missing metadata field, add the missing field to the snap's
9+
``snapcraft.yaml`` file.
10+
11+
Currently these following metadata fields are linted:
12+
13+
.. kitbash-field:: craft_application.models.project.Project title
14+
.. kitbash-field:: project.Project contact
15+
.. kitbash-field:: project.App common_id
16+
:prepend-name: apps.<app-name>
17+
.. kitbash-field:: project.Project donation
18+
.. kitbash-field:: project.Project issues
19+
.. kitbash-field:: craft_application.models.project.Project license
20+
.. kitbash-field:: project.Project source_code
21+
.. kitbash-field:: project.Project website
22+
23+
To ignore the metadata field, add the field to the ``lint.ignore.metadata`` key.

docs/how-to/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ companion tools have features for identifying and resolving such issues.
6060
- :ref:`how-to-debug-a-snap`
6161
- :ref:`how-to-use-the-library-linter`
6262
- :ref:`how-to-use-the-classic-linter`
63+
- :ref:`how-to-use-the-metadata-linter`
6364
- :ref:`how-to-debug-with-gdb`
6465

6566

docs/reference/linters.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,5 @@ Snapcraft runs the following linters:
2222
- :ref:`Library <how-to-use-the-library-linter>`. Verifies that no ELF file
2323
dependencies, such as libraries, are missing, and that no extra libraries are included
2424
in the snap package.
25+
- :ref:`Metadata <how-to-use-the-metadata-linter>`. Verifies that the snap contains all the
26+
:ref:`metadata <reference-anatomy-of-snapcraft-yaml>` needed for a better listing in the snap store.

snapcraft/linters/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class LinterResult(str, enum.Enum):
4141
WARNING = "warning"
4242
ERROR = "error"
4343
FATAL = "fatal"
44+
INFO = "info"
4445
IGNORED = "ignored"
4546

4647
def __str__(self):

snapcraft/linters/linters.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@
3131
from .base import Linter, LinterIssue, LinterResult
3232
from .classic_linter import ClassicLinter
3333
from .library_linter import LibraryLinter
34+
from .metadata_linter import MetadataLinter
3435

3536
LinterType = type[Linter]
3637

3738

3839
LINTERS: dict[str, LinterType] = {
3940
"classic": ClassicLinter,
4041
"library": LibraryLinter,
42+
"metadata": MetadataLinter,
4143
}
4244

4345

@@ -49,12 +51,14 @@ class LinterStatus(enum.IntEnum):
4951
FATAL = 1
5052
ERRORS = 2
5153
WARNINGS = 3
54+
INFO = 4
5255

5356

5457
_lint_reports: dict[LinterResult, str] = {
5558
LinterResult.OK: "Lint OK",
5659
LinterResult.WARNING: "Lint warnings",
5760
LinterResult.ERROR: "Lint errors",
61+
LinterResult.INFO: "Lint information",
5862
}
5963

6064

@@ -106,6 +110,8 @@ def _update_status(status: LinterStatus, result: LinterResult) -> LinterStatus:
106110
status = LinterStatus.ERRORS
107111
elif result == LinterResult.WARNING and status == LinterStatus.OK:
108112
status = LinterStatus.WARNINGS
113+
elif result == LinterResult.INFO and status != LinterStatus.OK:
114+
status = LinterStatus.INFO
109115

110116
return status
111117

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
2+
"""Snapcraft metadata linter.
3+
4+
Validates that the snap's ``snapcraft.yaml`` (represented by ``SnapMetadata``)
5+
contains the minimum viable set of metadata fields and that their values look
6+
sane. The checks are grouped into *levels* so the user can prioritise the
7+
highest-impact fixes first.
8+
"""
9+
10+
from collections.abc import Callable
11+
from dataclasses import dataclass
12+
from typing import Any, Optional
13+
14+
from overrides import overrides
15+
16+
from snapcraft.meta.snap_yaml import SnapMetadata
17+
18+
from .base import Linter, LinterIssue, LinterResult
19+
20+
_HELP_URL = "https://documentation.ubuntu.com/snapcraft/stable/reference/project-file/snapcraft-yaml/"
21+
22+
23+
@dataclass
24+
class MetadataField:
25+
name: str
26+
severity: LinterResult
27+
extract: Callable[["SnapMetadata"], Any]
28+
help_url: str
29+
30+
31+
# Helper to pull the matching attribute values from all the apps
32+
def _get_apps_attr(meta: SnapMetadata, key: str) -> dict[str, list[str]] | None:
33+
if not meta.apps:
34+
return None
35+
36+
values: dict[str, list[str]] = {}
37+
for name, app in meta.apps.items():
38+
value: str | list[str] | None = getattr(app, key, None)
39+
if value and isinstance(value, list):
40+
values[name] = value
41+
elif isinstance(value, str):
42+
values[name] = [value]
43+
else:
44+
values[name] = []
45+
46+
return values if values else None
47+
48+
49+
# Helper to pull the matching attribute values from the links
50+
def _get_links_attr(meta: SnapMetadata, key: str) -> list[str] | str | None:
51+
if not meta.links:
52+
return None
53+
54+
return getattr(meta.links, key, None)
55+
56+
57+
_FIELDS: list[MetadataField] = [
58+
# TODO: implement desktop field in Rank1
59+
# TODO: implement icon field in Rank1
60+
# Rank 1 fields
61+
MetadataField(
62+
"title", LinterResult.WARNING, lambda meta: meta.title, f"{_HELP_URL}#title"
63+
),
64+
MetadataField(
65+
"contact",
66+
LinterResult.WARNING,
67+
lambda meta: _get_links_attr(meta, "contact"),
68+
f"{_HELP_URL}#contact",
69+
),
70+
MetadataField(
71+
"license",
72+
LinterResult.WARNING,
73+
lambda meta: meta.license,
74+
f"{_HELP_URL}#license",
75+
),
76+
MetadataField(
77+
"common_id",
78+
LinterResult.WARNING,
79+
lambda meta: _get_apps_attr(meta, "common_id"),
80+
f"{_HELP_URL}#apps.<app-name>.common-id",
81+
),
82+
# Rank 2 fields
83+
MetadataField(
84+
"donation",
85+
LinterResult.INFO,
86+
lambda meta: _get_links_attr(meta, "donation"),
87+
f"{_HELP_URL}#donation",
88+
),
89+
MetadataField(
90+
"issues",
91+
LinterResult.INFO,
92+
lambda meta: _get_links_attr(meta, "issues"),
93+
f"{_HELP_URL}#issues",
94+
),
95+
MetadataField(
96+
"source_code",
97+
LinterResult.INFO,
98+
lambda meta: _get_links_attr(meta, "source_code"),
99+
f"{_HELP_URL}#source-code",
100+
),
101+
MetadataField(
102+
"website",
103+
LinterResult.INFO,
104+
lambda meta: _get_links_attr(meta, "website"),
105+
f"{_HELP_URL}#website",
106+
),
107+
]
108+
109+
110+
class MetadataLinter(Linter):
111+
"""Checks snap metadata completeness and semantic validity."""
112+
113+
@staticmethod
114+
def get_categories() -> list[str]:
115+
return [field.name for field in _FIELDS]
116+
117+
@classmethod
118+
def _is_dict_empty(
119+
cls, value: dict[str, list[str]] | None
120+
) -> dict[str, bool] | bool:
121+
"""Check if dictionary values are empty or None.
122+
123+
:param value: Dictionary to check
124+
125+
:returns: True if all values are empty, Dict mapping keys to empty status otherwise
126+
"""
127+
if value is None:
128+
return True
129+
result = {}
130+
for key, values in value.items():
131+
if not values:
132+
result[key] = True
133+
134+
return result if result else False
135+
136+
@classmethod
137+
def _is_empty(
138+
cls,
139+
value: str | dict[str, list[str]] | list[str] | None,
140+
) -> dict[str, bool] | bool:
141+
"""Check if a value is empty, handling different types appropriately.
142+
143+
:param value: The value to check
144+
145+
:returns: True if empty, False if not empty, or Dict for partial emptiness in dict values
146+
"""
147+
if value is None:
148+
return True
149+
if isinstance(value, str):
150+
return value.strip() == ""
151+
if isinstance(value, list) and not value:
152+
return True
153+
if isinstance(value, dict):
154+
return cls._is_dict_empty(value)
155+
return False
156+
157+
def _create_issue(
158+
self, field: MetadataField, text: str, result: Optional["LinterResult"] = None
159+
) -> "LinterIssue":
160+
"""Create a linter issue for a metadata field.
161+
162+
:param field: The metadata field
163+
:param text: Issue description
164+
:param result: Override result type (defaults to field rank severity)
165+
166+
:returns: LinterIssue instance
167+
"""
168+
help_url = field.help_url if result is not LinterResult.IGNORED else None
169+
170+
return LinterIssue(
171+
name=self._name,
172+
result=result or field.severity,
173+
text=text,
174+
url=help_url,
175+
)
176+
177+
def _check_field(self, field: MetadataField) -> list["LinterIssue"]:
178+
"""Check if a metadata field is complete and create issues if not.
179+
180+
:param field: The metadata field to check
181+
182+
:returns: List of linter issues found
183+
"""
184+
issues = []
185+
value = field.extract(self._snap_metadata)
186+
field_name = field.name.replace("_", "-")
187+
188+
result = self._is_empty(value)
189+
190+
if isinstance(result, bool) and result:
191+
issues.append(
192+
self._create_issue(
193+
field,
194+
f"Metadata field '{field_name}' is missing or empty.",
195+
)
196+
)
197+
elif isinstance(result, dict):
198+
for name, is_empty in result.items():
199+
if is_empty:
200+
issues.append(
201+
self._create_issue(
202+
field,
203+
f"Metadata field '{field_name}' for app '{name}' is missing or empty.",
204+
)
205+
)
206+
207+
return issues
208+
209+
@overrides
210+
def run(self) -> list[LinterIssue]:
211+
meta: SnapMetadata = self._snap_metadata
212+
213+
if meta.grade == "devel":
214+
return []
215+
216+
issues: list[LinterIssue] = []
217+
for field in _FIELDS:
218+
if field.name == "common_id" and (
219+
(meta.type and meta.type != "app") or not meta.apps
220+
):
221+
continue
222+
if self._lint and field.name in self._lint.ignored_files(self._name):
223+
issues.append(
224+
self._create_issue(
225+
field,
226+
f"Metadata field '{field.name}' is ignored.",
227+
LinterResult.IGNORED,
228+
)
229+
)
230+
continue
231+
232+
issues.extend(self._check_field(field))
233+
234+
return issues

tests/spread/core22/linters/classic-libc/snap/snapcraft.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ confinement: strict
1313

1414
lint:
1515
ignore:
16+
- metadata
1617
- library
1718

1819
parts:

0 commit comments

Comments
 (0)