From cc660fd4ee095b2f5cc8ece1d2a249b9ffc09356 Mon Sep 17 00:00:00 2001 From: Bill Hlavacek Date: Mon, 11 May 2026 14:17:23 -0600 Subject: [PATCH] Unify modelapi export and XML error handling --- bionetgen/modelapi/bngfile.py | 51 ++++-- bionetgen/modelapi/bngparser.py | 24 +-- bionetgen/modelapi/model.py | 19 ++- bionetgen/modelapi/xmlparsers.py | 201 ++++++++++-------------- tests/test_modelapi_export_errors.py | 222 +++++++++++++++++++++++++++ tests/test_xmlparsers_errors.py | 128 +++++++++++++++ 6 files changed, 501 insertions(+), 144 deletions(-) create mode 100644 tests/test_modelapi_export_errors.py create mode 100644 tests/test_xmlparsers_errors.py diff --git a/bionetgen/modelapi/bngfile.py b/bionetgen/modelapi/bngfile.py index 3fdc5cfb..3d735a3a 100644 --- a/bionetgen/modelapi/bngfile.py +++ b/bionetgen/modelapi/bngfile.py @@ -2,9 +2,11 @@ import os, re import shutil import tempfile +from typing import NoReturn from bionetgen.main import BioNetGen from bionetgen.core.exc import BNGFileError +from bionetgen.core.utils.logging import BNGLogger from bionetgen.core.utils.utils import find_BNG_path, run_command, ActionList # This allows access to the CLIs config setup @@ -46,6 +48,7 @@ def __init__( self, path, BNGPATH=def_bng_path, generate_network=False, suppress=True ) -> None: self.path = path + self.logger = BNGLogger() self.generate_network = generate_network self.suppress = suppress AList = ActionList() @@ -60,6 +63,11 @@ def __init__( # the top-level ActionBlock. self.parsed_protocol_actions = [] + def _raise_file_error(self, message, path=None, loc=None) -> NoReturn: + error_path = self.path if path is None else path + self.logger.error(message, loc=loc) + raise BNGFileError(error_path, message=message) + def generate_xml(self, xml_file, model_file=None) -> bool: """ generates an BNG-XML file from a given model file. Defaults @@ -85,7 +93,12 @@ def generate_xml(self, xml_file, model_file=None) -> bool: ["perl", self.bngexec, "--xml", stripped_bngl], suppress=self.suppress ) if rc != 0: - return False + msg = f"BNG-XML generation failed for {model_file}" + self._raise_file_error( + msg, + path=model_file, + loc=f"{__file__} : BNGFile.generate_xml()", + ) # we should now have the XML file path, model_name = os.path.split(stripped_bngl) @@ -102,7 +115,12 @@ def generate_xml(self, xml_file, model_file=None) -> bool: ] xml_path = preferred[0] if preferred else candidates[0] if not os.path.exists(xml_path): - return False + msg = f"BNG-XML generation did not produce an XML file for {model_file}" + self._raise_file_error( + msg, + path=model_file, + loc=f"{__file__} : BNGFile.generate_xml()", + ) with open(xml_path, "r", encoding="UTF-8") as f: content = f.read() xml_file.write(content) @@ -268,14 +286,22 @@ def write_xml(self, open_file, xml_type="bngxml", bngl_str=None) -> bool: # run with --xml # TODO: Make output supression an option somewhere if xml_type == "bngxml": + if self.bngexec is None: + msg = "BNG-XML generation requires BNG2.pl (BioNetGen) to be installed." + self._raise_file_error(msg, loc=f"{__file__} : BNGFile.write_xml()") rc, _ = run_command( ["perl", self.bngexec, "--xml", "temp.bngl"], suppress=self.suppress ) if rc != 0: - print("XML generation failed") - return False + msg = f"BNG-XML generation failed for {self.path}" + self._raise_file_error(msg, loc=f"{__file__} : BNGFile.write_xml()") else: # we should now have the XML file + if not os.path.exists("temp.xml"): + msg = "BNG-XML generation did not produce temp.xml" + self._raise_file_error( + msg, loc=f"{__file__} : BNGFile.write_xml()" + ) with open("temp.xml", "r", encoding="UTF-8") as f: content = f.read() open_file.write(content) @@ -284,25 +310,30 @@ def write_xml(self, open_file, xml_type="bngxml", bngl_str=None) -> bool: return True elif xml_type == "sbml": if self.bngexec is None: - print( + msg = ( "SBML generation requires BNG2.pl (BioNetGen) to be installed." ) - return False + self._raise_file_error(msg, loc=f"{__file__} : BNGFile.write_xml()") command = ["perl", self.bngexec, "temp.bngl"] rc, _ = run_command(command, suppress=self.suppress) if rc != 0: - print("SBML generation failed") - return False + msg = f"SBML generation failed for {self.path}" + self._raise_file_error(msg, loc=f"{__file__} : BNGFile.write_xml()") else: # we should now have the SBML file + if not os.path.exists("temp_sbml.xml"): + msg = "SBML generation did not produce temp_sbml.xml" + self._raise_file_error( + msg, loc=f"{__file__} : BNGFile.write_xml()" + ) with open("temp_sbml.xml", "r", encoding="UTF-8") as f: content = f.read() open_file.write(content) open_file.seek(0) return True else: - print("XML type {} not recognized".format(xml_type)) - return False + msg = f"XML type {xml_type} not recognized" + self._raise_file_error(msg, loc=f"{__file__} : BNGFile.write_xml()") finally: os.chdir(cur_dir) try: diff --git a/bionetgen/modelapi/bngparser.py b/bionetgen/modelapi/bngparser.py index 33fab560..0cf57039 100644 --- a/bionetgen/modelapi/bngparser.py +++ b/bionetgen/modelapi/bngparser.py @@ -1,7 +1,7 @@ import xmltodict from bionetgen.main import BioNetGen -from bionetgen.core.exc import BNGParseError, BNGModelError +from bionetgen.core.exc import BNGFileError, BNGParseError, BNGModelError from tempfile import TemporaryFile from .bngfile import BNGFile @@ -147,17 +147,19 @@ def _parse_model_bngpl(self, model_obj) -> None: # TODO: Add verbosity option to the library # print("Attempting to generate XML") with TemporaryFile("w+") as xml_file: - if self.bngfile.generate_xml(xml_file): - # TODO: Add verbosity option to the library - xmlstr = xml_file.read() - # < is not a valid XML character, we need to replace it - xmlstr = xmlstr.replace('relation="<', 'relation="<') - self.parse_xml(xmlstr, model_obj) - model_obj.reset_compilation_tags() - else: + try: + self.bngfile.generate_xml(xml_file) + except BNGFileError as exc: raise BNGModelError( - self.bngfile.path, message="XML file couldn't be generated" - ) + self.bngfile.path, + message=f"XML file couldn't be generated: {exc.message}", + ) from exc + # TODO: Add verbosity option to the library + xmlstr = xml_file.read() + # < is not a valid XML character, we need to replace it + xmlstr = xmlstr.replace('relation="<', 'relation="<') + self.parse_xml(xmlstr, model_obj) + model_obj.reset_compilation_tags() elif model_file.endswith(".xml"): with open(model_file, "r") as f: xml_str = f.read() diff --git a/bionetgen/modelapi/model.py b/bionetgen/modelapi/model.py index afe15e00..2e359a6a 100644 --- a/bionetgen/modelapi/model.py +++ b/bionetgen/modelapi/model.py @@ -1,7 +1,7 @@ import copy, tempfile, shutil from bionetgen.main import BioNetGen -from bionetgen.core.exc import BNGModelError +from bionetgen.core.exc import BNGFileError, BNGModelError from .bngparser import BNGParser from .blocks import ( @@ -393,20 +393,24 @@ def setup_simulator(self, sim_type="libRR"): self.add_action("writeSBML", {}) # temporary folder instead to make it work # with windows + tmp_folder = None try: tmp_folder = tempfile.mkdtemp() sbml_name = f"{self.model_name}_sbml.xml" # write the sbml with open(sbml_name, "w+") as f: - if not ( + try: self.bngparser.bngfile.write_xml( f, xml_type="sbml", bngl_str=str(self) ) - ): + except BNGFileError as exc: raise BNGModelError( self.model_path, - message="SBML couldn't be generated for libRR simulator", - ) + message=( + "SBML couldn't be generated for libRR simulator: " + f"{exc.message}" + ), + ) from exc self.actions.clear_actions() # get the simulator import bionetgen as bng @@ -416,8 +420,9 @@ def setup_simulator(self, sim_type="libRR"): selections = ["time"] + [obs for obs in self.observables] self.simulator.simulator.timeCourseSelections = selections finally: - shutil.rmtree(tmp_folder) - self.actions = curr_actions + if tmp_folder is not None: + shutil.rmtree(tmp_folder) + self.actions = curr_actions elif sim_type == "cpy": # get the simulator import bionetgen as bng diff --git a/bionetgen/modelapi/xmlparsers.py b/bionetgen/modelapi/xmlparsers.py index 32efec39..5ecccb37 100644 --- a/bionetgen/modelapi/xmlparsers.py +++ b/bionetgen/modelapi/xmlparsers.py @@ -1,5 +1,8 @@ import re +from typing import NoReturn +from bionetgen.core.exc import BNGParseError +from bionetgen.core.utils.logging import BNGLogger from .blocks import ParameterBlock, CompartmentBlock, ObservableBlock from .blocks import SpeciesBlock, MoleculeTypeBlock from .blocks import FunctionBlock, RuleBlock @@ -21,6 +24,56 @@ # tail of a user-defined identifier or a literal function call. _AND_OP_RE = re.compile(r"\)and\(") _OR_OP_RE = re.compile(r"\)or\(") +logger = BNGLogger() +_PARSE_SOURCE = "" + + +def _raise_parse_error(message: str, *, loc: str) -> NoReturn: + logger.error(message, loc=loc) + raise BNGParseError(_PARSE_SOURCE, message=f": {message}") + + +def _ratelaw_arg_ids(args_xml): + """Join argument ids from a ListOfArguments[N] element.""" + if not args_xml: + return "" + args = args_xml.get("Argument") if hasattr(args_xml, "get") else None + if args is None: + return "" + if isinstance(args, list): + return ",".join(str(arg["@id"]) for arg in args) + return str(args["@id"]) + + +def _resolve_ratelaw(xml, *, context: str, loc: str) -> str: + rate_type = str(xml["@type"]) + if rate_type == "Ele": + rate_cts_xml = xml["ListOfRateConstants"] + return str(rate_cts_xml["RateConstant"]["@value"]) + if rate_type == "Function": + return str(xml["@name"]) + if rate_type == "FunctionProduct": + name1 = str(xml["@name1"]) + name2 = str(xml["@name2"]) + a1 = _ratelaw_arg_ids(xml.get("ListOfArguments1")) + a2 = _ratelaw_arg_ids(xml.get("ListOfArguments2")) + return f'FunctionProduct("{name1}({a1})","{name2}({a2})")' + if rate_type in ("MM", "Sat", "Hill", "Arrhenius"): + rate_cts = rate_type + "(" + args = xml["ListOfRateConstants"]["RateConstant"] + if isinstance(args, list): + for iarg, arg in enumerate(args): + if iarg > 0: + rate_cts += "," + rate_cts += str(arg["@value"]) + else: + rate_cts += str(args["@value"]) + rate_cts += ")" + return rate_cts + _raise_parse_error( + f"Unrecognized rate law type {rate_type!r} in {context}", + loc=loc, + ) def _decode_xml_boolean_ops(expr): @@ -103,7 +156,7 @@ def get_bond_id(self, comp): comp_id = comp["@id"] try: num_bonds = int(num_bonds) - except: + except (TypeError, ValueError): # This means we have something like +/? return num_bonds # use the comp_id to find the bond index from @@ -205,10 +258,17 @@ def parse_xml(self, xml) -> Pattern: try: n = int(quantity) f = float(quantity) - if n == f: - pattern.quantity = quantity - except ValueError as e: - print("Quantity needs to be an integer") + except (TypeError, ValueError): + _raise_parse_error( + f"Pattern quantity must be an integer, got {quantity!r}", + loc=f"{__file__} : PatternXML.parse_xml()", + ) + if n != f: + _raise_parse_error( + f"Pattern quantity must be an integer, got {quantity!r}", + loc=f"{__file__} : PatternXML.parse_xml()", + ) + pattern.quantity = quantity # check for either list of molecules or single molecule, add if exist mols = xml["ListOfMolecules"]["Molecule"] molecules = [] @@ -615,8 +675,9 @@ def parse_xml(self, xml): reactants = self.resolve_rxn_side(rule["ListOfReactantPatterns"]) products = self.resolve_rxn_side(rule["ListOfProductPatterns"]) if "RateLaw" not in rule: - print( - "Rule seems to be missing a rate law, please make sure that XML exporter of BNGL supports whatever you are doing!" + _raise_parse_error( + f"Reaction rule {name!r} is missing a RateLaw entry", + loc=f"{__file__} : RuleBlockXML.parse_xml()", ) rate_constants = [self.resolve_ratelaw(rule["RateLaw"])] rule_modifier = self.get_rule_mod(rule) @@ -637,8 +698,9 @@ def parse_xml(self, xml): reactants = self.resolve_rxn_side(xml["ListOfReactantPatterns"]) products = self.resolve_rxn_side(xml["ListOfProductPatterns"]) if "RateLaw" not in xml: - print( - "Rule seems to be missing a rate law, please make sure that XML exporter of BNGL supports whatever you are doing!" + _raise_parse_error( + f"Reaction rule {name!r} is missing a RateLaw entry", + loc=f"{__file__} : RuleBlockXML.parse_xml()", ) rate_constants = [self.resolve_ratelaw(xml["RateLaw"])] rule_modifier = self.get_rule_mod(xml) @@ -656,59 +718,11 @@ def parse_xml(self, xml): return block def resolve_ratelaw(self, xml): - rate_type = xml["@type"] - if rate_type == "Ele": - rate_cts_xml = xml["ListOfRateConstants"] - rate_cts = rate_cts_xml["RateConstant"]["@value"] - elif rate_type == "Function": - rate_cts = xml["@name"] - elif rate_type == "FunctionProduct": - # Mirror BNG2.pl/Perl2/RateLaw.pm:670-677 — emit - # FunctionProduct("name1(args1)","name2(args2)") so the - # regenerated BNGL round-trips through BNG2.pl's parser - # and reaches NFsim, which supports FunctionProduct - # natively (NFinput.cpp:2251). - name1 = xml["@name1"] - name2 = xml["@name2"] - a1 = self._ratelaw_arg_ids(xml.get("ListOfArguments1")) - a2 = self._ratelaw_arg_ids(xml.get("ListOfArguments2")) - rate_cts = f'FunctionProduct("{name1}({a1})","{name2}({a2})")' - elif ( - rate_type == "MM" - or rate_type == "Sat" - or rate_type == "Hill" - or rate_type == "Arrhenius" - ): - # A function type - rate_cts = rate_type + "(" - args = xml["ListOfRateConstants"]["RateConstant"] - if isinstance(args, list): - for iarg, arg in enumerate(args): - if iarg > 0: - rate_cts += "," - rate_cts += arg["@value"] - else: - rate_cts += args["@value"] - rate_cts += ")" - else: - print("don't recognize rate law type") - return rate_cts - - def _ratelaw_arg_ids(self, args_xml): - """Join the ``@id`` of each Argument in a ListOfArguments[N] element. - - BNG-XML packs a single Argument as a dict and multiple as a list, - so we accept both shapes. Returns "" when ``args_xml`` is None - or empty so callers can render zero-arg ``f()`` consistently. - """ - if not args_xml: - return "" - args = args_xml.get("Argument") if hasattr(args_xml, "get") else None - if args is None: - return "" - if isinstance(args, list): - return ",".join(str(a["@id"]) for a in args) - return str(args["@id"]) + return _resolve_ratelaw( + xml, + context="reaction rule", + loc=f"{__file__} : RuleBlockXML.resolve_ratelaw()", + ) def resolve_rxn_side(self, xml): # this is either reactant or product @@ -745,7 +759,10 @@ def resolve_rxn_side(self, xml): sl.append(PatternXML(side).parsed_obj) return sl else: - print("Can't parse rule XML {}".format(xml)) + _raise_parse_error( + "Reaction side XML must contain ReactantPattern or ProductPattern", + loc=f"{__file__} : RuleBlockXML.resolve_rxn_side()", + ) def get_operations(self, xml): # TODO: create working operations class @@ -994,59 +1011,11 @@ def parse_xml(self, xml): return block def resolve_ratelaw(self, xml): - rate_type = xml["@type"] - if rate_type == "Ele": - rate_cts_xml = xml["ListOfRateConstants"] - rate_cts = rate_cts_xml["RateConstant"]["@value"] - elif rate_type == "Function": - rate_cts = xml["@name"] - elif rate_type == "FunctionProduct": - # Mirror BNG2.pl/Perl2/RateLaw.pm:670-677 — emit - # FunctionProduct("name1(args1)","name2(args2)") so the - # regenerated BNGL round-trips through BNG2.pl's parser - # and reaches NFsim, which supports FunctionProduct - # natively (NFinput.cpp:2251). - name1 = xml["@name1"] - name2 = xml["@name2"] - a1 = self._ratelaw_arg_ids(xml.get("ListOfArguments1")) - a2 = self._ratelaw_arg_ids(xml.get("ListOfArguments2")) - rate_cts = f'FunctionProduct("{name1}({a1})","{name2}({a2})")' - elif ( - rate_type == "MM" - or rate_type == "Sat" - or rate_type == "Hill" - or rate_type == "Arrhenius" - ): - # A function type - rate_cts = rate_type + "(" - args = xml["ListOfRateConstants"]["RateConstant"] - if isinstance(args, list): - for iarg, arg in enumerate(args): - if iarg > 0: - rate_cts += "," - rate_cts += arg["@value"] - else: - rate_cts += args["@value"] - rate_cts += ")" - else: - print("don't recognize rate law type") - return rate_cts - - def _ratelaw_arg_ids(self, args_xml): - """Join the ``@id`` of each Argument in a ListOfArguments[N] element. - - BNG-XML packs a single Argument as a dict and multiple as a list, - so we accept both shapes. Returns "" when ``args_xml`` is None - or empty so callers can render zero-arg ``f()`` consistently. - """ - if not args_xml: - return "" - args = args_xml.get("Argument") if hasattr(args_xml, "get") else None - if args is None: - return "" - if isinstance(args, list): - return ",".join(str(a["@id"]) for a in args) - return str(args["@id"]) + return _resolve_ratelaw( + xml, + context="population map", + loc=f"{__file__} : PopulationMapBlockXML.resolve_ratelaw()", + ) # TODO: Store operations! diff --git a/tests/test_modelapi_export_errors.py b/tests/test_modelapi_export_errors.py new file mode 100644 index 00000000..d9a78600 --- /dev/null +++ b/tests/test_modelapi_export_errors.py @@ -0,0 +1,222 @@ +import os +import tempfile +import textwrap +from io import StringIO +from unittest.mock import MagicMock, patch + +import pytest + +from bionetgen.modelapi.blocks import ( + ActionBlock, + CompartmentBlock, + EnergyPatternBlock, + FunctionBlock, + MoleculeTypeBlock, + ObservableBlock, + ParameterBlock, + PopulationMapBlock, + RuleBlock, + SpeciesBlock, +) + +SAMPLE_BNGL = textwrap.dedent("""\ + begin model + begin parameters + k1 0.1 + end parameters + begin molecule types + A() + end molecule types + begin species + A() 100 + end species + begin reaction rules + A() -> 0 k1 + end reaction rules + begin observables + Molecules Atot A() + end observables + end model + simulate({method=>"ode",t_end=>10,n_steps=>100}) +""") + + +def _make_model_bypass_init(): + from bionetgen.modelapi.model import bngmodel + + obj = object.__new__(bngmodel) + obj.active_blocks = [] + obj._block_order = [ + "parameters", + "compartments", + "molecule_types", + "species", + "observables", + "functions", + "energy_patterns", + "population_maps", + "rules", + "actions", + ] + obj.model_name = "test_model" + obj.model_path = "/fake/test.bngl" + obj.parameters = ParameterBlock() + obj.compartments = CompartmentBlock() + obj.molecule_types = MoleculeTypeBlock() + obj.species = SpeciesBlock() + obj.observables = ObservableBlock() + obj.functions = FunctionBlock() + obj.energy_patterns = EnergyPatternBlock() + obj.population_maps = PopulationMapBlock() + obj.rules = RuleBlock() + obj.actions = ActionBlock() + return obj + + +@patch( + "bionetgen.modelapi.bngfile.find_BNG_path", return_value=("/fake", "/fake/BNG2.pl") +) +@patch("bionetgen.modelapi.bngfile.run_command", return_value=(1, "error")) +def test_generate_xml_failure_raises_bngfile_error(mock_run, mock_find): + from bionetgen.core.exc import BNGFileError + from bionetgen.modelapi.bngfile import BNGFile + + bf = BNGFile("/some/model.bngl") + + with tempfile.TemporaryDirectory() as tmpdir: + src = os.path.join(tmpdir, "model.bngl") + with open(src, "w", encoding="UTF-8") as handle: + handle.write(SAMPLE_BNGL) + + xml_file = StringIO() + with patch.object(bf.logger, "error") as mock_error: + with pytest.raises(BNGFileError, match="BNG-XML generation failed"): + bf.generate_xml(xml_file, model_file=src) + mock_error.assert_called_once() + + +@patch( + "bionetgen.modelapi.bngfile.find_BNG_path", return_value=("/fake", "/fake/BNG2.pl") +) +@patch("bionetgen.modelapi.bngfile.run_command", return_value=(0, "")) +def test_generate_xml_missing_output_raises_bngfile_error(mock_run, mock_find): + from bionetgen.core.exc import BNGFileError + from bionetgen.modelapi.bngfile import BNGFile + + bf = BNGFile("/some/model.bngl") + + with tempfile.TemporaryDirectory() as tmpdir: + src = os.path.join(tmpdir, "model.bngl") + with open(src, "w", encoding="UTF-8") as handle: + handle.write(SAMPLE_BNGL) + + with pytest.raises(BNGFileError, match="did not produce an XML file"): + bf.generate_xml(StringIO(), model_file=src) + + +@patch("bionetgen.modelapi.bngfile.find_BNG_path", return_value=("/fake", None)) +def test_generate_xml_no_bngexec_uses_minimal_xml(mock_find): + from bionetgen.modelapi.bngfile import BNGFile + + bf = BNGFile("/some/model.bngl") + + with tempfile.TemporaryDirectory() as tmpdir: + src = os.path.join(tmpdir, "model.bngl") + with open(src, "w", encoding="UTF-8") as handle: + handle.write(SAMPLE_BNGL) + + xml_file = StringIO() + assert bf.generate_xml(xml_file, model_file=src) is True + xml_file.seek(0) + content = xml_file.read() + assert "" in content + assert '' in content + + +@patch( + "bionetgen.modelapi.bngfile.find_BNG_path", return_value=("/fake", "/fake/BNG2.pl") +) +@patch("bionetgen.modelapi.bngfile.run_command", return_value=(1, "error")) +def test_write_xml_bngxml_failure_raises_bngfile_error(mock_run, mock_find): + from bionetgen.core.exc import BNGFileError + from bionetgen.modelapi.bngfile import BNGFile + + bf = BNGFile("/some/model.bngl") + with patch.object(bf.logger, "error") as mock_error: + with pytest.raises(BNGFileError, match="BNG-XML generation failed"): + bf.write_xml( + StringIO(), xml_type="bngxml", bngl_str="begin model\nend model\n" + ) + mock_error.assert_called_once() + + +@patch("bionetgen.modelapi.bngfile.find_BNG_path", return_value=("/fake", None)) +def test_write_xml_bngxml_no_bngexec_raises_bngfile_error(mock_find): + from bionetgen.core.exc import BNGFileError + from bionetgen.modelapi.bngfile import BNGFile + + bf = BNGFile("/some/model.bngl") + with pytest.raises(BNGFileError, match="BNG-XML generation requires BNG2.pl"): + bf.write_xml(StringIO(), xml_type="bngxml", bngl_str="begin model\nend model\n") + + +@patch( + "bionetgen.modelapi.bngfile.find_BNG_path", return_value=("/fake", "/fake/BNG2.pl") +) +def test_write_xml_unknown_type_raises_bngfile_error(mock_find): + from bionetgen.core.exc import BNGFileError + from bionetgen.modelapi.bngfile import BNGFile + + bf = BNGFile("/some/model.bngl") + with pytest.raises(BNGFileError, match="XML type unknown not recognized"): + bf.write_xml( + StringIO(), xml_type="unknown", bngl_str="begin model\nend model\n" + ) + + +@patch("bionetgen.modelapi.bngfile.find_BNG_path", return_value=("/fake", None)) +def test_write_xml_sbml_no_bngexec_raises_bngfile_error(mock_find): + from bionetgen.core.exc import BNGFileError + from bionetgen.modelapi.bngfile import BNGFile + + bf = BNGFile("/some/model.bngl") + with pytest.raises(BNGFileError, match="SBML generation requires BNG2.pl"): + bf.write_xml(StringIO(), xml_type="sbml", bngl_str="begin model\nend model\n") + + +@patch("bionetgen.modelapi.bngparser.BNGFile") +def test_parse_model_xml_generation_failure_wraps_bngfile_error(mock_bngfile_cls): + from bionetgen.core.exc import BNGFileError, BNGModelError + from bionetgen.modelapi.bngparser import BNGParser + + mock_bngfile = MagicMock() + mock_bngfile.path = "/some/model.bngl" + mock_bngfile.parsed_actions = [] + mock_bngfile.generate_xml.side_effect = BNGFileError( + "/some/model.bngl", message="BNG-XML generation failed" + ) + mock_bngfile_cls.return_value = mock_bngfile + + parser = BNGParser("/some/model.bngl") + with pytest.raises(BNGModelError, match="XML file couldn't be generated"): + parser.parse_model(MagicMock()) + + +def test_setup_simulator_write_xml_failure_raises_bngmodel_error_and_restores_actions( + tmp_path, monkeypatch +): + from bionetgen.core.exc import BNGFileError, BNGModelError + + monkeypatch.chdir(tmp_path) + model = _make_model_bypass_init() + model.add_action("simulate", {"method": '"ode"'}) + model.bngparser = MagicMock() + model.bngparser.bngfile.write_xml.side_effect = BNGFileError( + model.model_path, message="SBML generation failed for /fake/test.bngl" + ) + + with pytest.raises(BNGModelError, match="SBML couldn't be generated"): + model.setup_simulator(sim_type="libRR") + + assert len(model.actions.items) == 1 + assert model.actions.items[0].type == "simulate" diff --git a/tests/test_xmlparsers_errors.py b/tests/test_xmlparsers_errors.py new file mode 100644 index 00000000..1b8b8fba --- /dev/null +++ b/tests/test_xmlparsers_errors.py @@ -0,0 +1,128 @@ +from collections import OrderedDict + +import pytest + +from bionetgen.core.exc import BNGParseError +from bionetgen.modelapi.xmlparsers import ( + PatternXML, + PopulationMapBlockXML, + RuleBlockXML, +) + + +def _simple_molecule_xml(name): + return OrderedDict([("@id", "M1"), ("@name", name)]) + + +def _simple_pattern_xml(molecules, relation=None, quantity=None): + pattern = OrderedDict() + if relation is not None and quantity is not None: + pattern["@relation"] = relation + pattern["@quantity"] = quantity + pattern["ListOfMolecules"] = OrderedDict([("Molecule", molecules)]) + return pattern + + +def _make_rate_law_xml(rate_type, value="0.5"): + if rate_type == "Function": + return OrderedDict( + [("@type", "Function"), ("@id", "rule1"), ("@name", "rate1")] + ) + return OrderedDict( + [ + ("@type", rate_type), + ( + "ListOfRateConstants", + OrderedDict([("RateConstant", OrderedDict([("@value", value)]))]), + ), + ] + ) + + +def _make_rule_xml(name="r1", reactant="A", product="B", rate_type="Ele", value="0.5"): + return OrderedDict( + [ + ("@name", name), + ( + "ListOfReactantPatterns", + OrderedDict( + [ + ( + "ReactantPattern", + _simple_pattern_xml(_simple_molecule_xml(reactant)), + ) + ] + ), + ), + ( + "ListOfProductPatterns", + OrderedDict( + [ + ( + "ProductPattern", + _simple_pattern_xml(_simple_molecule_xml(product)), + ) + ] + ), + ), + ("RateLaw", _make_rate_law_xml(rate_type, value)), + ("ListOfOperations", OrderedDict()), + ] + ) + + +def _make_population_map_xml(rate_type="Ele", value="0.5"): + return OrderedDict( + [ + ("@id", "pm1"), + ( + "StructuredSpecies", + OrderedDict( + [("Species", _simple_pattern_xml(_simple_molecule_xml("A")))] + ), + ), + ( + "PopulationSpecies", + OrderedDict( + [("Species", _simple_pattern_xml(_simple_molecule_xml("Apop")))] + ), + ), + ("RateLaw", _make_rate_law_xml(rate_type, value)), + ] + ) + + +def test_pattern_quantity_non_integer_raises_parse_error(): + pattern_xml = _simple_pattern_xml( + _simple_molecule_xml("A"), relation="==", quantity="1.5" + ) + with pytest.raises(BNGParseError, match="Pattern quantity must be an integer"): + PatternXML(pattern_xml) + + +def test_parse_rule_missing_rate_law_raises_parse_error(): + rule_xml = _make_rule_xml() + del rule_xml["RateLaw"] + with pytest.raises(BNGParseError, match="missing a RateLaw entry"): + RuleBlockXML(rule_xml) + + +def test_rule_ratelaw_unknown_type_raises_parse_error(): + rule_block = RuleBlockXML(_make_rule_xml()) + with pytest.raises(BNGParseError, match="Unrecognized rate law type"): + rule_block.resolve_ratelaw(OrderedDict([("@type", "mystery")])) + + +def test_rule_reaction_side_invalid_xml_raises_parse_error(): + rule_block = RuleBlockXML(_make_rule_xml()) + with pytest.raises( + BNGParseError, + match="Reaction side XML must contain ReactantPattern or ProductPattern", + ): + rule_block.resolve_rxn_side(OrderedDict([("NotAPattern", OrderedDict())])) + + +def test_population_map_ratelaw_unknown_type_raises_parse_error(): + population_map = PopulationMapBlockXML(_make_population_map_xml()) + with pytest.raises(BNGParseError, match="Unrecognized rate law type"): + population_map.resolve_ratelaw(OrderedDict([("@type", "mystery")]))