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, + )