From efe477287e958ff61c1d91d072e550ff22a2eebf Mon Sep 17 00:00:00 2001 From: StableLlama Date: Thu, 5 Jun 2025 23:24:47 +0200 Subject: [PATCH 1/8] Add set extension node --- README.md | 1 + src/basic_data_handling/path_nodes.py | 33 +++++++++++++++++++++++++++ tests/test_path_nodes.py | 20 +++++++++++++++- 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9b4c178..9f45a9c 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ These nodes are very lightweight and require no additional dependencies. - is_absolute - Checks if a path is absolute (begins at root directory) - get_size - Returns the size of a file in bytes - get_extension - Extracts the file extension from a path (including the dot) + - set_extension - Replaces or adds a file extension to a path - Directory operations: - list_dir - Lists files and directories in a specified path with filtering options - get_cwd - Returns the current working directory diff --git a/src/basic_data_handling/path_nodes.py b/src/basic_data_handling/path_nodes.py index 3846f1f..a7af9e9 100644 --- a/src/basic_data_handling/path_nodes.py +++ b/src/basic_data_handling/path_nodes.py @@ -453,6 +453,37 @@ def normalize_path(self, path: str) -> tuple[str]: return (os.path.normpath(path),) +class PathSetExtension(ComfyNodeABC): + """ + Sets the file extension for a path. + + This node replaces the current extension in a path with a new one. + The extension should include the dot (e.g., '.jpg'). + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "path": (IO.STRING, {}), + "extension": (IO.STRING, {"default": ".txt"}), + } + } + + RETURN_TYPES = (IO.STRING,) + RETURN_NAMES = ("path",) + CATEGORY = "Basic/Path" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "set_extension" + + def set_extension(self, path: str, extension: str) -> tuple[str]: + # Make sure extension starts with a dot + if not extension.startswith('.') and extension: + extension = '.' + extension + + root, _ = os.path.splitext(path) + return (root + extension,) + + class PathRelative(ComfyNodeABC): """ Returns a relative path. @@ -542,6 +573,7 @@ def split_ext(self, path: str) -> tuple[str, str]: "Basic data handling: PathExpandVars": PathExpandVars, "Basic data handling: PathGetCwd": PathGetCwd, "Basic data handling: PathGetExtension": PathGetExtension, + "Basic data handling: PathSetExtension": PathSetExtension, "Basic data handling: PathGetSize": PathGetSize, "Basic data handling: PathGlob": PathGlob, "Basic data handling: PathIsAbsolute": PathIsAbsolute, @@ -564,6 +596,7 @@ def split_ext(self, path: str) -> tuple[str, str]: "Basic data handling: PathExpandVars": "expand vars", "Basic data handling: PathGetCwd": "get current working directory", "Basic data handling: PathGetExtension": "get extension", + "Basic data handling: PathSetExtension": "set extension", "Basic data handling: PathGetSize": "get size", "Basic data handling: PathGlob": "glob", "Basic data handling: PathIsAbsolute": "is absolute", diff --git a/tests/test_path_nodes.py b/tests/test_path_nodes.py index 6f68ce9..0052253 100644 --- a/tests/test_path_nodes.py +++ b/tests/test_path_nodes.py @@ -5,7 +5,7 @@ from src.basic_data_handling.path_nodes import ( PathJoin, PathAbspath, PathExists, PathIsFile, PathIsDir, PathGetSize, PathSplit, PathSplitExt, PathBasename, PathDirname, PathGetExtension, - PathNormalize, PathRelative, PathGlob, PathExpandVars, PathGetCwd, + PathSetExtension, PathNormalize, PathRelative, PathGlob, PathExpandVars, PathGetCwd, PathListDir, PathIsAbsolute, PathCommonPrefix, ) @@ -199,6 +199,24 @@ def test_path_get_extension(): # Test with empty string assert node.get_extension("") == ("",) +def test_path_set_extension(): + node = PathSetExtension() + # Test basic extension replacement + assert node.set_extension("file.txt", ".jpg") == ("file.jpg",) + # Test adding extension to file without extension + assert node.set_extension("file", ".png") == ("file.png",) + # Test with extension without dot prefix + assert node.set_extension("document.docx", "pdf") == ("document.pdf",) + # Test with path containing directory + assert node.set_extension("folder/image.jpg", ".png") == ("folder/image.png",) + # Test with multiple dots in filename + assert node.set_extension("archive.tar.gz", ".zip") == ("archive.tar.zip",) + # Test with empty extension (removes extension) + assert node.set_extension("config.ini", "") == ("config",) + # Test with empty string as path + assert node.set_extension("", ".txt") == (".txt",) + + def test_path_normalize(): node = PathNormalize() From b1dd34175659db22513eb110932112cba90fffb3 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Fri, 6 Jun 2025 22:41:06 +0200 Subject: [PATCH 2/8] Remove the experimental marking --- src/basic_data_handling/control_flow_nodes.py | 2 -- src/basic_data_handling/data_list_nodes.py | 4 ---- src/basic_data_handling/dict_nodes.py | 5 ----- src/basic_data_handling/list_nodes.py | 4 ---- src/basic_data_handling/set_nodes.py | 4 ---- 5 files changed, 19 deletions(-) diff --git a/src/basic_data_handling/control_flow_nodes.py b/src/basic_data_handling/control_flow_nodes.py index ef4ee4b..a18d53a 100644 --- a/src/basic_data_handling/control_flow_nodes.py +++ b/src/basic_data_handling/control_flow_nodes.py @@ -64,7 +64,6 @@ class IfElifElse(ComfyNodeABC): When none is true, the value of the else is returned. This allows conditional data flow in ComfyUI workflows. """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): return { @@ -149,7 +148,6 @@ class SwitchCase(ComfyNodeABC): NOTE: This version of the node will most likely be deprecated in the future. """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): diff --git a/src/basic_data_handling/data_list_nodes.py b/src/basic_data_handling/data_list_nodes.py index c3ffa58..5da7b9c 100644 --- a/src/basic_data_handling/data_list_nodes.py +++ b/src/basic_data_handling/data_list_nodes.py @@ -52,7 +52,6 @@ class DataListCreateFromBoolean(ComfyNodeABC): This node creates and returns a Data List. The list of items is dynamically extended based on the number of inputs provided. """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): return { @@ -80,7 +79,6 @@ class DataListCreateFromFloat(ComfyNodeABC): This node creates and returns a Data List. The list of items is dynamically extended based on the number of inputs provided. """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): return { @@ -108,7 +106,6 @@ class DataListCreateFromInt(ComfyNodeABC): This node creates and returns a Data List. The list of items is dynamically extended based on the number of inputs provided. """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): return { @@ -136,7 +133,6 @@ class DataListCreateFromString(ComfyNodeABC): This node creates and returns a Data List. The list of items is dynamically extended based on the number of inputs provided. """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): return { diff --git a/src/basic_data_handling/dict_nodes.py b/src/basic_data_handling/dict_nodes.py index 1b4a8f9..a556153 100644 --- a/src/basic_data_handling/dict_nodes.py +++ b/src/basic_data_handling/dict_nodes.py @@ -22,7 +22,6 @@ class DictCreate(ComfyNodeABC): This node creates and returns a new empty dictionary object. """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): return { @@ -54,7 +53,6 @@ class DictCreateFromBoolean(ComfyNodeABC): This node creates and returns a new empty dictionary object. """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): return { @@ -86,7 +84,6 @@ class DictCreateFromFloat(ComfyNodeABC): This node creates and returns a new empty dictionary object. """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): return { @@ -118,7 +115,6 @@ class DictCreateFromInt(ComfyNodeABC): This node creates and returns a new empty dictionary object. """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): return { @@ -150,7 +146,6 @@ class DictCreateFromString(ComfyNodeABC): This node creates and returns a new empty dictionary object. """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): return { diff --git a/src/basic_data_handling/list_nodes.py b/src/basic_data_handling/list_nodes.py index cb35268..5d3e3e8 100644 --- a/src/basic_data_handling/list_nodes.py +++ b/src/basic_data_handling/list_nodes.py @@ -50,7 +50,6 @@ class ListCreateFromBoolean(ComfyNodeABC): This node creates and returns a LIST. The list of items is dynamically extended based on the number of inputs provided. """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): return { @@ -76,7 +75,6 @@ class ListCreateFromFloat(ComfyNodeABC): This node creates and returns a LIST. The list of items is dynamically extended based on the number of inputs provided. """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): return { @@ -102,7 +100,6 @@ class ListCreateFromInt(ComfyNodeABC): This node creates and returns a LIST. The list of items is dynamically extended based on the number of inputs provided. """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): return { @@ -128,7 +125,6 @@ class ListCreateFromString(ComfyNodeABC): This node creates and returns a LIST. The list of items is dynamically extended based on the number of inputs provided. """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): return { diff --git a/src/basic_data_handling/set_nodes.py b/src/basic_data_handling/set_nodes.py index bb52ef8..e4c12c2 100644 --- a/src/basic_data_handling/set_nodes.py +++ b/src/basic_data_handling/set_nodes.py @@ -48,7 +48,6 @@ class SetCreateFromBoolean(ComfyNodeABC): This node creates and returns a SET. The list of items is dynamically extended based on the number of inputs provided. """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): return { @@ -74,7 +73,6 @@ class SetCreateFromFloat(ComfyNodeABC): This node creates and returns a SET. The list of items is dynamically extended based on the number of inputs provided. """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): return { @@ -100,7 +98,6 @@ class SetCreateFromInt(ComfyNodeABC): This node creates and returns a SET. The list of items is dynamically extended based on the number of inputs provided. """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): return { @@ -126,7 +123,6 @@ class SetCreateFromString(ComfyNodeABC): This node creates and returns a SET. The list of items is dynamically extended based on the number of inputs provided. """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): return { From caf7672126a20c0f74d390f0569082e947844c37 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Sat, 7 Jun 2025 01:01:24 +0200 Subject: [PATCH 3/8] Completely rework the formula node - make it much more stable and much more powerfull --- src/basic_data_handling/math_formula_node.py | 403 +++++++++---------- 1 file changed, 186 insertions(+), 217 deletions(-) diff --git a/src/basic_data_handling/math_formula_node.py b/src/basic_data_handling/math_formula_node.py index 9041983..22e673a 100644 --- a/src/basic_data_handling/math_formula_node.py +++ b/src/basic_data_handling/math_formula_node.py @@ -1,8 +1,11 @@ +import re +import math from inspect import cleandoc try: from comfy.comfy_types.node_typing import IO, ComfyNodeABC -except: +except ImportError: + # Mock classes for standalone testing class IO: BOOLEAN = "BOOLEAN" INT = "INT" @@ -18,14 +21,20 @@ class MathFormula(ComfyNodeABC): """ A node that evaluates a mathematical formula provided as a string without using eval. - This node takes up to 4 numerical inputs (`a`, `b`, `c`, and `d`) and safely evaluates the formula - with supported operations: +, -, *, /, //, %, **, parentheses, and mathematical functions like abs(), - floor(), ceil(), and round(). It treats one-letter variables as inputs and multi-letter words as functions. + This node takes an arbitrary number of numerical inputs (single letters like `a`, `b`, `c`, etc.) and safely + evaluates the formula with supported operations: +, -, *, /, //, %, **, parentheses, and mathematical + functions. It treats one-letter variables as inputs and multi-letter words as functions. - Example: If the formula is "a + abs(b * 4 + c)" and inputs are: - a = 2, b = -3, c = 5, - The calculation would be: - result = 2 + abs(-3 * 4 + 5) = 2 + abs(-7) = 2 + 7 = 9 + The identifiers `e` and `pi` are special. When used as a function call (`e()`, `pi()`), they return their + respective mathematical constants. When used as a variable (`e`), they are treated like any other + variable and expect a corresponding input. + + Supported functions: + - Basic: abs, floor, ceil, round, min(a,b), max(a,b) + - Trigonometric: sin, cos, tan, asin, acos, atan, atan2(y,x), degrees, radians + - Hyperbolic: sinh, cosh, tanh, asinh, acosh, atanh + - Exponential & Logarithmic: exp, log, log10, log2, sqrt, pow(base,exp) + - Constants (must be called with empty parentheses): pi(), e() """ EXPERIMENTAL = True @@ -34,244 +43,204 @@ def INPUT_TYPES(cls): return { "required": ContainsDynamicDict({ "formula": (IO.STRING, {"default": "a + b"}), - "a": (IO.NUMBER, {"default": 0.0, "_dynamic": "letter"}), + "a": (IO.NUMBER, {"default": 1.0, "_dynamic": "letter"}), + "b": (IO.NUMBER, {"default": 2.0, "_dynamic": "letter"}), }), } RETURN_TYPES = (IO.FLOAT,) - CATEGORY = "Basic/maths" + CATEGORY = "math" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "evaluate" - OPERATORS = {"**", "//", "/", "*", "-", "+", "%"} - SUPPORTED_FUNCTIONS = {"abs", "floor", "ceil", "round"} - VALID_CHARS = set("0123456789.+-*/%(), ") - - def evaluate(self, formula: str, a: float, b: float = 0.0, c: float = 0.0, d: float = 0.0) -> tuple[float]: - # Tokenize the formula without replacement - print(f'formula: "{formula}"') + FUNC_ARITIES = { + "pi": 0, "e": 0, "abs": 1, "floor": 1, "ceil": 1, "round": 1, "sin": 1, "cos": 1, + "tan": 1, "asin": 1, "acos": 1, "atan": 1, "degrees": 1, "radians": 1, "sinh": 1, + "cosh": 1, "tanh": 1, "asinh": 1, "acosh": 1, "atanh": 1, "exp": 1, "log": 1, + "log10": 1, "log2": 1, "sqrt": 1, "pow": 2, "atan2": 2, "min": 2, "max": 2, + } + SUPPORTED_FUNCTIONS = set(FUNC_ARITIES.keys()) + + OPERATOR_PROPS = { + "+": (2, "LEFT", 2), "-": (2, "LEFT", 2), "*": (3, "LEFT", 2), "/": (3, "LEFT", 2), + "//": (3, "LEFT", 2), "%": (3, "LEFT", 2), "**": (4, "RIGHT", 2), + "_NEG": (5, "RIGHT", 1), + } + OPERATORS = set(OPERATOR_PROPS.keys()) + + TOKEN_REGEX = re.compile( + r"([a-zA-Z_][a-zA-Z0-9_]*)" + r"|(\d+(?:\.\d*)?|\.\d+)" + r"|(\*\*|//|[+\-*/%(),])" + ) + + def evaluate(self, formula: str, **kwargs) -> tuple[float]: tokens = self.tokenize_formula(formula) - print(f'ev tokens: "{tokens}"') - - # Replace variables in the tokenized formula - replaced_tokens = [] - for token in tokens: - if token == "a": - replaced_tokens.append(str(a)) - elif token == "b": - replaced_tokens.append(str(b)) - elif token == "c": - replaced_tokens.append(str(c)) - elif token == "d": - replaced_tokens.append(str(d)) - else: - replaced_tokens.append(token) - - # Join the replaced tokens back into a formula - updated_formula = " ".join(replaced_tokens) - print(f'updated_formula: "{updated_formula}"') - - # Ensure formula is valid - self.validate_formula(updated_formula) - - # Parse formula into postfix form and evaluate - postfix = self.infix_to_postfix(replaced_tokens) - print(f'postfix: "{postfix}"') - result = self.evaluate_postfix(postfix) - print(f'result: "{result}"') - + postfix_tokens = self.infix_to_postfix(tokens) + result = self.evaluate_postfix(postfix_tokens, kwargs) return (result,) - def validate_formula(self, formula: str): - """Validates the formula for allowed operators, functions, and characters.""" - import re - - # Validate supported characters + def tokenize_formula(self, formula: str) -> list[str]: + allowed_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.,+-*/%() \t\n\r" for char in formula: - if char not in self.VALID_CHARS and not char.isalpha(): + if char not in allowed_chars: raise ValueError(f"Invalid character in formula: '{char}'") - - # Validate that all functions are supported - functions = re.findall(r"[a-zA-Z_]\w*(?=\()", formula) # Matches functions immediately before "(" - print('formula:') - print(formula) - print('functions:') - print(functions) - for func in functions: - if func not in self.SUPPORTED_FUNCTIONS: - raise ValueError(f"Unsupported function in formula: '{func}'") - - def tokenize_formula(self, formula: str) -> list: - """ - Tokenizes a formula into numbers, variables, functions, operators, and parentheses. - - Rules: - - A single-letter token (e.g., 'a') is treated as a variable. - - A multi-letter token (e.g., 'abs') is treated as a function. - - Numbers can be integers, floating point, or hexadecimal. - """ - import re - - # Regex pattern for tokenizing - pattern = ( - r"(?P-?\d+(\.\d+)?([eE][+-]?\d+)?|-?0x[0-9a-fA-F]+)" # Matches float, int, hex, scientific notation - r"|(?P[a-zA-Z]{2,})" # Matches multi-letter functions like 'abs' - r"|(?P[a-zA-Z])" # Matches single-letter variables (e.g., 'a', 'b') - r"|(?P\*\*|//|[+\-*/%()])" # Matches operators and parentheses - ) - # Apply regex to extract matches - tokens = re.finditer(pattern, formula) - - # Collect and clean the matched tokens - result = [match.group(0) for match in tokens] - print(f'Tokens: {result}') # Debug print - return result - - def infix_to_postfix(self, tokens: list) -> list: - """ - Converts a list of tokens in infix notation to postfix notation (RPN). - Correctly handles unary negation (e.g., -b) and operator precedence. - """ - precedence = {"**": 4, "*": 3, "/": 3, "//": 3, "%": 3, "+": 2, "-": 2, "(": 1} - stack = [] # Operator stack - postfix = [] # Output list - previous_token = None # Tracks the previous token to distinguish unary vs binary operators - - # Track function-to-parenthesis relationships - function_stack = [] # Stack to track pending functions - - for token in tokens: - if self.is_number(token): # Numbers - postfix.append(float(token)) - elif token in self.SUPPORTED_FUNCTIONS: # Functions (e.g., abs) - # Push function onto stack - will be processed when matching parenthesis is found - function_stack.append(len(stack)) # Record stack depth for this function - stack.append(token) - elif token == "(": # Left parentheses - stack.append(token) - elif token == ")": # Right parentheses - # Pop operators until left parenthesis - while stack and stack[-1] != "(": - postfix.append(stack.pop()) - - if not stack: - raise ValueError("Mismatched parentheses in expression.") - - stack.pop() # Discard the left parenthesis - - # Check if this closing parenthesis matches a function call - if function_stack and len(stack) == function_stack[-1]: - # We've found our function, add it to postfix - if stack and stack[-1] in self.SUPPORTED_FUNCTIONS: - postfix.append(stack.pop()) - function_stack.pop() # Remove the function marker - elif token in self.OPERATORS: # Operators (+, -, *, etc.) - # Check for unary negation - if token == "-" and (previous_token is None or previous_token in self.OPERATORS or previous_token == "("): - # Handle negative numbers directly - place negative value in postfix - if tokens.index(token) + 1 < len(tokens) and self.is_number(tokens[tokens.index(token) + 1]): - next_token = tokens[tokens.index(token) + 1] - # Skip this token and the next number, add negative number to postfix - postfix.append(-float(next_token)) - previous_token = next_token # Skip to after the number - continue + return [token for group in self.TOKEN_REGEX.findall(formula) for token in group if token] + + def infix_to_postfix(self, tokens: list[str]) -> list: + output_queue = [] + op_stack = [] + prev_token = None + binary_operators = {op for op, props in self.OPERATOR_PROPS.items() if props[2] == 2} + + for i, token in enumerate(tokens): + if self.is_number(token): + output_queue.append(float(token)) + elif token.isalnum() and not token.isdigit(): + is_function_call = (i + 1 < len(tokens) and tokens[i+1] == '(') + + if is_function_call: + if token not in self.SUPPORTED_FUNCTIONS: + raise ValueError(f"Unknown function: '{token}'") + op_stack.append(token) # Push function name as string + else: # It's a variable + if len(token) == 1 and token.isalpha(): + output_queue.append(('VAR', token)) # Push variable as a tagged tuple else: - # Traditional unary minus using 0 - operand - postfix.append(0.0) - stack.append(token) - else: - # Handle regular operators - while stack and stack[-1] not in ["("] and precedence.get(stack[-1], 0) >= precedence[token]: - postfix.append(stack.pop()) - stack.append(token) + if token in self.SUPPORTED_FUNCTIONS: + raise ValueError(f"Function '{token}' must be called with parentheses, e.g., {token}()") + else: + raise ValueError(f"Unknown identifier: '{token}'. Only single-letter variables are allowed.") + + elif token == "(": + op_stack.append(token) + elif token == ",": + while op_stack and op_stack[-1] != "(": + output_queue.append(op_stack.pop()) + if not op_stack or op_stack[-1] != "(": + raise ValueError("Misplaced comma or mismatched parentheses.") + elif token == ")": + while op_stack and op_stack[-1] != "(": + output_queue.append(op_stack.pop()) + if not op_stack: + raise ValueError("Mismatched parentheses.") + op_stack.pop() + if op_stack and op_stack[-1] in self.SUPPORTED_FUNCTIONS: + output_queue.append(op_stack.pop()) + elif token in self.OPERATORS: + is_unary = token == '-' and (prev_token is None or prev_token in binary_operators or prev_token in ['(', ',']) + op_to_push = "_NEG" if is_unary else token + + props = self.OPERATOR_PROPS[op_to_push] + prec, assoc = props[0], props[1] + + while (op_stack and op_stack[-1] != '(' and op_stack[-1] in self.OPERATORS and + (self.OPERATOR_PROPS.get(op_stack[-1], (0,))[0] > prec or + (self.OPERATOR_PROPS.get(op_stack[-1], (0,))[0] == prec and assoc == "LEFT"))): + output_queue.append(op_stack.pop()) + op_stack.append(op_to_push) else: - raise ValueError(f"Unexpected token in infix expression: {token}") - - # Update previous token - previous_token = token + raise ValueError(f"Unknown identifier or invalid expression: '{token}'") + prev_token = token - # Pop any remaining operators from the stack - while stack: - top = stack.pop() - if top in ["(", ")"]: - raise ValueError("Mismatched parentheses in expression.") - postfix.append(top) + while op_stack: + op = op_stack.pop() + if op == "(": + raise ValueError("Mismatched parentheses.") + output_queue.append(op) - return postfix + return output_queue - def evaluate_postfix(self, postfix: list) -> float: - """ - Evaluates a postfix (RPN) expression and returns the result. - Handles numbers, operators, and functions, including unary negation. - """ + def evaluate_postfix(self, postfix: list, variables: dict) -> float: stack = [] - for token in postfix: - if isinstance(token, float): # Numbers + if isinstance(token, float): stack.append(token) - elif token in self.SUPPORTED_FUNCTIONS: # Functions (e.g., abs) - if not stack: - raise ValueError(f"Invalid formula: function '{token}' requires an argument but the stack is empty.") - arg = stack.pop() - stack.append(self.apply_function(token, arg)) - elif token in self.OPERATORS: # Operators (+, -, *, /, etc.) - if len(stack) < 2: - raise ValueError(f"Invalid formula: operator '{token}' requires two arguments but the stack has {len(stack)}.") - b = stack.pop() - a = stack.pop() - stack.append(self.apply_operator(a, b, token)) + elif isinstance(token, tuple) and token[0] == 'VAR': + var_name = token[1] + if var_name not in variables: + raise ValueError(f"Variable '{var_name}' was not provided.") + stack.append(float(variables[var_name])) + elif token in self.OPERATORS: + arity = self.OPERATOR_PROPS[token][2] + if len(stack) < arity: + raise ValueError(f"Operator '{token}' needs {arity} operand(s).") + + operands = [stack.pop() for _ in range(arity)] + operands.reverse() + + if arity == 1: + stack.append(-operands[0]) + else: + stack.append(self.apply_operator(operands[0], operands[1], token)) + elif token in self.SUPPORTED_FUNCTIONS: + arity = self.FUNC_ARITIES[token] + if len(stack) < arity: + raise ValueError(f"Function '{token}' needs {arity} argument(s).") + + args = [stack.pop() for _ in range(arity)] + args.reverse() + stack.append(self.apply_function(token, args)) else: - raise ValueError(f"Unexpected token in postfix expression: {token}") + raise ValueError(f"Internal error: Unknown token in postfix queue: {token}") if len(stack) != 1: - raise ValueError(f"Invalid postfix expression: stack contains excess items: {stack}") - return stack.pop() + raise ValueError("Invalid expression. The formula may be incomplete or have extra values.") + return stack[0] def apply_operator(self, a: float, b: float, operator: str) -> float: - """Applies the given operator to two operands.""" - if operator == "+": - return a + b - elif operator == "-": - return a - b - elif operator == "*": - return a * b - elif operator == "/": - if b == 0: - raise ZeroDivisionError("Division by zero.") - return a / b - elif operator == "//": - if b == 0: - raise ZeroDivisionError("Division by zero.") - return a // b - elif operator == "%": - if b == 0: - raise ZeroDivisionError("Modulo by zero.") - return a % b - elif operator == "**": - return a ** b - else: - raise ValueError(f"Unsupported operator: {operator}") - - def apply_function(self, func: str, arg: float) -> float: - """Applies a mathematical function.""" - import math - if func == "abs": - return abs(arg) - elif func == "floor": - return math.floor(arg) - elif func == "ceil": - return math.ceil(arg) - elif func == "round": - return round(arg) - else: - raise ValueError(f"Unsupported function: {func}") + if operator == "+": return a + b + if operator == "-": return a - b + if operator == "*": return a * b + if operator == "**": return a ** b + if b == 0 and operator in ['/', '//', '%']: + raise ZeroDivisionError(f"Division by zero in operator '{operator}'.") + if operator == "/": return a / b + if operator == "//": return a // b + if operator == "%": return a % b + raise ValueError(f"Unsupported operator: {operator}") + + def apply_function(self, func: str, args: list) -> float: + if func == "pi": return math.pi + if func == "e": return math.e + if len(args) == 1: + arg = args[0] + if func == "abs": return abs(arg) + if func == "floor": return math.floor(arg) + if func == "ceil": return math.ceil(arg) + if func == "round": return round(arg) + if func == "sin": return math.sin(arg) + if func == "cos": return math.cos(arg) + if func == "tan": return math.tan(arg) + if func == "asin": return math.asin(arg) + if func == "acos": return math.acos(arg) + if func == "atan": return math.atan(arg) + if func == "degrees": return math.degrees(arg) + if func == "radians": return math.radians(arg) + if func == "sinh": return math.sinh(arg) + if func == "cosh": return math.cosh(arg) + if func == "tanh": return math.tanh(arg) + if func == "asinh": return math.asinh(arg) + if func == "acosh": return math.acosh(arg) + if func == "atanh": return math.atanh(arg) + if func == "exp": return math.exp(arg) + if func == "log": return math.log(arg) + if func == "log10": return math.log10(arg) + if func == "log2": return math.log2(arg) + if func == "sqrt": return math.sqrt(arg) + if len(args) == 2: + a, b = args[0], args[1] + if func == "pow": return math.pow(a, b) + if func == "atan2": return math.atan2(a, b) + if func == "min": return min(a, b) + if func == "max": return max(a, b) + raise ValueError(f"Internal error: apply_function called with wrong number of args for '{func}'") def is_number(self, value: str) -> bool: - """Checks if a token is a valid number.""" try: - float.fromhex(value) if value.startswith("0x") else float(value) + float(value) return True - except ValueError: + except (ValueError, TypeError): return False NODE_CLASS_MAPPINGS = { From 7a83acf33e0ca102f7efa0c2c00812284601471f Mon Sep 17 00:00:00 2001 From: StableLlama Date: Sat, 7 Jun 2025 01:12:39 +0200 Subject: [PATCH 4/8] Add test cases for function node --- tests/test_math_formula_node.py | 237 ++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 tests/test_math_formula_node.py diff --git a/tests/test_math_formula_node.py b/tests/test_math_formula_node.py new file mode 100644 index 0000000..ba469bb --- /dev/null +++ b/tests/test_math_formula_node.py @@ -0,0 +1,237 @@ +import pytest +import math +import re +from src.basic_data_handling.math_formula_node import MathFormula + +def test_basic_formula_evaluation(): + """Test basic formula evaluation with simple operations.""" + node = MathFormula() + + # Simple arithmetic + assert node.evaluate("a + b", a=3, b=4)[0] == pytest.approx(7.0) + assert node.evaluate("a - b", a=10, b=6)[0] == pytest.approx(4.0) + assert node.evaluate("a * b", a=5, b=3)[0] == pytest.approx(15.0) + assert node.evaluate("a / b", a=10, b=2)[0] == pytest.approx(5.0) + + # Combined operations + assert node.evaluate("a + b * c", a=2, b=3, c=4)[0] == pytest.approx(14.0) + assert node.evaluate("(a + b) * c", a=2, b=3, c=4)[0] == pytest.approx(20.0) + assert node.evaluate("a ** b", a=2, b=3)[0] == pytest.approx(8.0) + assert node.evaluate("a // b", a=7, b=2)[0] == pytest.approx(3.0) + assert node.evaluate("a % b", a=7, b=4)[0] == pytest.approx(3.0) + + # Unary minus + assert node.evaluate("-a", a=5)[0] == pytest.approx(-5.0) + assert node.evaluate("a * -b", a=3, b=4)[0] == pytest.approx(-12.0) + +def test_function_calls(): + """Test formula evaluation with math functions.""" + node = MathFormula() + + # Single-argument functions + assert node.evaluate("sin(a)", a=math.pi/6)[0] == pytest.approx(0.5) + assert node.evaluate("cos(a)", a=0)[0] == pytest.approx(1.0) + assert node.evaluate("tan(a)", a=math.pi/4)[0] == pytest.approx(1.0) + assert node.evaluate("sqrt(a)", a=16)[0] == pytest.approx(4.0) + assert node.evaluate("log(a)", a=math.e)[0] == pytest.approx(1.0) + assert node.evaluate("exp(a)", a=2)[0] == pytest.approx(math.exp(2)) + + # Two-argument functions + assert node.evaluate("min(a, b)", a=5, b=10)[0] == pytest.approx(5.0) + assert node.evaluate("max(a, b)", a=5, b=10)[0] == pytest.approx(10.0) + assert node.evaluate("pow(a, b)", a=2, b=3)[0] == pytest.approx(8.0) + +def test_constants(): + """Test formula evaluation with constants.""" + node = MathFormula() + + # Constants called with empty parentheses + assert node.evaluate("pi()")[0] == pytest.approx(math.pi) + assert node.evaluate("e()")[0] == pytest.approx(math.e) + + # Using constants in expressions + assert node.evaluate("2 * pi()")[0] == pytest.approx(2 * math.pi) + assert node.evaluate("e() ** 2")[0] == pytest.approx(math.e ** 2) + assert node.evaluate("sin(pi() / 2)")[0] == pytest.approx(1.0) + assert node.evaluate("log(e())")[0] == pytest.approx(1.0) + + # More complex expressions with constants + assert node.evaluate("a * pi() + b", a=2, b=1)[0] == pytest.approx(2 * math.pi + 1) + assert node.evaluate("e() ** a", a=3)[0] == pytest.approx(math.e ** 3) + assert node.evaluate("sin(a * pi())", a=0.5)[0] == pytest.approx(1.0, abs=1e-10) + +def test_variable_constant_distinction(): + """Test that variables and constants can coexist without conflict.""" + node = MathFormula() + + # Using 'e' as a variable while also using 'e()' as a constant + assert node.evaluate("e + e()", e=5)[0] == pytest.approx(5 + math.e) + + # Using 'pi' as a variable while also using 'pi()' as a constant + with pytest.raises(ValueError, match=r"Function 'pi' must be called with parentheses, e.g., pi()"): + node.evaluate("pi * pi()", pi=3) + + # More complex expressions + assert node.evaluate("e * log(e())", e=2)[0] == pytest.approx(2.0) + assert node.evaluate("a * e() + b * e", a=2, b=3, e=4)[0] == pytest.approx(2 * math.e + 3 * 4) + +def test_error_conditions(): + """Test error conditions in formula evaluation.""" + node = MathFormula() + + # Constants without parentheses should raise ValueError + with pytest.raises(ValueError, match=r"Function 'pi' must be called with parentheses, e.g., pi()"): + node.evaluate("pi") + + with pytest.raises(ValueError, match=r"Variable 'e' was not provided."): + node.evaluate("2 * e") + + # Missing variables + with pytest.raises(ValueError, match=r"Variable 'x' was not provided"): + node.evaluate("x + y", y=5) + + # Invalid function calls + with pytest.raises(ValueError): + node.evaluate("unknown_func(x)", x=5) + + # Division by zero + with pytest.raises(ZeroDivisionError): + node.evaluate("a / b", a=5, b=0) + + # Function domain errors + with pytest.raises(ValueError): + node.evaluate("sqrt(x)", x=-1) # Negative square root + + with pytest.raises(ValueError): + node.evaluate("log(x)", x=0) # Log of zero + + with pytest.raises(ValueError): + node.evaluate("asin(x)", x=2) # Out of domain [-1, 1] + +def test_complex_expressions(): + """Test evaluation of complex mathematical expressions.""" + node = MathFormula() + + # Complex expression with variables, functions, and constants + formula = "a * sin(b * pi()) + c * sqrt(d) + e() ** 2" + result = node.evaluate(formula, a=2, b=0.5, c=3, d=9)[0] + expected = 2 * math.sin(0.5 * math.pi) + 3 * math.sqrt(9) + math.e ** 2 + assert result == pytest.approx(expected) + + # Expression with nested functions + formula = "sqrt(pow(a, 2) + pow(b, 2))" + assert node.evaluate(formula, a=3, b=4)[0] == pytest.approx(5.0) + + # Expression with multiple operations and precedence + formula = "a + b * c - d / e + f ** g" + result = node.evaluate(formula, a=1, b=2, c=3, d=8, e=4, f=2, g=3)[0] + expected = 1 + 2 * 3 - 8 / 4 + 2 ** 3 + assert result == pytest.approx(expected) + +def test_formula_with_e_variable(): + """Test formulas with 'e' as a variable to ensure it doesn't clash with the constant.""" + node = MathFormula() + + # Using 'e' as a variable + assert node.evaluate("e * 2", e=5)[0] == pytest.approx(10.0) + + # Using both 'e' as a variable and 'e()' as a constant in the same expression + assert node.evaluate("e + e()", e=3)[0] == pytest.approx(3 + math.e) + + # More complex formula with both uses + formula = "a * e + b * e()" + result = node.evaluate(formula, a=2, e=3, b=4)[0] + expected = 2 * 3 + 4 * math.e + assert result == pytest.approx(expected) + + # Formula using 'e' in different contexts + formula = "e * sin(e() * pi())" + result = node.evaluate(formula, e=2)[0] + expected = 2 * math.sin(math.e * math.pi) + assert result == pytest.approx(expected) + +def test_whitespace_handling(): + """Test that the parser correctly handles different whitespace patterns.""" + node = MathFormula() + + # No spaces + assert node.evaluate("a+b*c", a=1, b=2, c=3)[0] == pytest.approx(7.0) + + # Lots of spaces + assert node.evaluate("a + b * c", a=1, b=2, c=3)[0] == pytest.approx(7.0) + + # Mixed spacing around functions + assert node.evaluate("sin( a )+cos(b)", a=math.pi/2, b=0)[0] == pytest.approx(2.0) + + # Spacing around constants + assert node.evaluate("2*pi( )", a=1)[0] == pytest.approx(2 * math.pi) + assert node.evaluate("e( )**2")[0] == pytest.approx(math.e ** 2) + +def test_tokenizing(): + """Test the tokenization functionality.""" + node = MathFormula() + + # Test basic tokenizing + tokens = node.tokenize_formula("a + b * c") + assert "a" in tokens + assert "+" in tokens + assert "b" in tokens + assert "*" in tokens + assert "c" in tokens + + # Test tokenizing functions + tokens = node.tokenize_formula("sin(a) + cos(b)") + assert "sin" in tokens + assert "(" in tokens + assert "a" in tokens + assert ")" in tokens + assert "+" in tokens + assert "cos" in tokens + assert "b" in tokens + + # Test tokenizing constants with parentheses + tokens = node.tokenize_formula("pi() + e()") + assert "pi" in tokens + assert "(" in tokens + assert ")" in tokens + assert "+" in tokens + assert "e" in tokens + + # Test complex expression + tokens = node.tokenize_formula("a + b * (c - d) / e() ** 2") + assert all(token in tokens for token in ["a", "+", "b", "*", "(", "c", "-", "d", ")", "/", "e", "(", ")", "**", "2"]) + +def test_infix_to_postfix(): + """Test the infix to postfix conversion.""" + node = MathFormula() + + # Basic conversion + formula = "a + b" + tokens = node.tokenize_formula(formula) + postfix = node.infix_to_postfix(tokens) + + # Test operator precedence + formula = "a + b * c" + tokens = node.tokenize_formula(formula) + postfix = node.infix_to_postfix(tokens) + # We can't test exact postfix because it contains internal representations, + # but we can test the evaluation result + assert node.evaluate(formula, a=1, b=2, c=3)[0] == pytest.approx(7.0) + + # Test parentheses + formula = "(a + b) * c" + tokens = node.tokenize_formula(formula) + postfix = node.infix_to_postfix(tokens) + assert node.evaluate(formula, a=1, b=2, c=3)[0] == pytest.approx(9.0) + + # Test function calls + formula = "sin(a) + cos(b)" + tokens = node.tokenize_formula(formula) + postfix = node.infix_to_postfix(tokens) + assert node.evaluate(formula, a=math.pi/2, b=0)[0] == pytest.approx(2.0) + + # Test constant function calls + formula = "pi() + e()" + tokens = node.tokenize_formula(formula) + postfix = node.infix_to_postfix(tokens) + assert node.evaluate(formula)[0] == pytest.approx(math.pi + math.e) From 2bac353f81dfb3ad8d88a6bf5db55e012f6e6f02 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Sat, 7 Jun 2025 01:28:15 +0200 Subject: [PATCH 5/8] Add more test cases for function node and fix it to work well with it --- src/basic_data_handling/math_formula_node.py | 48 ++++++----- tests/test_math_formula_node.py | 90 +++++++++++++++++++- 2 files changed, 118 insertions(+), 20 deletions(-) diff --git a/src/basic_data_handling/math_formula_node.py b/src/basic_data_handling/math_formula_node.py index 22e673a..9a5c36b 100644 --- a/src/basic_data_handling/math_formula_node.py +++ b/src/basic_data_handling/math_formula_node.py @@ -23,11 +23,14 @@ class MathFormula(ComfyNodeABC): This node takes an arbitrary number of numerical inputs (single letters like `a`, `b`, `c`, etc.) and safely evaluates the formula with supported operations: +, -, *, /, //, %, **, parentheses, and mathematical - functions. It treats one-letter variables as inputs and multi-letter words as functions. + functions. + + NOTE on Operator Precedence: This parser uses standard precedence rules. Unary minus binds tightly, + so expressions like `-a ** 2` are interpreted as `(-a) ** 2`. To calculate `-(a ** 2)`, + use parentheses: `-(a ** 2)`. The identifiers `e` and `pi` are special. When used as a function call (`e()`, `pi()`), they return their - respective mathematical constants. When used as a variable (`e`), they are treated like any other - variable and expect a corresponding input. + respective mathematical constants. When used as a variable (`e`), they expect a corresponding input. Supported functions: - Basic: abs, floor, ceil, round, min(a,b), max(a,b) @@ -42,9 +45,8 @@ class MathFormula(ComfyNodeABC): def INPUT_TYPES(cls): return { "required": ContainsDynamicDict({ - "formula": (IO.STRING, {"default": "a + b"}), - "a": (IO.NUMBER, {"default": 1.0, "_dynamic": "letter"}), - "b": (IO.NUMBER, {"default": 2.0, "_dynamic": "letter"}), + "formula": (IO.STRING, {"default": "-e() ** -2"}), + "a": (IO.NUMBER, {"default": 3.0, "_dynamic": "letter"}), }), } @@ -62,9 +64,15 @@ def INPUT_TYPES(cls): SUPPORTED_FUNCTIONS = set(FUNC_ARITIES.keys()) OPERATOR_PROPS = { - "+": (2, "LEFT", 2), "-": (2, "LEFT", 2), "*": (3, "LEFT", 2), "/": (3, "LEFT", 2), - "//": (3, "LEFT", 2), "%": (3, "LEFT", 2), "**": (4, "RIGHT", 2), - "_NEG": (5, "RIGHT", 1), + # token: (precedence, associativity, arity) + "+": (2, "LEFT", 2), + "-": (2, "LEFT", 2), + "*": (3, "LEFT", 2), + "/": (3, "LEFT", 2), + "//": (3, "LEFT", 2), + "%": (3, "LEFT", 2), + "**": (4, "RIGHT", 2), + "_NEG": (5, "RIGHT", 1), # Unary has higher precedence than exponentiation } OPERATORS = set(OPERATOR_PROPS.keys()) @@ -98,14 +106,13 @@ def infix_to_postfix(self, tokens: list[str]) -> list: output_queue.append(float(token)) elif token.isalnum() and not token.isdigit(): is_function_call = (i + 1 < len(tokens) and tokens[i+1] == '(') - if is_function_call: if token not in self.SUPPORTED_FUNCTIONS: raise ValueError(f"Unknown function: '{token}'") - op_stack.append(token) # Push function name as string - else: # It's a variable + op_stack.append(token) + else: if len(token) == 1 and token.isalpha(): - output_queue.append(('VAR', token)) # Push variable as a tagged tuple + output_queue.append(('VAR', token)) else: if token in self.SUPPORTED_FUNCTIONS: raise ValueError(f"Function '{token}' must be called with parentheses, e.g., {token}()") @@ -130,14 +137,17 @@ def infix_to_postfix(self, tokens: list[str]) -> list: elif token in self.OPERATORS: is_unary = token == '-' and (prev_token is None or prev_token in binary_operators or prev_token in ['(', ',']) op_to_push = "_NEG" if is_unary else token - props = self.OPERATOR_PROPS[op_to_push] - prec, assoc = props[0], props[1] + prec = props[0] - while (op_stack and op_stack[-1] != '(' and op_stack[-1] in self.OPERATORS and - (self.OPERATOR_PROPS.get(op_stack[-1], (0,))[0] > prec or - (self.OPERATOR_PROPS.get(op_stack[-1], (0,))[0] == prec and assoc == "LEFT"))): - output_queue.append(op_stack.pop()) + while (op_stack and op_stack[-1] != '(' and op_stack[-1] in self.OPERATORS): + stack_op_props = self.OPERATOR_PROPS[op_stack[-1]] + stack_op_prec = stack_op_props[0] + stack_op_assoc = stack_op_props[1] + if (stack_op_prec > prec) or (stack_op_prec == prec and stack_op_assoc == "LEFT"): + output_queue.append(op_stack.pop()) + else: + break op_stack.append(op_to_push) else: raise ValueError(f"Unknown identifier or invalid expression: '{token}'") diff --git a/tests/test_math_formula_node.py b/tests/test_math_formula_node.py index ba469bb..0375079 100644 --- a/tests/test_math_formula_node.py +++ b/tests/test_math_formula_node.py @@ -1,6 +1,6 @@ import pytest import math -import re +from math import sin, cos from src.basic_data_handling.math_formula_node import MathFormula def test_basic_formula_evaluation(): @@ -31,6 +31,15 @@ def test_function_calls(): # Single-argument functions assert node.evaluate("sin(a)", a=math.pi/6)[0] == pytest.approx(0.5) assert node.evaluate("cos(a)", a=0)[0] == pytest.approx(1.0) + + # Unary minus with function calls + assert node.evaluate("sin(-a)", a=math.pi/6)[0] == pytest.approx(-0.5) + assert node.evaluate("-sin(a)", a=math.pi/6)[0] == pytest.approx(-0.5) + assert node.evaluate("-sin(-a)", a=math.pi/6)[0] == pytest.approx(0.5) + + # Unary minus with nested function calls + assert node.evaluate("sin(cos(-a))", a=math.pi)[0] == pytest.approx(sin(cos(-math.pi))) + assert node.evaluate("-sin(cos(-a))", a=math.pi)[0] == pytest.approx(-sin(cos(-math.pi))) assert node.evaluate("tan(a)", a=math.pi/4)[0] == pytest.approx(1.0) assert node.evaluate("sqrt(a)", a=16)[0] == pytest.approx(4.0) assert node.evaluate("log(a)", a=math.e)[0] == pytest.approx(1.0) @@ -75,6 +84,51 @@ def test_variable_constant_distinction(): assert node.evaluate("e * log(e())", e=2)[0] == pytest.approx(2.0) assert node.evaluate("a * e() + b * e", a=2, b=3, e=4)[0] == pytest.approx(2 * math.e + 3 * 4) +def test_unary_minus(): + """Test that unary minus (negation) is handled correctly in all contexts.""" + node = MathFormula() + + # Simple variable negation + assert node.evaluate("-a", a=5)[0] == pytest.approx(-5.0) + assert node.evaluate("-a", a=-3)[0] == pytest.approx(3.0) + + # Constant negation + assert node.evaluate("-pi()")[0] == pytest.approx(-math.pi) + assert node.evaluate("-e()")[0] == pytest.approx(-math.e) + + # Function result negation + assert node.evaluate("-sin(a)", a=math.pi/2)[0] == pytest.approx(-1.0) + assert node.evaluate("-sqrt(a)", a=4)[0] == pytest.approx(-2.0) + + # Nested unary minus + assert node.evaluate("--a", a=5)[0] == pytest.approx(5.0) + assert node.evaluate("---a", a=5)[0] == pytest.approx(-5.0) + + # Unary minus with binary operators + assert node.evaluate("a + -b", a=5, b=3)[0] == pytest.approx(2.0) + assert node.evaluate("a * -b", a=4, b=3)[0] == pytest.approx(-12.0) + assert node.evaluate("a / -b", a=6, b=2)[0] == pytest.approx(-3.0) + assert node.evaluate("a - -b", a=5, b=3)[0] == pytest.approx(8.0) + + # Unary minus with higher precedence operators + assert node.evaluate("-a * b", a=2, b=3)[0] == pytest.approx(-6.0) + assert node.evaluate("-(a * b)", a=2, b=3)[0] == pytest.approx(-6.0) + assert node.evaluate("-a ** 2", a=3)[0] == pytest.approx(9.0) # see documentation about this special case + assert node.evaluate("(-a) ** 2", a=3)[0] == pytest.approx(9.0) # (-a)^2 + + # Unary minus with parentheses + assert node.evaluate("-(a + b)", a=2, b=3)[0] == pytest.approx(-5.0) + assert node.evaluate("-(a + b) * c", a=2, b=3, c=4)[0] == pytest.approx(-20.0) + assert node.evaluate("a * -(b + c)", a=2, b=3, c=4)[0] == pytest.approx(-14.0) + + # Unary minus at start of parenthesized expressions + assert node.evaluate("(-a + b)", a=5, b=3)[0] == pytest.approx(-2.0) + assert node.evaluate("(-a - b)", a=5, b=3)[0] == pytest.approx(-8.0) + + # Unary minus with constants and functions in complex expressions + assert node.evaluate("-pi() * sin(-a)", a=math.pi/2)[0] == pytest.approx(math.pi) + assert node.evaluate("-e() ** -2")[0] == pytest.approx(1/(math.e**2)) # see documentation about this special case + def test_error_conditions(): """Test error conditions in formula evaluation.""" node = MathFormula() @@ -86,6 +140,20 @@ def test_error_conditions(): with pytest.raises(ValueError, match=r"Variable 'e' was not provided."): node.evaluate("2 * e") + # Test unary minus with errors + with pytest.raises(ValueError): + node.evaluate("-unknown_var") + + with pytest.raises(ValueError): + node.evaluate("-unknown_func(x)", x=5) + + # Unary minus with domain errors + with pytest.raises(ValueError): + node.evaluate("sqrt(-a)", a=4) # Negative under square root + + with pytest.raises(ValueError): + node.evaluate("log(-a)", a=2) # Negative in logarithm + # Missing variables with pytest.raises(ValueError, match=r"Variable 'x' was not provided"): node.evaluate("x + y", y=5) @@ -128,6 +196,18 @@ def test_complex_expressions(): expected = 1 + 2 * 3 - 8 / 4 + 2 ** 3 assert result == pytest.approx(expected) + # Complex expressions with unary minus + formula = "a + -b * c - -(d / e) + -f ** g" + result = node.evaluate(formula, a=1, b=2, c=3, d=8, e=4, f=2, g=3)[0] + expected = 1 + (-2) * 3 - (-(8 / 4)) + (-(2 ** 3)) + assert result == pytest.approx(expected) + + # Expression with multiple unary minuses and parentheses + formula = "-a * (b + -c) / (-d)" + result = node.evaluate(formula, a=2, b=5, c=3, d=4)[0] + expected = -2 * (5 + (-3)) / (-4) + assert result == pytest.approx(expected) + def test_formula_with_e_variable(): """Test formulas with 'e' as a variable to ensure it doesn't clash with the constant.""" node = MathFormula() @@ -160,6 +240,14 @@ def test_whitespace_handling(): # Lots of spaces assert node.evaluate("a + b * c", a=1, b=2, c=3)[0] == pytest.approx(7.0) + # Whitespace around unary minus + assert node.evaluate("- a", a=5)[0] == pytest.approx(-5.0) + assert node.evaluate("- a", a=5)[0] == pytest.approx(-5.0) + assert node.evaluate("a * - b", a=3, b=4)[0] == pytest.approx(-12.0) + assert node.evaluate("a * - b", a=3, b=4)[0] == pytest.approx(-12.0) + assert node.evaluate("- (a + b)", a=2, b=3)[0] == pytest.approx(-5.0) + assert node.evaluate("- (a + b)", a=2, b=3)[0] == pytest.approx(-5.0) + # Mixed spacing around functions assert node.evaluate("sin( a )+cos(b)", a=math.pi/2, b=0)[0] == pytest.approx(2.0) From a64634a6b6770157d43a0a152ec2911f65eb7ff1 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Sat, 7 Jun 2025 01:30:52 +0200 Subject: [PATCH 6/8] Version 0.4.0 --- pyproject.toml | 3 ++- src/basic_data_handling/math_formula_node.py | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cc8688d..2cc17ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "basic_data_handling" -version = "0.3.5" +version = "0.4.0" description = """NOTE: Still in development! Expect breaking changes! Basic Python functions for manipulating data that every programmer is used to. @@ -21,6 +21,7 @@ Feature categories: - Data structures manipulation (data lists, LIST, DICT, SET) - Flow control (conditionals, branching, execution order) - Mathematical operations (arithmetic, trigonometry, logarithmic functions) +- Mathematical formula node in a safe implementation - String manipulation (case conversion, formatting, splitting, validation) - File system path handling (path operations, information, searching) - SET operations (creation, modification, comparison, mathematical set theory) diff --git a/src/basic_data_handling/math_formula_node.py b/src/basic_data_handling/math_formula_node.py index 9a5c36b..f2149e6 100644 --- a/src/basic_data_handling/math_formula_node.py +++ b/src/basic_data_handling/math_formula_node.py @@ -39,7 +39,6 @@ class MathFormula(ComfyNodeABC): - Exponential & Logarithmic: exp, log, log10, log2, sqrt, pow(base,exp) - Constants (must be called with empty parentheses): pi(), e() """ - EXPERIMENTAL = True @classmethod def INPUT_TYPES(cls): From eedb5a2de401dc37cb6c05a179db5e2f05289323 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Sat, 7 Jun 2025 01:37:38 +0200 Subject: [PATCH 7/8] Linter errors --- tests/test_math_formula_node.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/tests/test_math_formula_node.py b/tests/test_math_formula_node.py index 0375079..dd58a9d 100644 --- a/tests/test_math_formula_node.py +++ b/tests/test_math_formula_node.py @@ -293,33 +293,20 @@ def test_infix_to_postfix(): """Test the infix to postfix conversion.""" node = MathFormula() - # Basic conversion - formula = "a + b" - tokens = node.tokenize_formula(formula) - postfix = node.infix_to_postfix(tokens) - # Test operator precedence formula = "a + b * c" - tokens = node.tokenize_formula(formula) - postfix = node.infix_to_postfix(tokens) # We can't test exact postfix because it contains internal representations, # but we can test the evaluation result assert node.evaluate(formula, a=1, b=2, c=3)[0] == pytest.approx(7.0) # Test parentheses formula = "(a + b) * c" - tokens = node.tokenize_formula(formula) - postfix = node.infix_to_postfix(tokens) assert node.evaluate(formula, a=1, b=2, c=3)[0] == pytest.approx(9.0) # Test function calls formula = "sin(a) + cos(b)" - tokens = node.tokenize_formula(formula) - postfix = node.infix_to_postfix(tokens) assert node.evaluate(formula, a=math.pi/2, b=0)[0] == pytest.approx(2.0) # Test constant function calls formula = "pi() + e()" - tokens = node.tokenize_formula(formula) - postfix = node.infix_to_postfix(tokens) assert node.evaluate(formula)[0] == pytest.approx(math.pi + math.e) From dc90741358a1b65d355cfaab3e9c527910f5e9b7 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Sat, 7 Jun 2025 01:39:32 +0200 Subject: [PATCH 8/8] Remove disclaimer as there shouldn't be big breaking changes anymore --- README.md | 3 --- pyproject.toml | 4 +--- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/README.md b/README.md index 9f45a9c..263373a 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,6 @@ Basic Python functions for manipulating data that every programmer is used to. These nodes are very lightweight and require no additional dependencies. -> [!NOTE] -> This projected is in early development—do not use it in production, yet! - ## Quickstart 1. Install [ComfyUI](https://docs.comfy.org/get_started). diff --git a/pyproject.toml b/pyproject.toml index 2cc17ac..7dcd4fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,9 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "basic_data_handling" version = "0.4.0" -description = """NOTE: Still in development! Expect breaking changes! - -Basic Python functions for manipulating data that every programmer is used to. +description = """Basic Python functions for manipulating data that every programmer is used to. Comprehensive node collection for data manipulation in ComfyUI workflows. Supported data types: