diff --git a/CHANGELOG.md b/CHANGELOG.md index ebda2fa..bc19be6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ The Reasoner Validator package is evolving along with progress in TRAPI and Biolink Model standards within the NCATS Biomedical Knowledge Translator. + +## 3.8.4 +- Non-destructive TRAPIResponseValidator.check_compliance_of_trapi_response(response) validation of TRAPI Response JSON (release 3.8.3 bug removed Message) + ## 3.8.3 - Fixed TRAPI release management to cache TRAPI GitHub code release and branch tags locally - in a **versions.yaml** file - to avoid Git API calling denial of service issues; Small **scripts/trapi_release.py** utility script provided to update the **versions.yaml** file, as periodically necessary. - YAML file management tech debt cleaned up a tiny bit. Tweaked a couple of validation codes for this reason. diff --git a/poetry.lock b/poetry.lock index a77dbcc..904bc25 100644 --- a/poetry.lock +++ b/poetry.lock @@ -315,6 +315,23 @@ files = [ [package.dependencies] packaging = "*" +[[package]] +name = "dictdiffer" +version = "0.9.0" +description = "Dictdiffer is a library that helps you to diff and patch dictionaries." +optional = false +python-versions = "*" +files = [ + {file = "dictdiffer-0.9.0-py2.py3-none-any.whl", hash = "sha256:442bfc693cfcadaf46674575d2eba1c53b42f5e404218ca2c2ff549f2df56595"}, + {file = "dictdiffer-0.9.0.tar.gz", hash = "sha256:17bacf5fbfe613ccf1b6d512bd766e6b21fb798822a133aa86098b8ac9997578"}, +] + +[package.extras] +all = ["Sphinx (>=3)", "check-manifest (>=0.42)", "mock (>=1.3.0)", "numpy (>=1.13.0)", "numpy (>=1.15.0)", "numpy (>=1.18.0)", "numpy (>=1.20.0)", "pytest (==5.4.3)", "pytest (>=6)", "pytest-cov (>=2.10.1)", "pytest-isort (>=1.2.0)", "pytest-pycodestyle (>=2)", "pytest-pycodestyle (>=2.2.0)", "pytest-pydocstyle (>=2)", "pytest-pydocstyle (>=2.2.0)", "sphinx (>=3)", "sphinx-rtd-theme (>=0.2)", "tox (>=3.7.0)"] +docs = ["Sphinx (>=3)", "sphinx-rtd-theme (>=0.2)"] +numpy = ["numpy (>=1.13.0)", "numpy (>=1.15.0)", "numpy (>=1.18.0)", "numpy (>=1.20.0)"] +tests = ["check-manifest (>=0.42)", "mock (>=1.3.0)", "pytest (==5.4.3)", "pytest (>=6)", "pytest-cov (>=2.10.1)", "pytest-isort (>=1.2.0)", "pytest-pycodestyle (>=2)", "pytest-pycodestyle (>=2.2.0)", "pytest-pydocstyle (>=2)", "pytest-pydocstyle (>=2.2.0)", "sphinx (>=3)", "tox (>=3.7.0)"] + [[package]] name = "docutils" version = "0.18.1" @@ -1500,4 +1517,4 @@ web = ["fastapi", "uvicorn"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "960c16b393a1dcc7b75cb45054d0ad62aa7f72915f8721f9eda48e9140387462" +content-hash = "bbf381674dead50f32e647760707420716f81a86cf1f7c55556323a03197331f" diff --git a/pyproject.toml b/pyproject.toml index 18b43af..4b6854a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "reasoner-validator" -version = "3.8.3" +version = "3.8.4" description = "Validation tools for Reasoner API" authors = [ "Richard Bruskiewich ", @@ -48,7 +48,7 @@ bmt = "^1.1.1" # since 4.18.0 appeared to break something for the # access and processing of JSON schemata jsonschema = "~4.17.3" - +dictdiffer = "^0.9.0" PyYAML = "^6.0" requests = "^2.28.1" pydantic = "^1.10.11" diff --git a/reasoner_validator/validator.py b/reasoner_validator/validator.py index 3a01b3e..55fea59 100644 --- a/reasoner_validator/validator.py +++ b/reasoner_validator/validator.py @@ -150,48 +150,55 @@ def check_compliance_of_trapi_response( # nothing more to validate? return - # here, we split the message out from the Response - # checking along the way whether it is empty + # Here, we split the TRAPI Response.Message out from the other + # Response components, to allow for independent TRAPI Schema + # validation of those non-Message components versus the Message + # itself (checking along the way whether the Message is empty) message: Optional[Dict] = response.pop('message') - if not message: - # This is valid TRAPI but reported as an error, - # and not interesting for further validation - if not self.suppress_empty_data_warnings: - self.report("error.trapi.response.message.empty") - - # ... also, nothing more here to validate? - return - # we insert a stub to enable TRAPI validation - # of the remainder of the Response + # we insert a stub to enable TRAPI schema + # validation of the remainder of the Response response['message'] = {} + if message: - # TRAPI JSON specified versions override default versions - if "schema_version" in response and response["schema_version"]: - if self.default_trapi: - self.trapi_version = get_latest_version(response["schema_version"]) + # TRAPI JSON specified versions override default versions + if "schema_version" in response and response["schema_version"]: + if self.default_trapi: + self.trapi_version = get_latest_version(response["schema_version"]) - if "biolink_version" in response and response["biolink_version"]: - if self.default_biolink: - self.bmt = get_biolink_model_toolkit(biolink_version=response["biolink_version"]) - self.biolink_version = self.bmt.get_model_version() + if "biolink_version" in response and response["biolink_version"]: + if self.default_biolink: + self.bmt = get_biolink_model_toolkit(biolink_version=response["biolink_version"]) + self.biolink_version = self.bmt.get_model_version() - response = self.sanitize_trapi_response(response) + response = self.sanitize_trapi_response(response) - self.is_valid_trapi_query(instance=response, component="Response") - if self.has_critical(): - # we abort further processing here due to detected critical global validation errors? - return + self.is_valid_trapi_query(instance=response, component="Response") + if not self.has_critical(): + + status: Optional[str] = response['status'] if 'status' in response else None + if status and status not in ["OK", "Success", "QueryNotTraversable", "KPsNotAvailable"]: + self.report("warning.trapi.response.status.unknown", identifier=status) - status: Optional[str] = response['status'] if 'status' in response else None - if status and status not in ["OK", "Success", "QueryNotTraversable", "KPsNotAvailable"]: - self.report("warning.trapi.response.status.unknown", identifier=status) + # Sequentially validate the Query Graph, Knowledge Graph then validate + # the Results (which rely on the validity of the other two components) + elif self.has_valid_query_graph(message) and \ + self.has_valid_knowledge_graph(message, max_kg_edges): + self.has_valid_results(message, max_results) + + # else: + # we don't validate further if it has + # critical Response level errors + + else: + # Empty Message is valid TRAPI but reported as an error + # in the validation and not interesting for further validation + if not self.suppress_empty_data_warnings: + self.report("error.trapi.response.message.empty") - # Sequentially validate the Query Graph, Knowledge Graph then validate - # the Results (which rely on the validity of the other two components) - elif self.has_valid_query_graph(message) and \ - self.has_valid_knowledge_graph(message, max_kg_edges): - self.has_valid_results(message, max_results) + # Reconstitute the original Message + # to the Response before returning + response['message'] = message @staticmethod def sample_results(results: List, sample_size: int = 0) -> List: diff --git a/tests/test_response_validator.py b/tests/test_response_validator.py index ebcd1e3..30c3a77 100644 --- a/tests/test_response_validator.py +++ b/tests/test_response_validator.py @@ -1,7 +1,7 @@ """ Unit tests for the generic (shared) components of the SRI Testing Framework """ -from typing import Tuple, Dict +from typing import Dict from sys import stderr import logging @@ -12,6 +12,8 @@ import pytest +from dictdiffer import diff + from reasoner_validator.trapi import TRAPI_1_3_0, TRAPI_1_4_2 from reasoner_validator.validator import TRAPIResponseValidator from reasoner_validator.report import TRAPIGraphType @@ -431,6 +433,16 @@ def test_sanitize_trapi_query(response: Dict): } +# this unit test checks that the original response object is returned verbatim +def test_conservation_of_response_object(): + validator: TRAPIResponseValidator = TRAPIResponseValidator() + input_response = deepcopy(_TEST_TRAPI_1_4_2_FULL_SAMPLE) + reference_response = deepcopy(_TEST_TRAPI_1_4_2_FULL_SAMPLE) + assert input_response == reference_response + validator.check_compliance_of_trapi_response(response=input_response) + assert not list(diff(input_response, reference_response)) + + @pytest.mark.parametrize( "edges_limit,number_of_nodes_returned,number_of_edges_returned", [