diff --git a/README.md b/README.md
index b5232b4..b6f2629 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,12 @@ From Pypi:
pip install qc_baselib
```
+From Github repository:
+
+```bash
+pip install qc_baselib @ git+https://github.com/asam-ev/qc-baselib-py@main
+```
+
Locally for developing using [Poetry](https://python-poetry.org/):
```bash
@@ -187,24 +193,32 @@ def main():
summary="Executed evaluation",
)
- result.register_issue(
+ rule_uid = result.register_rule(
+ checker_bundle_name="TestBundle",
+ checker_id="TestChecker",
+ emanating_entity="test.com",
+ standard="qc",
+ definition_setting="1.0.0",
+ rule_full_name="qwerty.qwerty",
+ )
+
+ issue_id = result.register_issue(
checker_bundle_name="TestBundle",
checker_id="TestChecker",
- issue_id=0,
description="Issue found at odr",
level=IssueSeverity.INFORMATION,
+ rule_uid=rule_uid,
)
result.add_file_location(
checker_bundle_name="TestBundle",
checker_id="TestChecker",
- issue_id=0,
+ issue_id=issue_id,
row=1,
column=0,
file_type="odr",
description="Location for issue",
)
- # xml location are also supported
result.write_to_file("testResults.xqar")
@@ -290,6 +304,8 @@ Issue id: 0
Issue level: 3
```
+For more use case examples refer to the library [tests](tests/).
+
## Tests
- Install module on development mode
diff --git a/qc_baselib/__init__.py b/qc_baselib/__init__.py
index b3478b6..35caa3a 100644
--- a/qc_baselib/__init__.py
+++ b/qc_baselib/__init__.py
@@ -9,3 +9,4 @@
from .configuration import Configuration as Configuration
from .result import Result as Result
from .models import IssueSeverity as IssueSeverity
+from .models import StatusType as StatusType
diff --git a/qc_baselib/models/__init__.py b/qc_baselib/models/__init__.py
index e06e3f1..08cf31d 100644
--- a/qc_baselib/models/__init__.py
+++ b/qc_baselib/models/__init__.py
@@ -1 +1,2 @@
from .common import IssueSeverity as IssueSeverity
+from .result import StatusType as StatusType
diff --git a/qc_baselib/models/result.py b/qc_baselib/models/result.py
index df6a4f8..70afcd7 100644
--- a/qc_baselib/models/result.py
+++ b/qc_baselib/models/result.py
@@ -2,9 +2,11 @@
# This Source Code Form is subject to the terms of the Mozilla
# Public License, v. 2.0. If a copy of the MPL was not distributed
# with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
-from typing import List, Any
+import enum
+
+from typing import List, Any, Set, Dict
from pydantic import model_validator
-from pydantic_xml import BaseXmlModel, attr
+from pydantic_xml import BaseXmlModel, attr, element
from .common import ParamType, IssueSeverity
@@ -18,10 +20,10 @@ class XMLLocationType(BaseXmlModel, tag="XMLLocation"):
xpath: str = attr(name="xpath")
-class RoadLocationType(BaseXmlModel, tag="RoadLocation"):
- road_id: int = attr(name="roadId")
- t: str = attr(name="t")
- s: str = attr(name="s")
+class InertialLocationType(BaseXmlModel, tag="InertialLocation"):
+ x: float
+ y: float
+ z: float
class FileLocationType(BaseXmlModel, tag="FileLocation"):
@@ -33,7 +35,7 @@ class FileLocationType(BaseXmlModel, tag="FileLocation"):
class LocationType(BaseXmlModel, tag="Location"):
file_location: List[FileLocationType] = []
xml_location: List[XMLLocationType] = []
- road_location: List[RoadLocationType] = []
+ road_location: List[InertialLocationType] = []
description: str = attr(name="description")
@model_validator(mode="after")
@@ -48,19 +50,160 @@ def check_at_least_one_element(self) -> Any:
return self
+class RuleType(BaseXmlModel, tag="AddressedRule"):
+ """
+ Type containing the Rule Schema rules and its required checks
+
+ More information at:
+ https://github.com/asam-ev/qc-framework/blob/main/doc/manual/rule_uid_schema.md
+ """
+
+ # The current implementation makes Rule members required, so no element can
+ # be left empty for the uid composition.
+
+ emanating_entity: str = attr(
+ name="emanating_entity", default="", pattern=r"^((\w+(\.\w+)+))$", exclude=True
+ )
+ standard: str = attr(
+ name="standard", default="", pattern=r"^(([a-z]+))$", exclude=True
+ )
+ definition_setting: str = attr(
+ name="definition_setting",
+ default="",
+ pattern=r"^(([0-9]+(\.[0-9]+)+))$",
+ exclude=True,
+ )
+ rule_full_name: str = attr(
+ name="rule_full_name",
+ default="",
+ pattern=r"^((([a-z][\w_]*)\.)*)([a-z][\w_]*)$",
+ exclude=True,
+ )
+
+ rule_uid: str = attr(
+ name="ruleUID",
+ default="",
+ pattern=r"^((\w+(\.\w+)+)):(([a-z]+)):(([0-9]+(\.[0-9]+)+)):((([a-z][\w_]*)\.)*)([a-z][\w_]*)$",
+ )
+
+ @model_validator(mode="after")
+ def load_fields_into_uid(self) -> Any:
+ """
+ Loads fields into rule uid if all required fields are present.
+ Otherwise it skips initialization.
+ """
+ if (
+ self.emanating_entity != ""
+ and self.standard != ""
+ and self.definition_setting != ""
+ and self.rule_full_name != ""
+ ):
+ self.rule_uid = f"{self.emanating_entity}:{self.standard}:{self.definition_setting}:{self.rule_full_name}"
+
+ return self
+
+ @model_validator(mode="after")
+ def load_uid_into_fields(self) -> Any:
+ """
+ Loads fields from rule uid if no field is present in the model.
+ Otherwise it skips initialization.
+ """
+ if (
+ self.emanating_entity == ""
+ and self.standard == ""
+ and self.definition_setting == ""
+ and self.rule_full_name == ""
+ ):
+ elements = self.rule_uid.split(":")
+
+ if len(elements) < 4:
+ raise ValueError(
+ "Not enough elements to parse Rule UID. This should follow pattern described at https://github.com/asam-ev/qc-framework/blob/main/doc/manual/rule_uid_schema.md"
+ )
+
+ self.emanating_entity = elements[0]
+ self.standard = elements[1]
+ self.definition_setting = elements[2]
+ self.rule_full_name = elements[3]
+
+ return self
+
+ @model_validator(mode="after")
+ def check_any_empty(self) -> Any:
+ """
+ Validates if any field is empty after initialization. No field should
+ be leave empty after a successful initialization happens.
+ """
+ if self.rule_uid == "":
+ raise ValueError("Empty initialization of rule_uid")
+ if self.emanating_entity == "":
+ raise ValueError("Empty initialization of emanating_entity")
+ if self.standard == "":
+ raise ValueError("Empty initialization of standard")
+ if self.definition_setting == "":
+ raise ValueError("Empty initialization of definition_setting")
+ if self.rule_full_name == "":
+ raise ValueError("Empty initialization of rule_full_name")
+
+ return self
+
+
class IssueType(BaseXmlModel, tag="Issue"):
locations: List[LocationType] = []
issue_id: int = attr(name="issueId")
description: str = attr(name="description")
level: IssueSeverity = attr(name="level")
+ rule_uid: str = attr(
+ name="ruleUID",
+ default="",
+ pattern=r"^((\w+(\.\w+)+)):(([a-z]+)):(([0-9]+(\.[0-9]+)+)):((([a-z][\w_]*)\.)*)([a-z][\w_]*)$",
+ )
+
+
+class MetadataType(BaseXmlModel, tag="Metadata"):
+ key: str = attr(name="key")
+ value: str = attr(name="value")
+ description: str = attr(name="description")
+
+
+class StatusType(str, enum.Enum):
+ COMPLETED = "completed"
+ ERROR = "error"
+ SKIPPED = "skipped"
-class CheckerType(BaseXmlModel, tag="Checker"):
+class CheckerType(BaseXmlModel, tag="Checker", validate_assignment=True):
+ addressed_rule: List[RuleType] = []
issues: List[IssueType] = []
+ metadata: List[MetadataType] = []
+ status: StatusType = attr(name="status", default="")
checker_id: str = attr(name="checkerId")
description: str = attr(name="description")
summary: str = attr(name="summary")
+ @model_validator(mode="after")
+ def check_issue_ruleUID_matches_addressed_rules(self) -> Any:
+ if len(self.issues):
+ addressed_rule_uids: Set[int] = set()
+
+ for addressed_rule in self.addressed_rule:
+ addressed_rule_uids.add(addressed_rule.rule_uid)
+
+ for issue in self.issues:
+ if issue.rule_uid not in addressed_rule_uids:
+ raise ValueError(
+ f"Issue Rule UID '{issue.rule_uid}' does not match addressed rules UIDs {list(addressed_rule_uids)}"
+ )
+ return self
+
+ @model_validator(mode="after")
+ def check_skipped_status_containing_issues(self) -> Any:
+ if self.status == StatusType.SKIPPED and len(self.issues) > 0:
+ raise ValueError(
+ f"{self.checker_id}\nCheckers with skipped status cannot contain issues. Issues found: {len(self.issues)}"
+ )
+ return self
+
class CheckerBundleType(BaseXmlModel, tag="CheckerBundle"):
params: List[ParamType] = []
diff --git a/qc_baselib/result.py b/qc_baselib/result.py
index 8822848..e08bf47 100644
--- a/qc_baselib/result.py
+++ b/qc_baselib/result.py
@@ -170,7 +170,11 @@ def register_checker_bundle(
self._report_results.checker_bundles.append(bundle)
def register_checker(
- self, checker_bundle_name: str, checker_id: str, description: str, summary: str
+ self,
+ checker_bundle_name: str,
+ checker_id: str,
+ description: str,
+ summary: str,
) -> None:
checker = result.CheckerType(
@@ -181,12 +185,40 @@ def register_checker(
bundle.checkers.append(checker)
+ def register_rule(
+ self,
+ checker_bundle_name: str,
+ checker_id: str,
+ emanating_entity: str,
+ standard: str,
+ definition_setting: str,
+ rule_full_name: str,
+ ) -> str:
+ """
+ Rule will be registered to checker and the generated rule uid will be
+ returned.
+ """
+
+ rule = result.RuleType(
+ emanating_entity=emanating_entity,
+ standard=standard,
+ definition_setting=definition_setting,
+ rule_full_name=rule_full_name,
+ )
+
+ bundle = self._get_checker_bundle(checker_bundle_name=checker_bundle_name)
+ checker = self._get_checker(bundle=bundle, checker_id=checker_id)
+ checker.addressed_rule.append(rule)
+
+ return rule.rule_uid
+
def register_issue(
self,
checker_bundle_name: str,
checker_id: str,
description: str,
level: IssueSeverity,
+ rule_uid: str,
) -> int:
"""
Issue will be registered to checker and the generated issue id will be
@@ -195,7 +227,7 @@ def register_issue(
issue_id = self._id_manager.get_next_free_id()
issue = result.IssueType(
- issue_id=issue_id, description=description, level=level
+ issue_id=issue_id, description=description, level=level, rule_uid=rule_uid
)
bundle = self._get_checker_bundle(checker_bundle_name=checker_bundle_name)
@@ -204,6 +236,10 @@ def register_issue(
checker.issues.append(issue)
+ # Validation need to be triggered to check if no schema relation was
+ # violated by the new issue addition.
+ result.CheckerType.model_validate(checker)
+
return issue_id
def add_file_location(
@@ -250,6 +286,14 @@ def add_xml_location(
result.LocationType(xml_location=[xml_location], description=description)
)
+ def set_checker_status(
+ self, checker_bundle_name: str, checker_id: str, status: result.StatusType
+ ) -> None:
+ bundle = self._get_checker_bundle(checker_bundle_name=checker_bundle_name)
+ checker = self._get_checker(bundle=bundle, checker_id=checker_id)
+ checker.status = status
+ result.CheckerType.model_validate(checker)
+
def get_result_version(self) -> str:
return self._report_results.version
diff --git a/tests/data/demo_checker_bundle.xqar b/tests/data/demo_checker_bundle.xqar
index 0e3eac7..a927af1 100644
--- a/tests/data/demo_checker_bundle.xqar
+++ b/tests/data/demo_checker_bundle.xqar
@@ -3,7 +3,8 @@
-
+
+
diff --git a/tests/data/demo_checker_bundle_extended.xqar b/tests/data/demo_checker_bundle_extended.xqar
new file mode 100644
index 0000000..604b6a3
--- /dev/null
+++ b/tests/data/demo_checker_bundle_extended.xqar
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/data/result_test_output.xqar b/tests/data/result_test_output.xqar
index 9d4ed63..59d1129 100644
--- a/tests/data/result_test_output.xqar
+++ b/tests/data/result_test_output.xqar
@@ -1,8 +1,9 @@
-
-
+
+
+
diff --git a/tests/test_result.py b/tests/test_result.py
index 9c41f42..ca382ef 100644
--- a/tests/test_result.py
+++ b/tests/test_result.py
@@ -1,10 +1,12 @@
import os
import pytest
+from pydantic_core import ValidationError
from qc_baselib.models import config, result
-from qc_baselib import Result, IssueSeverity
+from qc_baselib import Result, IssueSeverity, StatusType
DEMO_REPORT_PATH = "tests/data/demo_checker_bundle.xqar"
+EXTENDED_DEMO_REPORT_PATH = "tests/data/demo_checker_bundle_extended.xqar"
EXAMPLE_OUTPUT_REPORT_PATH = "tests/data/result_test_output.xqar"
TEST_REPORT_OUTPUT_PATH = "tests/result_test_output.xqar"
@@ -22,6 +24,12 @@ def test_load_result_from_file() -> None:
assert len(result._report_results.to_xml()) > 0
+def test_load_result_from_extended_file() -> None:
+ result = Result()
+ result.load_from_file(EXTENDED_DEMO_REPORT_PATH)
+ assert len(result._report_results.to_xml()) > 0
+
+
def test_result_write() -> None:
result = Result()
@@ -40,11 +48,21 @@ def test_result_write() -> None:
summary="Executed evaluation",
)
+ rule_uid = result.register_rule(
+ checker_bundle_name="TestBundle",
+ checker_id="TestChecker",
+ emanating_entity="test.com",
+ standard="qc",
+ definition_setting="1.0.0",
+ rule_full_name="qwerty.qwerty",
+ )
+
issue_id = result.register_issue(
checker_bundle_name="TestBundle",
checker_id="TestChecker",
description="Issue found at odr",
level=IssueSeverity.INFORMATION,
+ rule_uid=rule_uid,
)
result.add_file_location(
@@ -64,6 +82,12 @@ def test_result_write() -> None:
description="Location for issue",
)
+ result.set_checker_status(
+ checker_bundle_name="TestBundle",
+ checker_id="TestChecker",
+ status=StatusType.COMPLETED,
+ )
+
result.write_to_file(TEST_REPORT_OUTPUT_PATH)
example_xml_text = ""
@@ -148,11 +172,21 @@ def test_result_register_issue_id_generation() -> None:
summary="Executed evaluation",
)
+ rule_uid = result.register_rule(
+ checker_bundle_name="TestBundle",
+ checker_id="TestChecker",
+ emanating_entity="test.com",
+ standard="qc",
+ definition_setting="1.0.0",
+ rule_full_name="qwerty.qwerty",
+ )
+
issue_id_0 = result.register_issue(
checker_bundle_name="TestBundle",
checker_id="TestChecker",
description="Issue found at odr",
level=IssueSeverity.INFORMATION,
+ rule_uid=rule_uid,
)
issue_id_1 = result.register_issue(
@@ -160,7 +194,156 @@ def test_result_register_issue_id_generation() -> None:
checker_id="TestChecker",
description="Issue found at odr",
level=IssueSeverity.INFORMATION,
+ rule_uid=rule_uid,
)
assert issue_id_0 != issue_id_1
assert issue_id_0 == issue_id_1 - 1
+
+
+def test_create_issue_with_unregistered_rule_id() -> None:
+ result = Result()
+
+ result.register_checker_bundle(
+ name="TestBundle",
+ build_date="2024-05-31",
+ description="Example checker bundle",
+ version="0.0.1",
+ summary="Tested example checkers",
+ )
+
+ result.register_checker(
+ checker_bundle_name="TestBundle",
+ checker_id="TestChecker",
+ description="Test checker",
+ summary="Executed evaluation",
+ )
+
+ with pytest.raises(
+ ValidationError,
+ match=r".* Issue Rule UID 'test.com:qc:1.0.0:qwerty.qwerty' does not match addressed rules UIDs \[\].*",
+ ) as exc_info:
+ issue_id_0 = result.register_issue(
+ checker_bundle_name="TestBundle",
+ checker_id="TestChecker",
+ description="Issue found at odr",
+ level=IssueSeverity.INFORMATION,
+ rule_uid="test.com:qc:1.0.0:qwerty.qwerty",
+ )
+
+
+def test_create_rule_id_validation() -> None:
+ result = Result()
+
+ result.register_checker_bundle(
+ name="TestBundle",
+ build_date="2024-05-31",
+ description="Example checker bundle",
+ version="0.0.1",
+ summary="Tested example checkers",
+ )
+
+ result.register_checker(
+ checker_bundle_name="TestBundle",
+ checker_id="TestChecker",
+ description="Test checker",
+ summary="Executed evaluation",
+ )
+
+ with pytest.raises(
+ ValidationError,
+ match=r".*\nemanating_entity\n.* String should match pattern .*",
+ ) as exc_info:
+ rule_uid = result.register_rule(
+ checker_bundle_name="TestBundle",
+ checker_id="TestChecker",
+ emanating_entity="",
+ standard="qc",
+ definition_setting="1.0.0",
+ rule_full_name="qwerty.qwerty",
+ )
+
+ with pytest.raises(
+ ValidationError,
+ match=r".*\nstandard\n.* String should match pattern .*",
+ ) as exc_info:
+ rule_uid = result.register_rule(
+ checker_bundle_name="TestBundle",
+ checker_id="TestChecker",
+ emanating_entity="test.com",
+ standard="",
+ definition_setting="1.0.0",
+ rule_full_name="qwerty.qwerty",
+ )
+
+ with pytest.raises(
+ ValidationError,
+ match=r".*\ndefinition_setting\n.* String should match pattern .*",
+ ) as exc_info:
+ rule_uid = result.register_rule(
+ checker_bundle_name="TestBundle",
+ checker_id="TestChecker",
+ emanating_entity="test.com",
+ standard="qc",
+ definition_setting="",
+ rule_full_name="qwerty.qwerty",
+ )
+
+ with pytest.raises(
+ ValidationError,
+ match=r".*\nrule_full_name\n.* String should match pattern .*",
+ ) as exc_info:
+ rule_uid = result.register_rule(
+ checker_bundle_name="TestBundle",
+ checker_id="TestChecker",
+ emanating_entity="test.com",
+ standard="qc",
+ definition_setting="1.0.0",
+ rule_full_name="",
+ )
+
+
+def test_set_checker_status_skipped_with_issues() -> None:
+ result = Result()
+
+ result.register_checker_bundle(
+ name="TestBundle",
+ build_date="2024-05-31",
+ description="Example checker bundle",
+ version="0.0.1",
+ summary="Tested example checkers",
+ )
+
+ result.register_checker(
+ checker_bundle_name="TestBundle",
+ checker_id="TestChecker",
+ description="Test checker",
+ summary="Executed evaluation",
+ )
+
+ rule_uid = result.register_rule(
+ checker_bundle_name="TestBundle",
+ checker_id="TestChecker",
+ emanating_entity="test.com",
+ standard="qc",
+ definition_setting="1.0.0",
+ rule_full_name="qwerty.qwerty",
+ )
+
+ issue_id_0 = result.register_issue(
+ checker_bundle_name="TestBundle",
+ checker_id="TestChecker",
+ description="Issue found at odr",
+ level=IssueSeverity.INFORMATION,
+ rule_uid=rule_uid,
+ )
+
+ with pytest.raises(
+ ValidationError,
+ match=r".*\nCheckers with skipped status cannot contain issues\. .*",
+ ) as exc_info:
+ result.set_checker_status(
+ checker_bundle_name="TestBundle",
+ checker_id="TestChecker",
+ status=StatusType.SKIPPED,
+ )