Merge pull request #14 from StableLlama/enhance_completeness

Enhance completeness
This commit is contained in:
StableLlama
2025-06-07 19:17:13 +02:00
committed by GitHub
11 changed files with 561 additions and 242 deletions
+1 -3
View File
@@ -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).
@@ -158,6 +155,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
+3 -4
View File
@@ -4,10 +4,8 @@ build-backend = "setuptools.build_meta"
[project]
name = "basic_data_handling"
version = "0.3.5"
description = """NOTE: Still in development! Expect breaking changes!
Basic Python functions for manipulating data that every programmer is used to.
version = "0.4.0"
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:
@@ -21,6 +19,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)
@@ -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):
@@ -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 {
-5
View File
@@ -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 {
-4
View File
@@ -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 {
+193 -215
View File
@@ -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,260 +21,235 @@ 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.
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
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 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
@classmethod
def INPUT_TYPES(cls):
return {
"required": ContainsDynamicDict({
"formula": (IO.STRING, {"default": "a + b"}),
"a": (IO.NUMBER, {"default": 0.0, "_dynamic": "letter"}),
"formula": (IO.STRING, {"default": "-e() ** -2"}),
"a": (IO.NUMBER, {"default": 3.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.+-*/%(), ")
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())
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}"')
OPERATOR_PROPS = {
# 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())
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}'")
return [token for group in self.TOKEN_REGEX.findall(formula) for token in group if token]
# 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 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}
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<number>-?\d+(\.\d+)?([eE][+-]?\d+)?|-?0x[0-9a-fA-F]+)" # Matches float, int, hex, scientific notation
r"|(?P<function>[a-zA-Z]{2,})" # Matches multi-letter functions like 'abs'
r"|(?P<variable>[a-zA-Z])" # Matches single-letter variables (e.g., 'a', 'b')
r"|(?P<operator>\*\*|//|[+\-*/%()])" # 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
else:
# Traditional unary minus using 0 - operand
postfix.append(0.0)
stack.append(token)
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)
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 len(token) == 1 and token.isalpha():
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}()")
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 = props[0]
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"Unexpected token in infix expression: {token}")
raise ValueError(f"Unknown identifier or invalid expression: '{token}'")
prev_token = token
# Update previous token
previous_token = token
while op_stack:
op = op_stack.pop()
if op == "(":
raise ValueError("Mismatched parentheses.")
output_queue.append(op)
# Pop any remaining operators from the stack
while stack:
top = stack.pop()
if top in ["(", ")"]:
raise ValueError("Mismatched parentheses in expression.")
postfix.append(top)
return output_queue
return postfix
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}")
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, 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}")
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 = {
+33
View File
@@ -453,6 +453,37 @@ class PathNormalize(ComfyNodeABC):
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 @@ NODE_CLASS_MAPPINGS = {
"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 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"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",
-4
View File
@@ -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 {
+312
View File
@@ -0,0 +1,312 @@
import pytest
import math
from math import sin, cos
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)
# 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)
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_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()
# 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")
# 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)
# 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)
# 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()
# 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)
# 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)
# 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()
# Test operator precedence
formula = "a + b * c"
# 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"
assert node.evaluate(formula, a=1, b=2, c=3)[0] == pytest.approx(9.0)
# Test function calls
formula = "sin(a) + cos(b)"
assert node.evaluate(formula, a=math.pi/2, b=0)[0] == pytest.approx(2.0)
# Test constant function calls
formula = "pi() + e()"
assert node.evaluate(formula)[0] == pytest.approx(math.pi + math.e)
+19 -1
View File
@@ -5,7 +5,7 @@ import platform
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()