Skip to content

Commit 8fdcaf9

Browse files
test(linter): add integration test for linter
1 parent 0dae811 commit 8fdcaf9

File tree

7 files changed

+137
-30
lines changed

7 files changed

+137
-30
lines changed

craft_application/commands/lint.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -86,16 +86,16 @@ def run(self, parsed_args: argparse.Namespace) -> int | None:
8686
cli_ignores=list(parsed_args.lint_ignores or []),
8787
cli_ignore_files=list(parsed_args.lint_ignore_files or []),
8888
)
89-
# Consume the generator to populate the issues for summary
9089
for _ in linter.run(Stage(parsed_args.stage), ctx):
9190
pass
9291

93-
code = int(linter.summary())
94-
95-
if code == 0:
92+
highest = linter.get_highest_severity()
93+
if highest is None:
9694
emit.message("lint: OK")
97-
elif code == 1:
98-
emit.message("lint: WARN")
99-
else:
95+
return 0
96+
if highest.name == "ERROR":
10097
emit.message("lint: ERROR")
101-
return code
98+
return 2
99+
100+
emit.message("lint: WARN")
101+
return 0

craft_application/commands/other.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
from craft_cli import CommandGroup, emit
2121

2222
from . import InitCommand, base
23-
from .lint import LintCommand
2423

2524
if TYPE_CHECKING: # pragma: no cover
2625
import argparse
@@ -30,7 +29,6 @@ def get_other_command_group() -> CommandGroup:
3029
"""Return the lifecycle related command group."""
3130
commands: list[type[base.AppCommand]] = [
3231
InitCommand,
33-
LintCommand,
3432
VersionCommand,
3533
]
3634

craft_application/lint/base.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@
1717

1818
from __future__ import annotations
1919

20+
import inspect as _inspect
2021
from abc import ABC, abstractmethod
2122
from typing import TYPE_CHECKING
2223

24+
from .types import Stage as _Stage
25+
2326
if TYPE_CHECKING: # pragma: no cover
2427
from collections.abc import Iterable
2528

@@ -37,6 +40,18 @@ class AbstractLinter(ABC):
3740
name: str
3841
stage: Stage
3942

43+
def __init_subclass__(cls) -> None:
44+
"""Validate subclass has required attributes."""
45+
super().__init_subclass__()
46+
47+
if _inspect.isabstract(cls):
48+
return
49+
50+
if not isinstance(getattr(cls, "name", None), str) or not cls.name:
51+
raise TypeError("Linter subclass must define a non-empty 'name' string.")
52+
if not isinstance(getattr(cls, "stage", None), _Stage):
53+
raise TypeError("Linter subclass must define 'stage' as a Stage enum.")
54+
4055
@abstractmethod
4156
def run(self, ctx: LintContext) -> Iterable[LinterIssue]:
4257
"""Execute the linter and yield issues."""

craft_application/lint/types.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
# ruff: noqa: A005 # module name shadows stdlib 'types'
2626
from dataclasses import dataclass
27-
from enum import Enum
27+
from enum import Enum, IntEnum
2828
from fnmatch import fnmatch
2929
from typing import TYPE_CHECKING
3030

@@ -39,15 +39,15 @@ class Stage(str, Enum):
3939
POST = "post"
4040

4141

42-
class Severity(str, Enum):
42+
class Severity(IntEnum):
4343
"""Severity level for linter issues."""
4444

45-
INFO = "INFO"
46-
WARNING = "WARNING"
47-
ERROR = "ERROR"
45+
INFO = 1
46+
WARNING = 2
47+
ERROR = 3
4848

4949

50-
class ExitCode(int, Enum):
50+
class ExitCode(IntEnum):
5151
"""Exit codes summarising lint results."""
5252

5353
OK = 0

craft_application/services/linter.py

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,12 @@ def __init__(self, app: AppMetadata, services: ServiceFactory) -> None:
6161
def register(cls, linter_cls: type[AbstractLinter]) -> None:
6262
"""Register a linter class for use by the service."""
6363
if inspect.isabstract(linter_cls):
64-
return
65-
stage = getattr(linter_cls, "stage", None)
66-
name = getattr(linter_cls, "name", None)
67-
if not isinstance(stage, Stage) or not isinstance(name, str) or not name:
68-
raise ValueError(
69-
f"Invalid linter class {linter_cls!r}: missing/invalid name or stage"
70-
)
71-
emit.debug(f"Registering linter {name!r} for stage {stage.value}")
72-
cls._class_registry[stage].append(linter_cls)
64+
raise TypeError("Cannot register abstract linter class.")
65+
66+
emit.debug(
67+
f"Registering linter {linter_cls.name!r} for stage {linter_cls.stage.value}"
68+
)
69+
cls._class_registry.setdefault(linter_cls.stage, []).append(linter_cls)
7370

7471
@classmethod
7572
def build_ignore_config(
@@ -254,12 +251,17 @@ def run(
254251
self._issues.append(issue)
255252
yield issue
256253

254+
def get_highest_severity(self) -> Severity | None:
255+
"""Return the highest severity present among collected issues."""
256+
if not self._issues:
257+
return None
258+
return max((i.severity for i in self._issues), default=None)
259+
257260
def summary(self) -> ExitCode:
258-
"""Summarize results as an ExitCode based on highest severity."""
259-
if any(i.severity == Severity.ERROR for i in self._issues):
261+
"""Return an exit code (non-zero only for errors)."""
262+
highest = self.get_highest_severity()
263+
if highest == Severity.ERROR:
260264
return ExitCode.ERROR
261-
if any(i.severity == Severity.WARNING for i in self._issues):
262-
return ExitCode.WARN
263265
return ExitCode.OK
264266

265267
@property
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# This file is part of craft_application.
2+
#
3+
# Copyright 2025 Canonical Ltd.
4+
#
5+
# This program is free software: you can redistribute it and/or modify it
6+
# under the terms of the GNU Lesser General Public License version 3, as
7+
# published by the Free Software Foundation.
8+
#
9+
# This program is distributed in the hope that it will be useful, but WITHOUT
10+
# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
11+
# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE.
12+
# See the GNU Lesser General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU Lesser General Public License along
15+
# with this program. If not, see <http://www.gnu.org/licenses/>.
16+
"""Integration tests for the LinterService."""
17+
18+
from __future__ import annotations
19+
20+
from typing import TYPE_CHECKING
21+
22+
if TYPE_CHECKING:
23+
from collections.abc import Iterator
24+
25+
import pytest
26+
from craft_application.lint.base import AbstractLinter
27+
from craft_application.lint.types import (
28+
ExitCode,
29+
LintContext,
30+
LinterIssue,
31+
Severity,
32+
Stage,
33+
)
34+
from craft_application.services.linter import LinterService
35+
36+
37+
@pytest.fixture
38+
def restore_linter_registry() -> Iterator[None]:
39+
"""Snapshot and restore the linter registry around a test."""
40+
saved = {
41+
stage: list(classes) for stage, classes in LinterService._class_registry.items()
42+
}
43+
try:
44+
yield
45+
finally:
46+
for stage, classes in saved.items():
47+
LinterService._class_registry[stage] = list(classes)
48+
49+
50+
def test_issue_then_ignore(
51+
fake_services,
52+
project_path,
53+
fake_project,
54+
restore_linter_registry,
55+
) -> None:
56+
class _FailingPreLinter(AbstractLinter):
57+
name = "integration.failing_pre"
58+
stage = Stage.PRE
59+
60+
def run(self, ctx: LintContext):
61+
target = ctx.project_dir / "README.md"
62+
target.write_text("content")
63+
yield LinterIssue(
64+
id="INT001",
65+
message="integration failure",
66+
severity=Severity.ERROR,
67+
filename=str(target),
68+
)
69+
70+
LinterService.register(_FailingPreLinter)
71+
72+
project_service = fake_services.get("project")
73+
project_service.set(fake_project) # type: ignore[reportAttributeAccessIssue]
74+
project_dir = project_service.resolve_project_file_path().parent
75+
project_dir.mkdir(parents=True, exist_ok=True)
76+
77+
linter_service = fake_services.get("linter")
78+
ctx = LintContext(project_dir=project_dir, artifact_dirs=[])
79+
80+
linter_service.load_ignore_config(project_dir=project_dir)
81+
issues = list(linter_service.run(Stage.PRE, ctx))
82+
assert [i.id for i in issues] == ["INT001"]
83+
assert linter_service.summary() == ExitCode.ERROR
84+
85+
ignore_file = project_dir / "craft-lint.yaml"
86+
ignore_file.write_text(f"{_FailingPreLinter.name}:\n ids: ['INT001']\n")
87+
88+
linter_service.load_ignore_config(project_dir=project_dir)
89+
rerun = list(linter_service.run(Stage.PRE, ctx))
90+
assert rerun == []
91+
assert linter_service.summary() == ExitCode.OK

tests/unit/services/test_linter_service.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ def test_register_and_run_warning(tmp_path: Path) -> None:
5050
issues = list(svc.run(Stage.PRE, ctx))
5151
assert len(issues) == 1
5252
assert issues[0].id == "D001"
53-
assert int(svc.summary()) == 1 # WARN
53+
assert svc.get_highest_severity() == Severity.WARNING
54+
assert int(svc.summary()) == 0 # warnings do not cause non-zero exit
5455

5556

5657
def test_ignore_by_id_cli(tmp_path: Path) -> None:

0 commit comments

Comments
 (0)