Skip to content

Commit bd19901

Browse files
committed
Merge branch 'main' into modelapi-centralize-action-validation
2 parents 7010070 + cd03d2d commit bd19901

8 files changed

Lines changed: 521 additions & 186 deletions

File tree

bionetgen/core/utils/utils.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ def __init__(self):
105105
"max_agg",
106106
"max_iter",
107107
"max_stoich",
108+
"check_iso",
108109
"TextReaction",
109110
"TextSpecies",
110111
]
@@ -578,7 +579,10 @@ def define_parser(self):
578579
arg_type_list = "[" + pp.delimitedList((quote_word ^ arg_type_float)) + "]"
579580
arg_type_string = quote_word
580581
#
581-
curly_arg_token = quote_word + "=>" + arg_type_int
582+
# BNGL/Perl `=>` auto-quotes its left operand, so dict keys
583+
# may be either bareword (max_stoich=>{R=>6}) or quoted
584+
# (max_stoich=>{"R"=>6}). Accept both.
585+
curly_arg_token = (base_name ^ quote_word) + "=>" + arg_type_int
582586
# TODO: handle 0 case
583587
arg_type_curly = "{" + pp.delimitedList(curly_arg_token) + "}"
584588
arg_types = (
@@ -746,20 +750,18 @@ def run_command(command, suppress=True, timeout=None, cwd=None):
746750
be killed.
747751
"""
748752
if timeout is not None:
749-
if suppress:
750-
# I am unsure how to do both timeout and the live polling of stdo
751-
rc = subprocess.run(
752-
command,
753-
timeout=timeout,
754-
stdout=subprocess.DEVNULL,
755-
stderr=subprocess.DEVNULL,
756-
cwd=cwd,
757-
)
758-
return rc.returncode, rc
759-
else:
760-
# I am unsure how to do both timeout and the live polling of stdo
761-
rc = subprocess.run(command, timeout=timeout, capture_output=True, cwd=cwd)
762-
return rc.returncode, rc
753+
# Always capture stdout/stderr — this lets callers (notably BNGCLI)
754+
# surface BNG2.pl's error tail in BNGRunError when the command
755+
# fails. With timeout set, subprocess.run buffers all output
756+
# anyway, so suppress=True vs False makes no behavioral difference
757+
# for the user during the run.
758+
rc = subprocess.run(
759+
command,
760+
timeout=timeout,
761+
capture_output=True,
762+
cwd=cwd,
763+
)
764+
return rc.returncode, rc
763765
else:
764766
if suppress:
765767
process = subprocess.Popen(

bionetgen/modelapi/blocks.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,30 @@ def gen_string(self) -> str:
645645
return "\n".join(block_lines)
646646

647647

648+
class ProtocolBlock(ActionBlock):
649+
"""
650+
Protocol block object, subclass of ActionBlock.
651+
652+
Protocol lines live inside ``begin model``/``end model`` and must
653+
retain their own begin/end block wrapper rather than being rendered
654+
as top-level actions.
655+
"""
656+
657+
def __init__(self) -> None:
658+
super().__init__()
659+
self.name = "protocol"
660+
661+
def add_item(self, item_tpl) -> None:
662+
_, value = item_tpl
663+
self.items.append(value)
664+
665+
def gen_string(self) -> str:
666+
block_lines = ["\nbegin protocol"]
667+
block_lines.extend(item.print_line() for item in self.items)
668+
block_lines.append("end protocol\n")
669+
return "\n".join(block_lines)
670+
671+
648672
class EnergyPatternBlock(ModelBlock):
649673
"""
650674
Energy pattern block object, subclass of ModelBlock.

bionetgen/modelapi/bngfile.py

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ def __init__(
5454
self.BNGPATH = BNGPATH
5555
self.bngexec = bngexec
5656
self.parsed_actions = []
57+
# Actions that live inside a ``begin protocol``/``end protocol``
58+
# block, stored separately from top-level actions so BNGParser can
59+
# round-trip them into a ProtocolBlock instead of folding them into
60+
# the top-level ActionBlock.
61+
self.parsed_protocol_actions = []
5762

5863
def generate_xml(self, xml_file, model_file=None) -> bool:
5964
"""
@@ -150,17 +155,51 @@ def strip_actions(self, model_path, folder) -> str:
150155
with open(model_path, "r", encoding="UTF-8") as mf:
151156
# read and strip actions
152157
mstr = mf.read()
153-
# TODO: Clean this up _a lot_
154-
# this removes any new line escapes (\ \n) to continue
155-
# to another line, so we can just remove the action lines
156-
mstr = re.sub(r"\\\n", "", mstr)
158+
# Collapse `\<newline>` line continuations before stripping
159+
# action lines, so the action parser sees the same logical
160+
# command boundaries as BNG.
161+
#
162+
# Only collapse `\` that appears before any `#` on its line.
163+
# A continuation marker after the comment introducer is part
164+
# of the comment body in BNG2.pl — collapsing it would glue
165+
# the next physical line (often a real definition) into the
166+
# comment, dropping it from the model. Repro: a commented-out
167+
# `# foo()=if(t<42,0,\` immediately above a live
168+
# `foo()=if(t<42,9.899,\` definition would silently lose the
169+
# live function from the regenerated `.bngl` (and from any
170+
# `.net` BNG2.pl generated downstream).
171+
mstr = re.sub(r"^([^#\n]*)\\\n", r"\1", mstr, flags=re.MULTILINE)
157172
mlines = mstr.split("\n")
158-
stripped_lines = list(filter(lambda x: self._not_action(x), mlines))
159-
# remove spaces, actions don't allow them
160-
self.parsed_actions = [
161-
x.replace(" ", "")
162-
for x in filter(lambda x: not self._not_action(x), mlines)
163-
]
173+
# Walk the lines once, separating non-action content (kept in the
174+
# stripped output for BNG2.pl) from action-shaped lines, and
175+
# further splitting action-shaped lines based on whether they sit
176+
# inside a ``begin protocol``/``end protocol`` block. Protocol
177+
# actions are tracked separately so BNGParser can round-trip them
178+
# into a ProtocolBlock instead of the top-level ActionBlock.
179+
self.parsed_actions = []
180+
self.parsed_protocol_actions = []
181+
stripped_lines = []
182+
in_protocol = False
183+
for line in mlines:
184+
if re.match(r"\s*(begin)\s+(protocol)\b", line):
185+
in_protocol = True
186+
stripped_lines.append(line)
187+
continue
188+
if re.match(r"\s*(end)\s+(protocol)\b", line):
189+
in_protocol = False
190+
stripped_lines.append(line)
191+
continue
192+
if self._not_action(line):
193+
stripped_lines.append(line)
194+
continue
195+
# Hand the action line off to BNGParser intact — quoted
196+
# spans (e.g. ``param=>"-v -gml 1000000"``) need to survive
197+
# the whitespace-collapse pass, which `_normalize_action_text`
198+
# does in a quote-aware way.
199+
if in_protocol:
200+
self.parsed_protocol_actions.append(line)
201+
else:
202+
self.parsed_actions.append(line)
164203
# let's remove begin/end actions, rarely used but should be removed
165204
remove_from = -1
166205
remove_to = -1
@@ -192,8 +231,14 @@ def strip_actions(self, model_path, folder) -> str:
192231
return stripped_model
193232

194233
def _not_action(self, line) -> bool:
234+
# Anchor the match to the start of the (left-stripped) line so that
235+
# user identifiers containing an action name as a substring — most
236+
# commonly ``conversion()`` (the substring ``version(`` matches the
237+
# ``version`` action) inside a ``begin functions`` block — aren't
238+
# misclassified and pulled out as actions.
239+
stripped = line.lstrip()
195240
for action in self._action_list:
196-
if action in line:
241+
if stripped.startswith(action):
197242
return False
198243
return True
199244

0 commit comments

Comments
 (0)