Repository: MarketSquare/robotframework-robocop
Pull request #1328 (2025-03-17 11:03:08)

Create common base class for json-based reports
We have now several reports that produce json-like file. I have created common class for them and standardized their configuration.


Patch:
diff --git a/docs/releasenotes/6.0.0a3.rst b/docs/releasenotes/6.0.0a3.rst
index 40b2dd5..26506a8 100644
--- a/docs/releasenotes/6.0.0a3.rst
+++ b/docs/releasenotes/6.0.0a3.rst
@@ -612,6 +612,22 @@ features. It is now replaced with Python format syntax::
 
 It's important only for users who have custom rules using templates in messages.
 
+Normalized output path configuration in json and sarif reports
+--------------------------------------------------------------
+
+In this release we have added several new reports, that are file-based (such as gitlab, sonarqube etc reports).
+To make it simpler and more logical, I have standardized  how the output path can be configured for such reports.
+
+Following configuration parameters are now deprecated in json and sarif reports:
+
+- output_dir
+- report_filename
+
+Instead, we can use ``output_path`` to configure both directory and filename::
+
+    robocop check --reports sarif --configure sarif.output_path=reports/sarif.json
+    robocop check --reports gitlab --configure gitlab.output_path=reports/gitlab.json
+
 New features & other changes
 ============================
 
diff --git a/src/robocop/__init__.py b/src/robocop/__init__.py
index 1cc493f..ccba933 100644
--- a/src/robocop/__init__.py
+++ b/src/robocop/__init__.py
@@ -1,1 +1,1 @@
-__version__ = "6.0.0a3"
+__version__ = "6.0.0b1"
diff --git a/src/robocop/linter/reports/__init__.py b/src/robocop/linter/reports/__init__.py
index c7158ce..bfc854c 100644
--- a/src/robocop/linter/reports/__init__.py
+++ b/src/robocop/linter/reports/__init__.py
@@ -7,6 +7,7 @@ from typing import NoReturn
 
 import robocop.linter.exceptions
 from robocop.config import Config
+from robocop.errors import FatalError
 from robocop.linter.rules import RobocopImporter
 from robocop.linter.utils.misc import get_robocop_cache_directory
 
@@ -38,6 +39,30 @@ class Report:
         raise NotImplementedError
 
 
+class JsonFileReport(Report):
+    """Base class for report that generates json-based file."""
+
+    def __init__(self, output_path: str, config: Config) -> None:
+        self.output_path = output_path
+        super().__init__(config)
+
+    def configure(self, name: str, value: str) -> None:
+        if name == "output_path":
+            self.output_path = value
+        else:
+            super().configure(name, value)
+
+    def generate_report(self, report: list[dict] | dict, report_type: str) -> None:
+        output_path = Path(self.output_path)
+        try:
+            output_path.parent.mkdir(exist_ok=True, parents=True)
+            with open(output_path, "w") as fp:
+                json.dump(report, fp, indent=4)
+        except OSError as err:
+            raise FatalError(f"Failed to write {report_type} report to {output_path}: {err}") from None
+        print(f"Generated {report_type} report at {self.output_path}")
+
+
 class ComparableReport(Report):
     def __init__(self, config: Config) -> None:
         self.compare_runs = config.linter.compare
diff --git a/src/robocop/linter/reports/gitlab.py b/src/robocop/linter/reports/gitlab.py
index efeb7bc..2dd3d0e 100644
--- a/src/robocop/linter/reports/gitlab.py
+++ b/src/robocop/linter/reports/gitlab.py
@@ -1,12 +1,10 @@
 from __future__ import annotations
 
 import hashlib
-import json
 from pathlib import Path
 from typing import TYPE_CHECKING
 
 import robocop.linter.reports
-from robocop.errors import FatalError
 from robocop.formatter.utils.misc import StatementLinesCollector
 from robocop.linter.rules import RuleSeverity
 
@@ -17,7 +15,7 @@ if TYPE_CHECKING:
     from robocop.linter.diagnostics import Diagnostic, Diagnostics
 
 
-class GitlabReport(robocop.linter.reports.Report):
+class GitlabReport(robocop.linter.reports.JsonFileReport):
     """
     **Report name**: ``gitlab``
 
@@ -42,25 +40,11 @@ class GitlabReport(robocop.linter.reports.Report):
     def __init__(self, config: Config):
         self.name = "gitlab"
         self.description = "Generate Gitlab Code Quality output file"
-        self.output_path = "robocop-code-quality.json"
-        super().__init__(config)
-
-    def configure(self, name, value) -> None:
-        if name == "output_path":
-            self.output_path = value
-        else:
-            super().configure(name, value)
+        super().__init__(output_path="robocop-code-quality.json", config=config)
 
     def generate_report(self, diagnostics: Diagnostics, **kwargs) -> None:  # noqa: ARG002
         report = self.generate_gitlab_report(diagnostics)
-        output_path = Path(self.output_path)
-        try:
-            output_path.parent.mkdir(exist_ok=True, parents=True)
-            with open(output_path, "w") as fp:
-                json.dump(report, fp, indent=4)
-        except OSError as err:
-            raise FatalError(f"Failed to write Gitlab report to {output_path}: {err}") from None
-        print(f"Generated Gitlab Code Quality report at {self.output_path}")
+        super().generate_report(report, "Gitlab Code Quality")
 
     def generate_gitlab_report(self, diagnostics: Diagnostics) -> list[dict]:
         report = []
diff --git a/src/robocop/linter/reports/json_report.py b/src/robocop/linter/reports/json_report.py
index 42d90e4..3c331f7 100644
--- a/src/robocop/linter/reports/json_report.py
+++ b/src/robocop/linter/reports/json_report.py
@@ -1,4 +1,3 @@
-import json
 from pathlib import Path
 
 import robocop.linter.reports
@@ -6,7 +5,7 @@ from robocop.config import Config
 from robocop.linter.diagnostics import Diagnostic, Diagnostics
 
 
-class JsonReport(robocop.linter.reports.Report):
+class JsonReport(robocop.linter.reports.JsonFileReport):
     r"""
     **Report name**: ``json_report``
 
@@ -16,10 +15,11 @@ class JsonReport(robocop.linter.reports.Report):
     This report is not included in the default reports. The ``--reports all`` option will not enable this report.
     You can still enable it using report name directly: ``--reports json_report`` or ``--reports all,json_report``.
 
-    You can configure output directory and report filename::
+    You can configure output path. It's relative path to file that will be produced by the report::
 
-        robocop check --configure json_report.output_dir=C:/json_reports
-        robocop check --configure json_report.report_filename=robocop_output.json
+        robocop check --configure json_report.output_path=C:/json_reports/report.json
+
+    Default path is ``robocop.json`` .
 
     Example content of the file::
 
@@ -55,29 +55,11 @@ class JsonReport(robocop.linter.reports.Report):
     def __init__(self, config: Config):
         self.name = "json_report"
         self.description = "Produces JSON file with found issues"
-        self.output_dir = None
-        self.report_filename = "robocop.json"
-        super().__init__(config)
+        super().__init__(output_path="robocop.json", config=config)
 
     def generate_report(self, diagnostics: Diagnostics, **kwargs) -> None:  # noqa: ARG002
         issues = [self.message_to_json(diagnostic) for diagnostic in diagnostics]
-        if self.output_dir is not None:
-            output_path = self.output_dir / self.report_filename
-        else:
-            output_path = Path(self.report_filename)
-        with open(output_path, "w") as fp:
-            json_string = json.dumps(issues, indent=4)
-            fp.write(json_string)
-        print(f"\nGenerated JSON report at {output_path}")
-
-    def configure(self, name: str, value: str) -> None:
-        if name == "output_dir":
-            self.output_dir = Path(value)
-            self.output_dir.mkdir(parents=True, exist_ok=True)
-        elif name == "report_filename":
-            self.report_filename = value
-        else:
-            super().configure(name, value)
+        super().generate_report(issues, "JSON")
 
     @staticmethod
     def message_to_json(message: Diagnostic) -> dict:
diff --git a/src/robocop/linter/reports/sarif_report.py b/src/robocop/linter/reports/sarif.py
similarity index 80%
rename from src/robocop/linter/reports/sarif_report.py
rename to src/robocop/linter/reports/sarif.py
index 332b76d..c99b7a8 100644
--- a/src/robocop/linter/reports/sarif_report.py
+++ b/src/robocop/linter/reports/sarif.py
@@ -1,4 +1,3 @@
-import json
 from pathlib import Path
 
 import robocop.linter.reports
@@ -9,7 +8,7 @@ from robocop.linter.rules import Rule
 from robocop.linter.utils.misc import ROBOCOP_RULES_URL
 
 
-class SarifReport(robocop.linter.reports.Report):
+class SarifReport(robocop.linter.reports.JsonFileReport):
     """
     **Report name**: ``sarif``
 
@@ -21,9 +20,11 @@ class SarifReport(robocop.linter.reports.Report):
     All fields required by GitHub Code Scanning are supported. The output file will be generated
     in the current working directory with the ``.sarif.json`` name.
 
-    You can configure output directory and report filename::
+    You can configure output path. It's relative path to file that will be produced by the report::
 
-        robocop check --configure sarif.output_dir=C:/sarif_reports --configure sarif.report_filename=.sarif
+        robocop check --configure sarif.output_path=output/robocop_sarif.json
+
+    Default path is ``.sarif.json`` .
 
     """
 
@@ -34,18 +35,7 @@ class SarifReport(robocop.linter.reports.Report):
     def __init__(self, config: Config):
         self.name = "sarif"
         self.description = "Generate SARIF output file"
-        self.output_dir = None
-        self.report_filename = ".sarif.json"
-        super().__init__(config)
-
-    def configure(self, name, value) -> None:
-        if name == "output_dir":
-            self.output_dir = Path(value)
-            self.output_dir.mkdir(parents=True, exist_ok=True)
-        elif name == "report_filename":
-            self.report_filename = value
-        else:
-            super().configure(name, value)
+        super().__init__(output_path=".sarif.json", config=config)
 
     @staticmethod
     def map_severity_to_level(severity):
@@ -118,11 +108,4 @@ class SarifReport(robocop.linter.reports.Report):
         report = self.generate_sarif_report(
             diagnostics, config_manager.root, config_manager.default_config.linter.rules
         )
-        if self.output_dir is not None:
-            output_path = self.output_dir / self.report_filename
-        else:
-            output_path = Path(self.report_filename)
-        with open(output_path, "w") as fp:
-            json_string = json.dumps(report, indent=4)
-            fp.write(json_string)
-        return f"Generated SARIF report in {output_path}"
+        super().generate_report(report, "SARIF")
diff --git a/src/robocop/linter/reports/sonarqube.py b/src/robocop/linter/reports/sonarqube.py
index 832d456..fad22a9 100644
--- a/src/robocop/linter/reports/sonarqube.py
+++ b/src/robocop/linter/reports/sonarqube.py
@@ -1,15 +1,13 @@
-import json
 from pathlib import Path
 
 import robocop.linter.reports
 from robocop.config import Config, ConfigManager
-from robocop.errors import FatalError
 from robocop.linter import sonar_qube
 from robocop.linter.diagnostics import Diagnostic, Diagnostics
 from robocop.linter.rules import Rule, RuleSeverity
 
 
-class SonarQubeReport(robocop.linter.reports.Report):
+class SonarQubeReport(robocop.linter.reports.JsonFileReport):
     """
     **Report name**: ``sonarqube``
 
@@ -41,14 +39,7 @@ class SonarQubeReport(robocop.linter.reports.Report):
     def __init__(self, config: Config):
         self.name = "sonarqube"
         self.description = "Generate SonarQube report"
-        self.output_path = "robocop_sonar_qube.json"
-        super().__init__(config)
-
-    def configure(self, name, value) -> None:
-        if name == "output_path":
-            self.output_path = value
-        else:
-            super().configure(name, value)
+        super().__init__(output_path="robocop_sonar_qube.json", config=config)
 
     def get_rule_description(self, diagnostic: Diagnostic) -> dict:
         return {
@@ -110,11 +101,4 @@ class SonarQubeReport(robocop.linter.reports.Report):
 
     def generate_report(self, diagnostics: Diagnostics, config_manager: ConfigManager, **kwargs) -> None:  # noqa: ARG002
         report = self.generate_sonarqube_report(diagnostics, config_manager.root)
-        output_path = Path(self.output_path)
-        try:
-            output_path.parent.mkdir(exist_ok=True, parents=True)
-            with open(output_path, "w") as fp:
-                json.dump(report, fp, indent=4)
-        except OSError as err:
-            raise FatalError(f"Failed to write SonarQube report to {output_path}: {err}") from None
-        print(f"Generated SonarQube report in {self.output_path}")
+        super().generate_report(report, "SonarQube")
diff --git a/src/robocop/linter/reports/text_file.py b/src/robocop/linter/reports/text_file.py
index fa73c6d..5798c9d 100644
--- a/src/robocop/linter/reports/text_file.py
+++ b/src/robocop/linter/reports/text_file.py
@@ -2,6 +2,7 @@ from pathlib import Path
 
 import robocop.linter.reports
 from robocop.config import Config
+from robocop.errors import FatalError
 from robocop.linter.diagnostics import Diagnostic, Diagnostics
 
 
@@ -47,9 +48,13 @@ class TextFile(robocop.linter.reports.Report):
                 )
                 for diagnostic in diag_by_source
             )
-        with open(self.output_path, "w") as text_file:
-            text_file.write("\n".join(messages))
-        print(f"\nGenerated text file report at {self.output_path}")
+        output_path = Path(self.output_path)
+        try:
+            output_path.parent.mkdir(exist_ok=True, parents=True)
+            with open(output_path, "w") as text_file:
+                text_file.write("\n".join(messages))
+        except OSError as err:
+            raise FatalError(f"Failed to write TextFile report to {output_path}: {err}") from None
 
     def configure(self, name: str, value: str) -> None:
         if name == "output_path":


Test patch:
diff --git a/tests/linter/reports/test_gitlab.py b/tests/linter/reports/test_gitlab.py
index 90f1de8..8f92126 100644
--- a/tests/linter/reports/test_gitlab.py
+++ b/tests/linter/reports/test_gitlab.py
@@ -59,7 +59,7 @@ class TestGitlabReport:
             },
         ]
         report = GitlabReport(config)
-        report.configure("output_path", output_file)
+        report.configure("output_path", str(output_file))
         diagnostics = Diagnostics(issues)
         # content of 1 and 2 file. 2 file use the same lines to check if fingerprint will be different
         content = [["line1", "line2"], ["line1", "line1"]]
diff --git a/tests/linter/reports/test_json_report.py b/tests/linter/reports/test_json_report.py
index 01cae1a..d588470 100644
--- a/tests/linter/reports/test_json_report.py
+++ b/tests/linter/reports/test_json_report.py
@@ -1,5 +1,4 @@
 import json
-from pathlib import Path
 
 from robocop.linter.diagnostics import Diagnostics
 from robocop.linter.reports.json_report import JsonReport
@@ -7,22 +6,17 @@ from tests.linter.reports import generate_issues
 
 
 class TestJSONReport:
-    def test_configure_output_dir(self, config):
-        output_dir = "path/to/dir"
+    def test_configure_output_path(self, config):
+        output_path = "path/to/dir/file.json"
         report = JsonReport(config)
-        report.configure("output_dir", output_dir)
-        assert report.output_dir == Path(output_dir)
-
-    def test_configure_filename(self, config):
-        filename = ".robocop.json"
-        report = JsonReport(config)
-        report.configure("report_filename", filename)
-        assert report.report_filename == filename
+        report.configure("output_path", output_path)
+        assert report.output_path == output_path
 
     def test_json_reports_saved_to_file(self, rule, rule2, tmp_path, config):
         issues = generate_issues(rule, rule2)
+        output_file = tmp_path / "robocop.json"
         report = JsonReport(config)
-        report.configure("output_dir", tmp_path)
+        report.configure("output_path", str(output_file))
 
         expected_report = [
             {
@@ -71,7 +65,6 @@ class TestJSONReport:
             },
         ]
         report.generate_report(Diagnostics(issues))
-        json_path = report.output_dir / "robocop.json"
-        with open(json_path) as fp:
+        with open(output_file) as fp:
             json_report = json.load(fp)
         assert json_report == expected_report
diff --git a/tests/linter/reports/test_sarif_report.py b/tests/linter/reports/test_sarif_report.py
index 8a01ef9..150ab47 100644
--- a/tests/linter/reports/test_sarif_report.py
+++ b/tests/linter/reports/test_sarif_report.py
@@ -4,30 +4,25 @@ from unittest.mock import Mock
 
 from robocop import __version__
 from robocop.linter.diagnostics import Diagnostic, Diagnostics
-from robocop.linter.reports.sarif_report import SarifReport
+from robocop.linter.reports.sarif import SarifReport
 from tests.linter.reports import generate_issues
 
 
 class TestSarifReport:
-    def test_configure_output_dir(self, config):
-        output_dir = "path/to/dir"
+    def test_configure_output_path(self, config):
+        output_path = "path/to/dir/.sarif.json"
         report = SarifReport(config)
-        report.configure("output_dir", output_dir)
-        assert report.output_dir == Path(output_dir)
-
-    def test_configure_filename(self, config):
-        filename = ".sarif"
-        report = SarifReport(config)
-        report.configure("report_filename", filename)
-        assert report.report_filename == filename
+        report.configure("output_path", output_path)
+        assert report.output_path == output_path
 
     def test_sarif_report(self, rule, rule2, tmp_path, config):
         root = Path.cwd()
+        output_file = tmp_path / "sarif.json"
         rules = {m.rule_id: m for m in (rule, rule2)}
         source1_rel = "tests/atest/rules/comments/ignored-data/test.robot"
         source2_rel = "tests/atest/rules/misc/empty-return/test.robot"
         report = SarifReport(config)
-        report.configure("output_dir", tmp_path)
+        report.configure("output_path", str(output_file))
 
         issues = generate_issues(rule, rule2)
 
@@ -89,7 +84,6 @@ class TestSarifReport:
         config_manager.root = root
         config_manager.default_config.linter.rules = rules
         report.generate_report(Diagnostics(issues), config_manager)
-        sarif_path = report.output_dir / report.report_filename
-        with open(sarif_path) as fp:
+        with open(output_file) as fp:
             sarif_report = json.load(fp)
         assert expected_report == sarif_report
diff --git a/tests/linter/reports/test_sonar_qube.py b/tests/linter/reports/test_sonar_qube.py
index d3d7aa7..f1de880 100644
--- a/tests/linter/reports/test_sonar_qube.py
+++ b/tests/linter/reports/test_sonar_qube.py
@@ -11,7 +11,7 @@ class TestSonarQubeReport:
     def test_sonarqube_report(self, rule, rule2, tmp_path, config):
         output_file = tmp_path / "reports" / "sonarqube.json"
         report = SonarQubeReport(config)
-        report.configure("output_path", output_file)
+        report.configure("output_path", str(output_file))
 
         issues = generate_issues(rule, rule2)