diff --git a/examples/test_data/valid_doc.sbml b/examples/test_data/valid_doc.sbml new file mode 100644 index 000000000..578594a3b --- /dev/null +++ b/examples/test_data/valid_doc.sbml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + k + S1 + c1 + + + + + + + diff --git a/pyneuroml/pynml.py b/pyneuroml/pynml.py index 703e54022..fd6309f68 100644 --- a/pyneuroml/pynml.py +++ b/pyneuroml/pynml.py @@ -144,8 +144,8 @@ def parse_arguments(): "input_files", type=str, nargs="*", - metavar="", - help="LEMS/NeuroML 2 file(s) to process", + metavar="", + help="LEMS/NeuroML 2/SBML file(s) to process", ) mut_exc_opts_grp = parser.add_argument_group( @@ -363,6 +363,16 @@ def parse_arguments(): action="store_true", help=("(Via jNeuroML) Validate NeuroML file(s) against the\n" "v1.8.1 Schema"), ) + mut_exc_opts.add_argument( + "-validate-sbml", + action="store_true", + help=("Validate SBML file(s), unit consistency failure generates a warning"), + ) + mut_exc_opts.add_argument( + "-validate-sbml-units", + action="store_true", + help=("Validate SBML file(s), unit consistency failure generates an error"), + ) return parser.parse_args() @@ -2139,6 +2149,39 @@ def evaluate_arguments(args): post_args = "" exit_on_fail = True + # Deal with the SBML validation option which doesn't call run_jneuroml + if args.validate_sbml or args.validate_sbml_units: + try: + from pyneuroml.sbml import validate_sbml_files + except Exception: + logger.critical("Unable to import pyneuroml.sbml") + sys.exit(UNKNOWN_ERR) + + if not len(args.input_files) >= 1: + logger.critical("No input files specified") + sys.exit(ARGUMENT_ERR) + + if args.validate_sbml_units: + # A failed unit consistency check generates an error + strict_units = True + else: + # A failed unit consistency check generates only a warning + strict_units = False + + try: + result = validate_sbml_files(args.input_files, strict_units) + except Exception as e: + logger.critical(f"validate_sbml_files failed with {str(e)}") + sys.exit(UNKNOWN_ERR) + + if result: + # All files validated ok (with possible warnings but no errors) + sys.exit(0) + + # Errors of some kind were found in one or more files + logger.error(f"one or more SBML files failed to validate") + sys.exit(UNKNOWN_ERR) + # These do not use the shared option where files are supplied # They require the file name to be specified after # TODO: handle these better diff --git a/pyneuroml/sbml/__init__.py b/pyneuroml/sbml/__init__.py new file mode 100644 index 000000000..c1ec3cf00 --- /dev/null +++ b/pyneuroml/sbml/__init__.py @@ -0,0 +1,94 @@ +""" +use libsbml.SBMLDocument.checkConsistency to check validaity of an SBML document +based on https://github.com/combine-org/combine-notebooks/blob/main/src/combine_notebooks/validation/validation_sbml.py +""" + +import os +import errno +import libsbml +from libsbml import SBMLReader +from typing import List + + +def validate_sbml_files(input_files: List[str], strict_units: bool = False) -> bool: + """ + validate each input file using libsbml.SBMLDocument.checkConsistency + input_files is a list of one or more filepaths + strict_units converts unit consistency warnings into errors + """ + + if not len(input_files) >= 1: + raise ValueError("No input files specified") + + all_valid = True + + for file_name in input_files: + # These checks are already implemented by SBMLReader + # But could just be logged along with the other error types rather than causing exceptions + if not os.path.isfile(file_name): + raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), file_name) + + if not os.access(file_name, os.R_OK): + raise IOError(f"Could not read SBML file {file_name}") + + try: + reader = SBMLReader() + doc = reader.readSBML(file_name) + except Exception: + # usually errors are logged within the doc object rather than being thrown + raise IOError(f"SBMLReader failed trying to open the file {file_name}") + + # Always check for unit consistency + doc.setConsistencyChecks(libsbml.LIBSBML_CAT_UNITS_CONSISTENCY, True) + doc.checkConsistency() + + # Get errors/warnings arising from the file reading or consistency checking calls above + n_errors: int = doc.getNumErrors() + errors: List[libsbml.SBMLError] = list() + warnings: List[libsbml.SBMLError] = list() + + for k in range(n_errors): + error: libsbml.SBMLError = doc.getError(k) + severity = error.getSeverity() + if (severity == libsbml.LIBSBML_SEV_ERROR) or ( + severity == libsbml.LIBSBML_SEV_FATAL + ): + errors.append(error) + elif ( + (strict_units is True) + # For error code definitions see + # https://github.com/sbmlteam/libsbml/blob/fee56c943ea39b9ac1f8491cac2fc9b3665e368f/src/sbml/SBMLError.h#L528 + # and sbml.level-3.version-2.core.release-2.pdf page 159 + and (error.getErrorId() >= 10500) + and (error.getErrorId() <= 10599) + ): + # Treat unit consistency warnings as errors + errors.append(error) + else: + warnings.append(error) + + # print results + print("-" * 80) + print(f"{'validation error(s)':<25}: {len(errors)}") + print(f"{'validation warning(s)':<25}: {len(warnings)}") + + if len(errors) > 0: + all_valid = False + print("--- errors ---") + for kerr in enumerate(errors): + print(f"E{kerr}: {error.getCategoryAsString()} L{error.getLine()}") + print( + f"[{error.getSeverityAsString()}] {error.getShortMessage()} | {error.getMessage()}" + ) + + if len(warnings) > 0: + print("--- warnings ---") + for kwarn in enumerate(warnings): + print(f"E{kwarn}: {error.getCategoryAsString()} L{error.getLine()}") + print( + f"[{error.getSeverityAsString()}] {error.getShortMessage()} | {error.getMessage()}" + ) + + print("-" * 80) + + return all_valid diff --git a/setup.cfg b/setup.cfg index 2b5104322..7fbf90f42 100644 --- a/setup.cfg +++ b/setup.cfg @@ -101,6 +101,9 @@ plotly = nsg = pynsgr +sbml = + python-libsbml + all = pyNeuroML[neuron] pyNeuroML[brian] @@ -113,6 +116,7 @@ all = pyNeuroML[vispy] pyNeuroML[plotly] pyNeuroML[nsg] + pyNeuroML[sbml] dev = pyNeuroML[all] diff --git a/test-ghactions.sh b/test-ghactions.sh index aa261a1d3..4fd7c1e84 100755 --- a/test-ghactions.sh +++ b/test-ghactions.sh @@ -70,6 +70,13 @@ pynml LEMS_NML2_Ex9_FN.xml -spineml pynml LEMS_NML2_Ex9_FN.xml -sbml +echo +echo "################################################" +echo "## Simple SBML validation example" + +pynml -validate-sbml test_data/valid_doc.sbml +pynml -validate-sbml-units test_data/valid_doc.sbml + echo echo "################################################" diff --git a/tests/sbml/test_data/inconsistent_units_doc.sbml b/tests/sbml/test_data/inconsistent_units_doc.sbml new file mode 100644 index 000000000..998a448b4 --- /dev/null +++ b/tests/sbml/test_data/inconsistent_units_doc.sbml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + k + S1 + S2 + + + + + + + diff --git a/tests/sbml/test_data/invalid_doc00.sbml b/tests/sbml/test_data/invalid_doc00.sbml new file mode 100644 index 000000000..923ce7010 --- /dev/null +++ b/tests/sbml/test_data/invalid_doc00.sbml @@ -0,0 +1,2 @@ +10 PRINT "HELLO" +20 GOTO 10 diff --git a/tests/sbml/test_data/invalid_doc01.sbml b/tests/sbml/test_data/invalid_doc01.sbml new file mode 100644 index 000000000..011c3c9b2 --- /dev/null +++ b/tests/sbml/test_data/invalid_doc01.sbml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + k + S1 + c1 + + + + + + + diff --git a/tests/sbml/test_data/invalid_doc02.sbml b/tests/sbml/test_data/invalid_doc02.sbml new file mode 100644 index 000000000..05d999017 --- /dev/null +++ b/tests/sbml/test_data/invalid_doc02.sbml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + k + S1 + c1 + + + + + + + diff --git a/tests/sbml/test_data/no_read_access.sbml b/tests/sbml/test_data/no_read_access.sbml new file mode 100644 index 000000000..011c3c9b2 --- /dev/null +++ b/tests/sbml/test_data/no_read_access.sbml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + k + S1 + c1 + + + + + + + diff --git a/tests/sbml/test_data/valid_doc.sbml b/tests/sbml/test_data/valid_doc.sbml new file mode 100644 index 000000000..578594a3b --- /dev/null +++ b/tests/sbml/test_data/valid_doc.sbml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + k + S1 + c1 + + + + + + + diff --git a/tests/sbml/test_sbml.py b/tests/sbml/test_sbml.py new file mode 100755 index 000000000..8b5afd4b6 --- /dev/null +++ b/tests/sbml/test_sbml.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 + +from pyneuroml import sbml +import os +import stat + + +def test_sbml_validate_a_valid_file(): + "ensure it validates a single valid file by returning True" + + fname = "tests/sbml/test_data/valid_doc.sbml" + result = sbml.validate_sbml_files([fname]) + assert result + + +def test_sbml_validate_missing_inputfile(): + try: + result = sbml.validate_sbml_files(["tests/sbml/test_data/nonexistent_file"]) + except FileNotFoundError: + return + except Exception: + pass + + raise Exception("failed to properly flag file not found error") + + +def test_sbml_validate_no_read_access(): + fname = "tests/sbml/test_data/no_read_access.sbml" + + # Remove read permission + os.chmod(fname, 0) + try: + result = sbml.validate_sbml_files([fname]) + except IOError: + os.chmod(fname, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) + return + except Exception: + pass + + os.chmod( + fname, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH + ) # Restore permission for git + raise Exception("failed to properly flag lack of read access") + + +def test_sbml_validate_valueerror_on_no_inputfiles(): + "ensure it raises a ValueError exception for failing to provide any files" + + try: + result = sbml.validate_sbml_files([]) + except ValueError: + return + except Exception: + pass + + raise Exception("failed to properly flag missing input files") + + +def test_sbml_validate_unit_consistency_check(): + """ + ensure it fails a unit inconsistency in strict mode + ensure it only warns about a unit inconsistency when not in strict mode + """ + + try: + result = sbml.validate_sbml_files( + ["tests/sbml/test_data/inconsistent_units_doc.sbml"], strict_units=True + ) + assert not result + except Exception: + raise Exception("failed to flag inconsistent units as an error") + + try: + result = sbml.validate_sbml_files( + ["tests/sbml/test_data/inconsistent_units_doc.sbml"], strict_units=False + ) + assert result + except Exception: + raise Exception("failed to flag inconsistent units as an error") + + +def test_sbml_validate_flag_all_invalid_files(): + """ + ensure it returns False for all invalid files + without raising any exceptions + """ + + fail_count = 0 + n_files = 3 + + for i in range(n_files): + fname = "tests/sbml/test_data/invalid_doc%02d.sbml" % i + + try: + result = sbml.validate_sbml_files([fname]) + if not result: + fail_count += 1 + except Exception: + pass + + assert fail_count == n_files + + +if __name__ == "__main__": + test_sbml_validate_validate_a_valid_file() + test_sbml_validate_valueerror_on_no_inputfiles() + test_sbml_validate_missing_inputfile() + test_sbml_validate_flag_all_invalid_files() + test_sbml_validate_unit_consistency_check() + test_sbml_validate_no_read_access()