Critical:
- rename nodes/ -> mp_nodes/, shared/ -> mp_shared/ to avoid shadowing
ComfyUI core's nodes.py via sys.path injection (root cause of v0.1.1
'nodes is not a package' startup failure)
- http_post now streams response and enforces 50MB cap mid-stream (DoS)
- AES key derivation: HKDF -> PBKDF2-HMAC-SHA256 600k iters + random salt
(work factor against weak-key brute force; ciphertext format changed)
- fs_mkdir/copy/move/delete confined to ComfyUI input/output/temp + ~ by
default; UTILITY_MEGAPACK_ALLOW_ARBITRARY_PATHS=1 to override
UX / correctness:
- combo settings now declare options array (themes dropdown was empty)
- LATENT default 1x4x8x8 zeros -> {} (avoids Flux/SD3 shape crashes)
- @op decorator rejects reserved widget names mode/theme at register time
- _pad_outputs docstring updated to match relaxed opaque-type behavior
153 lines
4.5 KiB
Python
153 lines
4.5 KiB
Python
"""Control flow primitives: for/while/if/eval/compare/null-coalesce/switch."""
|
|
|
|
import json
|
|
|
|
from simpleeval import SimpleEval, NameNotDefined
|
|
|
|
from .. import op
|
|
|
|
|
|
_MAX_ITERS_HARD_CEILING = 100_000
|
|
|
|
|
|
def _safe_eval(expr: str, names: dict | None = None):
|
|
"""Evaluate `expr` via SimpleEval. Reject dunder access. Return the value."""
|
|
if "__" in expr:
|
|
raise ValueError("expression contains forbidden '__' substring")
|
|
s = SimpleEval(names=names or {})
|
|
return s.eval(expr)
|
|
|
|
|
|
@op(
|
|
op_id="for_loop",
|
|
display_name="For Loop",
|
|
category="Control Flow",
|
|
input_schema={"required": {
|
|
"start": ("INT", {"default": 0}),
|
|
"end": ("INT", {"default": 10}),
|
|
"step": ("INT", {"default": 1, "min": 1, "max": 1_000_000}),
|
|
}},
|
|
output_indices=(4,),
|
|
description="Build the list [start, start+step, ..., end-step].",
|
|
)
|
|
def for_loop(self, start, end, step=1):
|
|
return (list(range(int(start), int(end), int(step))),)
|
|
|
|
|
|
@op(
|
|
op_id="while_loop",
|
|
display_name="While Loop",
|
|
category="Control Flow",
|
|
input_schema={"required": {
|
|
"expr": ("STRING", {"default": "i<10"}),
|
|
"max_iters": ("INT", {"default": 1000, "min": 1, "max": _MAX_ITERS_HARD_CEILING}),
|
|
}},
|
|
output_indices=(4,),
|
|
description="Iterate while `expr` is truthy. `i` is the counter starting at 0. Returns [0, 1, ..., final-1].",
|
|
)
|
|
def while_loop(self, expr, max_iters=1000):
|
|
cap = min(int(max_iters), _MAX_ITERS_HARD_CEILING)
|
|
out = []
|
|
for i in range(cap):
|
|
if not _safe_eval(expr, {"i": i}):
|
|
break
|
|
out.append(i)
|
|
return (out,)
|
|
|
|
|
|
@op(
|
|
op_id="if_else",
|
|
display_name="If / Else",
|
|
category="Control Flow",
|
|
input_schema={"required": {
|
|
"condition": ("BOOLEAN", {"default": False}),
|
|
"when_true": ("STRING", {"default": ""}),
|
|
"when_false": ("STRING", {"default": ""}),
|
|
}},
|
|
output_indices=(0,),
|
|
)
|
|
def if_else(self, condition, when_true, when_false):
|
|
return (when_true if bool(condition) else when_false,)
|
|
|
|
|
|
_COMPARE_OPS = {"<": lambda a, b: a < b, ">": lambda a, b: a > b,
|
|
"<=": lambda a, b: a <= b, ">=": lambda a, b: a >= b,
|
|
"==": lambda a, b: a == b, "!=": lambda a, b: a != b}
|
|
|
|
|
|
@op(
|
|
op_id="compare",
|
|
display_name="Compare",
|
|
category="Control Flow",
|
|
input_schema={"required": {
|
|
"a": ("FLOAT", {"default": 0.0}),
|
|
"op": (list(_COMPARE_OPS.keys()), {"default": "=="}),
|
|
"b": ("FLOAT", {"default": 0.0}),
|
|
}},
|
|
output_indices=(3,),
|
|
)
|
|
def compare(self, a, op, b):
|
|
if op not in _COMPARE_OPS:
|
|
raise ValueError(f"unknown compare op '{op}' (allowed: {list(_COMPARE_OPS)})")
|
|
return (_COMPARE_OPS[op](float(a), float(b)),)
|
|
|
|
|
|
@op(
|
|
op_id="eval_expr",
|
|
display_name="Eval Expression",
|
|
category="Control Flow",
|
|
input_schema={"required": {
|
|
"expr": ("STRING", {"default": "1 + 1"}),
|
|
"vars_json": ("STRING", {"default": "{}"}),
|
|
}},
|
|
output_indices=(6,),
|
|
description="Sandboxed expression evaluator (simpleeval). `vars_json` is parsed as JSON for variable bindings.",
|
|
)
|
|
def eval_expr(self, expr, vars_json="{}"):
|
|
names = json.loads(vars_json) if vars_json else {}
|
|
if not isinstance(names, dict):
|
|
raise ValueError("vars_json must decode to an object")
|
|
try:
|
|
return (_safe_eval(expr, names),)
|
|
except NameNotDefined as e:
|
|
raise ValueError(f"undefined name in expression: {e}") from e
|
|
|
|
|
|
@op(
|
|
op_id="null_coalesce",
|
|
display_name="Null Coalesce",
|
|
category="Control Flow",
|
|
input_schema={"required": {
|
|
"a": ("STRING", {"default": ""}),
|
|
"b": ("STRING", {"default": ""}),
|
|
"c": ("STRING", {"default": ""}),
|
|
}},
|
|
output_indices=(0,),
|
|
description="Returns the first non-empty string out of a, b, c. Empty if all empty.",
|
|
)
|
|
def null_coalesce(self, a, b, c=""):
|
|
for v in (a, b, c):
|
|
if v:
|
|
return (v,)
|
|
return ("",)
|
|
|
|
|
|
@op(
|
|
op_id="switch_case",
|
|
display_name="Switch / Case",
|
|
category="Control Flow",
|
|
input_schema={"required": {
|
|
"value": ("STRING", {"default": ""}),
|
|
"cases_json": ("STRING", {"default": '{"_": ""}'}),
|
|
}},
|
|
output_indices=(0,),
|
|
description='cases_json is a JSON object mapping case-string → result-string. "_" is the default.',
|
|
)
|
|
def switch_case(self, value, cases_json='{"_":""}'):
|
|
cases = json.loads(cases_json) if cases_json else {}
|
|
if not isinstance(cases, dict):
|
|
raise ValueError("cases_json must decode to an object")
|
|
if value in cases:
|
|
return (str(cases[value]),)
|
|
return (str(cases.get("_", "")),)
|