From 678f610931db094e7f7caa3337587424e9f274df Mon Sep 17 00:00:00 2001 From: Xuan Wang Date: Thu, 9 Apr 2026 17:57:23 -0400 Subject: [PATCH 1/3] Fix output_transitions for Python 3.13+ (PEP 667) Python 3.13 changed locals() semantics (PEP 667): bare exec() inside a function now writes to a snapshot of locals that eval() cannot see. This broke loading of models that use the "logical" file format (e.g. BREAST_CANCER, LEUKEMIA) because output_transitions() relied on exec() to create variables that eval() would read. Replace exec()/eval() with direct dict assignment and an explicit namespace, which works on all Python versions. Also rename the loop variable from `input` (shadowing the builtin) to `input_name`. Co-Authored-By: Claude Opus 4.6 (1M context) --- cana/utils.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/cana/utils.py b/cana/utils.py index ec9252c..98e7e28 100644 --- a/cana/utils.py +++ b/cana/utils.py @@ -175,12 +175,17 @@ def output_transitions(eval_line, input_list): """ total = 2 ** len(input_list) # Total combinations to try output_list = [] + # Use an explicit namespace dict for exec/eval so that dynamically + # created variables are visible across calls. In Python 3.13+ + # (PEP 667) bare exec() inside a function writes to a snapshot of + # locals that eval() cannot see. + ns = {} for i in range(total): trial_string = statenum_to_binstate(i, len(input_list)) # Evaluate trial_string by assigning value to each input variable - for j, input in enumerate(input_list): - exec(input + "=" + trial_string[j]) - output_list.append(int(eval(eval_line))) + for j, input_name in enumerate(input_list): + ns[input_name] = int(trial_string[j]) + output_list.append(int(eval(eval_line, ns))) return output_list From d9c9d512a88a6a0e66900c70e66a0c04889c8e8d Mon Sep 17 00:00:00 2001 From: Xuan Wang Date: Thu, 9 Apr 2026 18:06:42 -0400 Subject: [PATCH 2/3] Add tests for logical format parsing and output_transitions Cover the code path fixed in the previous commit: - test_output_transitions: truth tables for AND, OR, OR+NOT gates - test_from_string_boolean: parse a 3-node network from boolean rules - test_load_logical_LEUKEMIA: load a real model in logical format Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_boolean_network.py | 38 ++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/test_boolean_network.py b/tests/test_boolean_network.py index 90f2b94..f92d5df 100644 --- a/tests/test_boolean_network.py +++ b/tests/test_boolean_network.py @@ -1,6 +1,6 @@ from cana.boolean_network import BooleanNetwork import networkx as nx -from cana.datasets.bio import THALIANA +from cana.datasets.bio import THALIANA, LEUKEMIA def test_EG_weight_THALIANA(): """Test that effective graph in-degree edge weights are computed correctly.""" @@ -13,3 +13,39 @@ def test_EG_weight_THALIANA(): edgews = {edge: network._eg.edges[edge]["weight"] for edge in network._eg.edges if edge[1]==i} true.append(sum(edgews.values())) assert network.effective_indegrees() == sorted(true, reverse=True) + + +def test_output_transitions(): + """Test output_transitions produces correct truth tables.""" + from cana.utils import output_transitions + + assert output_transitions('A', ['A']) == [0, 1] + assert output_transitions('A and B', ['A', 'B']) == [0, 0, 0, 1] + assert output_transitions('A or B', ['A', 'B']) == [0, 1, 1, 1] + assert output_transitions('(A or B) and not C', ['A', 'B', 'C']) == [0, 0, 1, 0, 1, 0, 1, 0] + + +def test_from_string_boolean(): + """Test that BooleanNetwork.from_string_boolean parses logical rules.""" + rules = "\n".join([ + "A *= B", + "B *= A and C", + "C *= A or B", + ]) + network = BooleanNetwork.from_string_boolean(rules, keep_constants=True) + assert network.Nnodes == 3 + # A = copy of B: output transitions [0, 1] + assert network.nodes[0].outputs == ['0', '1'] + # B = A AND C: output transitions [0, 0, 0, 1] + assert network.nodes[1].outputs == ['0', '0', '0', '1'] + # C = A OR B: output transitions [0, 1, 1, 1] + assert network.nodes[2].outputs == ['0', '1', '1', '1'] + + +def test_load_logical_LEUKEMIA(): + """Test loading a real model in 'logical' boolean-rule format.""" + network = LEUKEMIA() + assert network.Nnodes == 60 + # Spot-check first rule: CTLA4* = TCR (identity) + assert network.nodes[0].name == 'CTLA4' + assert network.nodes[0].outputs == ['0', '1'] From cad2444516d0443543b01fae9d58053adcd76965 Mon Sep 17 00:00:00 2001 From: Xuan Wang Date: Thu, 9 Apr 2026 22:51:35 -0400 Subject: [PATCH 3/3] Harden eval() in output_transitions: restrict builtins and pre-compile - Pass {"__builtins__": {}} to eval() to disable builtin functions - Pre-compile eval_line with compile() to avoid re-parsing each iteration - Update docstring to clarify trust assumption and current implementation Co-Authored-By: Claude Opus 4.6 (1M context) --- cana/utils.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/cana/utils.py b/cana/utils.py index 98e7e28..5d1bc7f 100644 --- a/cana/utils.py +++ b/cana/utils.py @@ -142,11 +142,17 @@ def isclose(a, b, rel_tol=1e-09, abs_tol=0.0): def output_transitions(eval_line, input_list): - """Returns an output list from combinatorically trying all input values + """Returns an output list from combinatorically trying all input values. + + Each input variable is assigned every possible binary combination (0/1) + via a namespace dict, and the boolean expression is evaluated with + ``eval()`` (builtins disabled, but this is not a security boundary). + Expressions must be trusted boolean rules using ``and``, ``or``, + ``not``, and parentheses, as produced by CANA model files. Args: - eval_line (string) : logic or arithmetic line to evaluate - input_list (list) : list of input variables + eval_line (string) : boolean expression to evaluate (e.g. "A and not B") + input_list (list) : list of input variable names Returns: list of all possible output transitions (list) @@ -168,9 +174,8 @@ def output_transitions(eval_line, input_list): 110 111 - A variable is dynamically created for each member of the input list - and assigned the corresponding value from each trail string. - The original eval_line is then evaluated with each assignment + Each input variable is assigned the corresponding value from each + trial string via a namespace dict, and the expression is evaluated which results in the output list [0, 0, 1, 0, 1, 0, 1, 0] """ total = 2 ** len(input_list) # Total combinations to try @@ -180,12 +185,14 @@ def output_transitions(eval_line, input_list): # (PEP 667) bare exec() inside a function writes to a snapshot of # locals that eval() cannot see. ns = {} + safe_globals = {"__builtins__": {}} + code = compile(eval_line.strip(), "", "eval") for i in range(total): trial_string = statenum_to_binstate(i, len(input_list)) # Evaluate trial_string by assigning value to each input variable for j, input_name in enumerate(input_list): ns[input_name] = int(trial_string[j]) - output_list.append(int(eval(eval_line, ns))) + output_list.append(int(eval(code, safe_globals, ns))) return output_list