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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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()