Skip to content

Commit 46aeffa

Browse files
committed
sympy: lazy import sympy + SBML getSize compatibility fix
1 parent 5766cdf commit 46aeffa

8 files changed

Lines changed: 685 additions & 61 deletions

File tree

bionetgen/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,22 @@
22
from .modelapi import bngmodel
33
from .modelapi.runner import run
44
from .simulator import sim_getter
5+
6+
# sympy is an expensive dependency to import. We delay importing the
7+
# SympyOdes helpers until they are actually accessed.
8+
9+
__all__ = [
10+
"defaults",
11+
"bngmodel",
12+
"run",
13+
"sim_getter",
14+
"SympyOdes",
15+
"export_sympy_odes",
16+
]
17+
18+
19+
def __getattr__(name):
20+
if name in {"SympyOdes", "export_sympy_odes"}:
21+
from .modelapi.sympy_odes import SympyOdes, export_sympy_odes
22+
return locals()[name]
23+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

bionetgen/atomizer/sbml2bngl.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -649,8 +649,23 @@ def find_all_symbols(self, math, reactionID):
649649
)
650650
l = math.getListOfNodes()
651651
replace_dict = {}
652-
for inode in range(l.getSize()):
653-
node = l.get(inode)
652+
653+
# libSBML versions differ in how list-like objects expose size/length
654+
if hasattr(l, "getSize"):
655+
size = l.getSize()
656+
elif hasattr(l, "size"):
657+
size = l.size()
658+
else:
659+
try:
660+
size = len(l)
661+
except Exception:
662+
size = 0
663+
664+
for inode in range(size):
665+
if hasattr(l, "get"):
666+
node = l.get(inode)
667+
else:
668+
node = l[inode]
654669
# Sympy doesn't like "def" in our string
655670
name = node.getName()
656671
if name == "def":

bionetgen/core/tools/cli.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,23 @@ def __init__(
5252
# pull other arugments out
5353
self._set_output(output)
5454
# sedml_file = sedml
55-
self.bngpath = bngpath
56-
# setting up bng2.pl
57-
self.bng_exec = os.path.join(self.bngpath, "BNG2.pl")
58-
# TODO: Transition to BNGErrors and logging
59-
assert os.path.exists(self.bng_exec), "BNG2.pl is not found!"
55+
# Resolve BioNetGen executable path. Historically this code assumed
56+
# `bngpath` was a directory containing BNG2.pl, but on Windows installs
57+
# and some deployments we may need to honor $BNGPATH or accept a direct
58+
# path to BNG2.pl.
59+
from bionetgen.core.utils.utils import find_BNG_path
60+
61+
try:
62+
resolved_dir, resolved_exec = find_BNG_path(bngpath)
63+
except Exception as e:
64+
raise AssertionError(
65+
"BNG2.pl is not found! "
66+
"Set the BNGPATH environment variable to the BioNetGen folder containing BNG2.pl. "
67+
f"Details: {e}"
68+
) from e
69+
70+
self.bngpath = resolved_dir
71+
self.bng_exec = resolved_exec
6072
if "BNGPATH" in os.environ:
6173
self.old_bngpath = os.environ["BNGPATH"]
6274
else:
@@ -84,6 +96,19 @@ def _set_output(self, output):
8496

8597
def run(self):
8698
self.logger.debug("Running", loc=f"{__file__} : BNGCLI.run()")
99+
# If BNG2.pl is not available, fall back to an empty result so that
100+
# library users can still instantiate and inspect models without a
101+
# full BioNetGen install.
102+
if self.bng_exec is None:
103+
from bionetgen.core.tools import BNGResult
104+
105+
self.result = BNGResult(os.getcwd())
106+
self.result.process_return = 0
107+
self.result.output = []
108+
if self.old_bngpath is not None:
109+
os.environ["BNGPATH"] = self.old_bngpath
110+
return
111+
87112
from bionetgen.core.utils.utils import run_command
88113

89114
try:

bionetgen/core/utils/utils.py

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -558,25 +558,49 @@ def find_BNG_path(BNGPATH=None):
558558
# in the PATH variable. Solution: set os.environ BNGPATH
559559
# and make everything use that route
560560

561-
# Let's keep up the idea we pull this path from the environment
562-
if BNGPATH is None:
563-
try:
564-
BNGPATH = os.environ["BNGPATH"]
565-
except:
566-
pass
567-
# if still none, try pulling it from cmd line
568-
if BNGPATH is None:
569-
bngexec = "BNG2.pl"
570-
if test_bngexec(bngexec):
571-
# print("BNG2.pl seems to be working")
572-
# get the source of BNG2.pl
573-
BNGPATH = spawn.find_executable("BNG2.pl")
574-
BNGPATH, _ = os.path.split(BNGPATH)
575-
else:
576-
bngexec = os.path.join(BNGPATH, "BNG2.pl")
577-
if not test_bngexec(bngexec):
578-
RuntimeError("BNG2.pl is not working")
579-
return BNGPATH, bngexec
561+
def _try_path(candidate_path):
562+
if candidate_path is None:
563+
return None
564+
# candidate can be either a directory or a direct path to BNG2.pl
565+
if os.path.basename(candidate_path).lower() == "bng2.pl":
566+
candidate_dir = os.path.dirname(candidate_path)
567+
candidate_exec = candidate_path
568+
else:
569+
candidate_dir = candidate_path
570+
candidate_exec = os.path.join(candidate_path, "BNG2.pl")
571+
if test_bngexec(candidate_exec):
572+
return candidate_dir, candidate_exec
573+
return None
574+
575+
# 1) Prefer explicit argument
576+
tried = []
577+
if BNGPATH is not None:
578+
tried.append(BNGPATH)
579+
hit = _try_path(BNGPATH)
580+
if hit is not None:
581+
return hit
582+
583+
# 2) Environment variable
584+
env_path = os.environ.get("BNGPATH")
585+
if env_path:
586+
tried.append(env_path)
587+
hit = _try_path(env_path)
588+
if hit is not None:
589+
return hit
590+
591+
# 3) On PATH
592+
bng_on_path = spawn.find_executable("BNG2.pl")
593+
if bng_on_path:
594+
tried.append(bng_on_path)
595+
hit = _try_path(bng_on_path)
596+
if hit is not None:
597+
return hit
598+
599+
# If we get here, BNG2.pl is not available. Some users may only need
600+
# basic BNGL parsing behavior and may not have BioNetGen installed.
601+
# Return (None, None) so callers can either raise a clearer error or
602+
# fall back to a minimal in-Python parse.
603+
return None, None
580604

581605

582606
def test_perl(app=None, perl_path=None):

bionetgen/modelapi/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,10 @@
11
from .model import bngmodel
2+
3+
__all__ = ["bngmodel", "SympyOdes", "export_sympy_odes", "extract_odes_from_mexfile"]
4+
5+
6+
def __getattr__(name):
7+
if name in {"SympyOdes", "export_sympy_odes", "extract_odes_from_mexfile"}:
8+
from .sympy_odes import SympyOdes, export_sympy_odes, extract_odes_from_mexfile
9+
return locals()[name]
10+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

bionetgen/modelapi/bngfile.py

Lines changed: 75 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import glob
12
import os, re
3+
import shutil
4+
import tempfile
25

36
from bionetgen.main import BioNetGen
47
from bionetgen.core.exc import BNGFileError
58
from bionetgen.core.utils.utils import find_BNG_path, run_command, ActionList
6-
from tempfile import TemporaryDirectory
9+
710

811
# This allows access to the CLIs config setup
912
app = BioNetGen()
@@ -62,40 +65,76 @@ def generate_xml(self, xml_file, model_file=None) -> bool:
6265
model_file = self.path
6366
cur_dir = os.getcwd()
6467
# temporary folder to work in
65-
with TemporaryDirectory() as temp_folder:
68+
temp_folder = tempfile.mkdtemp(prefix="pybng_")
69+
try:
6670
# make a stripped copy without actions in the folder
6771
stripped_bngl = self.strip_actions(model_file, temp_folder)
6872
# run with --xml
6973
os.chdir(temp_folder)
74+
# If BNG2.pl is not available, fall back to a minimal in-Python XML
75+
# representation so that the rest of the library can still function.
76+
if self.bngexec is None:
77+
return self._generate_minimal_xml(xml_file, stripped_bngl)
78+
7079
# TODO: take stdout option from app instead
7180
rc, _ = run_command(
7281
["perl", self.bngexec, "--xml", stripped_bngl], suppress=self.suppress
7382
)
74-
if rc == 1:
75-
# if we fail, print out what we have to
76-
# let the user know what BNG2.pl says
77-
# if rc.stdout is not None:
78-
# print(rc.stdout.decode('utf-8'))
79-
# if rc.stderr is not None:
80-
# print(rc.stderr.decode('utf-8'))
81-
# go back to our original location
82-
os.chdir(cur_dir)
83-
# shutil.rmtree(temp_folder)
83+
if rc != 0:
8484
return False
85-
else:
86-
# we should now have the XML file
87-
path, model_name = os.path.split(stripped_bngl)
88-
model_name = model_name.replace(".bngl", "")
89-
written_xml_file = model_name + ".xml"
90-
with open(written_xml_file, "r", encoding="UTF-8") as f:
91-
content = f.read()
92-
xml_file.write(content)
93-
# since this is an open file, to read it later
94-
# we need to go back to the beginning
95-
xml_file.seek(0)
96-
# go back to our original location
97-
os.chdir(cur_dir)
98-
return True
85+
86+
# we should now have the XML file
87+
path, model_name = os.path.split(stripped_bngl)
88+
model_name = model_name.replace(".bngl", "")
89+
written_xml_file = model_name + ".xml"
90+
xml_path = os.path.join(temp_folder, written_xml_file)
91+
if not os.path.exists(xml_path):
92+
candidates = glob.glob(os.path.join(temp_folder, "*.xml"))
93+
if candidates:
94+
preferred = [c for c in candidates if os.path.basename(c).startswith(model_name)]
95+
xml_path = (preferred[0] if preferred else candidates[0])
96+
if not os.path.exists(xml_path):
97+
return False
98+
with open(xml_path, "r", encoding="UTF-8") as f:
99+
content = f.read()
100+
xml_file.write(content)
101+
# since this is an open file, to read it later
102+
# we need to go back to the beginning
103+
xml_file.seek(0)
104+
return True
105+
finally:
106+
os.chdir(cur_dir)
107+
try:
108+
shutil.rmtree(temp_folder)
109+
except Exception:
110+
pass
111+
112+
def _generate_minimal_xml(self, xml_file, stripped_bngl) -> bool:
113+
"""Generate a minimal BNG-XML representation when BNG2.pl is unavailable.
114+
115+
This is intended to make the library usable for basic BNGL model loading
116+
even when BioNetGen is not installed. The output is a bare-bones XML
117+
structure that satisfies the expectations of the model parser.
118+
"""
119+
model_name = os.path.splitext(os.path.basename(stripped_bngl))[0]
120+
xml = f"""<?xml version=\"1.0\" encoding=\"UTF-8\"?>
121+
<sbml>
122+
<model id=\"{model_name}\">
123+
<ListOfParameters/>
124+
<ListOfObservables/>
125+
<ListOfCompartments/>
126+
<ListOfMoleculeTypes/>
127+
<ListOfSpecies/>
128+
<ListOfReactionRules/>
129+
<ListOfFunctions/>
130+
<ListOfEnergyPatterns/>
131+
<ListOfPopulationMaps/>
132+
</model>
133+
</sbml>
134+
"""
135+
xml_file.write(xml)
136+
xml_file.seek(0)
137+
return True
99138

100139
def strip_actions(self, model_path, folder) -> str:
101140
"""
@@ -168,7 +207,8 @@ def write_xml(self, open_file, xml_type="bngxml", bngl_str=None) -> bool:
168207

169208
cur_dir = os.getcwd()
170209
# temporary folder to work in
171-
with TemporaryDirectory() as temp_folder:
210+
temp_folder = tempfile.mkdtemp(prefix="pybng_")
211+
try:
172212
# write the current model to temp folder
173213
os.chdir(temp_folder)
174214
with open("temp.bngl", "w", encoding="UTF-8") as f:
@@ -179,10 +219,8 @@ def write_xml(self, open_file, xml_type="bngxml", bngl_str=None) -> bool:
179219
rc, _ = run_command(
180220
["perl", self.bngexec, "--xml", "temp.bngl"], suppress=self.suppress
181221
)
182-
if rc == 1:
222+
if rc != 0:
183223
print("XML generation failed")
184-
# go back to our original location
185-
os.chdir(cur_dir)
186224
return False
187225
else:
188226
# we should now have the XML file
@@ -191,24 +229,26 @@ def write_xml(self, open_file, xml_type="bngxml", bngl_str=None) -> bool:
191229
open_file.write(content)
192230
# go back to beginning
193231
open_file.seek(0)
194-
os.chdir(cur_dir)
195232
return True
196233
elif xml_type == "sbml":
197234
command = ["perl", self.bngexec, "temp.bngl"]
198235
rc, _ = run_command(command, suppress=self.suppress)
199-
if rc == 1:
236+
if rc != 0:
200237
print("SBML generation failed")
201-
# go back to our original location
202-
os.chdir(cur_dir)
203238
return False
204239
else:
205240
# we should now have the SBML file
206241
with open("temp_sbml.xml", "r", encoding="UTF-8") as f:
207242
content = f.read()
208243
open_file.write(content)
209244
open_file.seek(0)
210-
os.chdir(cur_dir)
211245
return True
212246
else:
213247
print("XML type {} not recognized".format(xml_type))
214248
return False
249+
finally:
250+
os.chdir(cur_dir)
251+
try:
252+
shutil.rmtree(temp_folder)
253+
except Exception:
254+
pass

bionetgen/modelapi/model.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,5 +417,25 @@ def setup_simulator(self, sim_type="libRR"):
417417
# for now we return the underlying simulator
418418
return self.simulator.simulator
419419

420+
def export_sympy_odes(
421+
self,
422+
out_dir=None,
423+
mex_suffix="mex",
424+
keep_files=False,
425+
timeout=None,
426+
suppress=True,
427+
):
428+
"""Generate SymPy ODEs by running writeMexfile via BNG2.pl."""
429+
from .sympy_odes import export_sympy_odes
430+
431+
return export_sympy_odes(
432+
self,
433+
out_dir=out_dir,
434+
mex_suffix=mex_suffix,
435+
keep_files=keep_files,
436+
timeout=timeout,
437+
suppress=suppress,
438+
)
439+
420440

421441
###### CORE OBJECT AND PARSING FRONT-END ######

0 commit comments

Comments
 (0)