From 0269c74d933b517eaf5f416f712050a0280c8826 Mon Sep 17 00:00:00 2001 From: yannrichet Date: Sat, 24 Jan 2026 22:49:56 +0100 Subject: [PATCH 01/11] working on true old funz param file --- examples/fzi_formulas_example.py | 200 +++++++++ examples/fzi_static_objects_example.py | 223 ++++++++++ examples/java_funz_syntax_example.py | 195 +++++++++ fz/core.py | 228 +++++++++- fz/helpers.py | 6 +- fz/interpreter.py | 552 ++++++++++++++++++++++- tests/test_cli_commands.py | 1 + tests/test_fzi_formulas.py | 265 ++++++++++++ tests/test_java_funz_compatibility.py | 350 +++++++++++++++ tests/test_model_key_aliases.py | 190 ++++++++ tests/test_no_variables.py | 14 +- tests/test_static_objects.py | 578 +++++++++++++++++++++++++ 12 files changed, 2771 insertions(+), 31 deletions(-) create mode 100644 examples/fzi_formulas_example.py create mode 100644 examples/fzi_static_objects_example.py create mode 100644 examples/java_funz_syntax_example.py create mode 100644 tests/test_fzi_formulas.py create mode 100644 tests/test_java_funz_compatibility.py create mode 100644 tests/test_model_key_aliases.py create mode 100644 tests/test_static_objects.py diff --git a/examples/fzi_formulas_example.py b/examples/fzi_formulas_example.py new file mode 100644 index 0000000..e317bdf --- /dev/null +++ b/examples/fzi_formulas_example.py @@ -0,0 +1,200 @@ +""" +Example demonstrating fzi formula parsing and evaluation + +This example shows how fzi now: +- Parses both variables and formulas +- Extracts default values from variables +- Computes formula values when all variables have defaults +""" +import tempfile +from pathlib import Path +from fz import fzi +import json + + +def example_fzi_with_formulas(): + """Demonstrate fzi with formulas and default values""" + + # Create a model + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + # Create input template with variables and formulas + input_template = """# Perfect Gas Law Example + +# Variables with default values +Temperature (Celsius): $(T_celsius~20) +Volume (Liters): $(V_L~1.0) +Moles: $(n_mol~0.041) + +# Constants +#@: R = 8.314 # Gas constant (J/(mol*K)) + +# Formulas with format specifiers +Temperature (Kelvin): @{$T_celsius + 273.15} +Volume (m³): @{$V_L / 1000 | 0.0000} +Pressure (Pa): @{$n_mol * R * ($T_celsius + 273.15) / ($V_L / 1000) | 0.00} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + + # Write input file + input_file = tmpdir / "input.txt" + with open(input_file, 'w') as f: + f.write(input_template) + + print("=" * 60) + print("FZI Formula Parsing and Evaluation Example") + print("=" * 60) + + # Parse variables and formulas + result = fzi(str(input_file), model=model) + + print("\n1. Variables Found:") + print("-" * 60) + for key, value in result.items(): + # Variables are keys without @{...} syntax + if not key.startswith("@"): + if value is not None: + print(f" {key} = {value}") + else: + print(f" {key} = (no default)") + + print("\n2. Formulas Found:") + print("-" * 60) + for key, value in result.items(): + # Formulas are keys with @{...} syntax + if key.startswith("@"): + if value is not None: + print(f" {key} = {value}") + else: + print(f" {key} = (cannot evaluate)") + + print("\n3. JSON Output:") + print("-" * 60) + print(json.dumps(result, indent=2)) + + print("\n" + "=" * 60) + print("Key Features Demonstrated:") + print("=" * 60) + print("✓ Variables parsed with default values") + print("✓ Formulas parsed from @{...} syntax") + print("✓ Default values automatically extracted") + print("✓ Formulas evaluated when all variables have defaults") + print("✓ Format specifiers applied (| 0.00, | 0.0000)") + print("=" * 60) + + +def example_fzi_without_defaults(): + """Show what happens when variables don't have defaults""" + + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + input_template = """# Variables without defaults +Length: $(x) +Width: $(y) + +# Formulas (cannot be evaluated without variable values) +Area: @{$x * $y} +Perimeter: @{2 * ($x + $y)} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + + input_file = tmpdir / "input.txt" + with open(input_file, 'w') as f: + f.write(input_template) + + print("\n" + "=" * 60) + print("FZI Without Default Values") + print("=" * 60) + + result = fzi(str(input_file), model=model) + + print("\nVariables:") + for key, value in result.items(): + if not key.startswith("@"): + print(f" {key} = {value}") + + print("\nFormulas (cannot be evaluated):") + for key, value in result.items(): + if key.startswith("@"): + print(f" {key} = {value}") + + print("\nNote: Formulas return None when variables lack defaults") + print("=" * 60) + + +def example_fzi_partial_defaults(): + """Show formulas with partial defaults""" + + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + input_template = """# Mixed: some variables with defaults, some without +Length: $(x~10) +Width: $(y) +Height: $(z~5) + +# Formulas +X doubled: @{$x * 2} +Z squared: @{$z ** 2} +Volume: @{$x * $y * $z} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + + input_file = tmpdir / "input.txt" + with open(input_file, 'w') as f: + f.write(input_template) + + print("\n" + "=" * 60) + print("FZI With Partial Defaults") + print("=" * 60) + + result = fzi(str(input_file), model=model) + + print("\nVariables:") + for key, value in result.items(): + if not key.startswith("@"): + status = f"{value}" if value is not None else "(no default)" + print(f" {key} = {status}") + + print("\nFormulas:") + for key, value in result.items(): + if key.startswith("@"): + if value is not None: + print(f" {key} = {value} ✓") + else: + print(f" {key} = None (missing variable defaults)") + + print("\nNote: Formulas evaluate only when ALL referenced variables have defaults") + print("=" * 60) + + +if __name__ == "__main__": + example_fzi_with_formulas() + example_fzi_without_defaults() + example_fzi_partial_defaults() diff --git a/examples/fzi_static_objects_example.py b/examples/fzi_static_objects_example.py new file mode 100644 index 0000000..2ed2702 --- /dev/null +++ b/examples/fzi_static_objects_example.py @@ -0,0 +1,223 @@ +""" +Example demonstrating static object definitions (constants and functions) in fzi + +This example shows how fzi now: +- Parses static object definitions using #@: syntax +- Evaluates constants and functions before formulas +- Includes them in the returned dictionary +- Makes them available for use in formulas +""" +import tempfile +from pathlib import Path +from fz import fzi +import json + + +def example_with_constants(): + """Demonstrate fzi with constant definitions""" + + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + input_template = """# Perfect Gas Law with Constants + +# Static object definitions (constants) +#@: R = 8.314 # Gas constant (J/(mol*K)) +#@: Navo = 6.022e23 # Avogadro's number + +# Variables with default values +Temperature (Celsius): $(T_celsius~20) +Volume (Liters): $(V_L~1.0) +Moles: $(n_mol~0.041) + +# Formulas using constants and variables +Temperature (Kelvin): @{$T_celsius + 273.15} +Volume (m³): @{$V_L / 1000} +Pressure (Pa): @{$n_mol * R * ($T_celsius + 273.15) / ($V_L / 1000)} +Molecules: @{$n_mol * Navo} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + + input_file = tmpdir / "input.txt" + with open(input_file, 'w') as f: + f.write(input_template) + + print("=" * 70) + print("FZI with Constant Definitions Example") + print("=" * 70) + + result = fzi(str(input_file), model=model) + + print("\n1. Static Objects (Constants):") + print("-" * 70) + for key, value in result.items(): + if not key.startswith("$") and not key.startswith("@") and not key.startswith("T_") and not key.startswith("V_") and not key.startswith("n_"): + print(f" {key} = {value}") + + print("\n2. Variables:") + print("-" * 70) + for key, value in result.items(): + if key.startswith("T_") or key.startswith("V_") or key.startswith("n_"): + print(f" {key} = {value}") + + print("\n3. Formulas (evaluated with constants):") + print("-" * 70) + for key, value in result.items(): + if key.startswith("@"): + print(f" {key} = {value}") + + print("\n" + "=" * 70) + + +def example_with_functions(): + """Demonstrate fzi with function definitions""" + + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + input_template = """# Material Density Calculator + +# Static objects: constants and functions +#@: L_x = 0.9 # Reference length +#@: +#@: def density(length, mass): +#@: '''Calculate density based on length and mass''' +#@: if length < 0: +#@: return mass / (L_x + length**2) +#@: else: +#@: return mass / length**3 +#@: +#@: def classify_density(d): +#@: '''Classify material by density''' +#@: if d < 1: +#@: return "light" +#@: elif d < 5: +#@: return "medium" +#@: else: +#@: return "heavy" + +# Variables with defaults +Length (m): $(length~1.2) +Mass (kg): $(mass~0.2) + +# Formulas using functions +Density: @{density($length, $mass)} +Classification: @{classify_density(density($length, $mass))} +Negative Length Density: @{density(-1, $mass)} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + + input_file = tmpdir / "input.txt" + with open(input_file, 'w') as f: + f.write(input_template) + + print("\n" + "=" * 70) + print("FZI with Function Definitions Example") + print("=" * 70) + + result = fzi(str(input_file), model=model) + + print("\n1. Static Objects:") + print("-" * 70) + print(f" L_x (constant) = {result.get('L_x')}") + print(f" density (function) = {result.get('density')}") + print(f" classify_density (function) = {result.get('classify_density')}") + + print("\n2. Variables:") + print("-" * 70) + print(f" length = {result.get('length')}") + print(f" mass = {result.get('mass')}") + + print("\n3. Formulas (using functions):") + print("-" * 70) + for key, value in result.items(): + if key.startswith("@"): + print(f" {key} = {value}") + + print("\n" + "=" * 70) + + +def example_java_funz_compatibility(): + """Example matching Java Funz ParameterizingInputFiles.md""" + + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + input_template = """# Example from Java Funz documentation + +# First variable: $(var1~1.2;"length in cm") +# Second variable: $(var2~0.2;"mass in g") + +# Declare constants +#@: L_x = 0.9 + +# Declare functions +#@: def density(l, m): +#@: if l < 0: +#@: return m / (L_x + l**2) +#@: else: +#@: return m / l**3 + +# Unit tests (these are ignored by fzi) +#@? density(1, 1) == 1 +#@? density(1, 0.2) == 0.2 + +# Use function in formulas +Result1: @{density($var1, $var2)} +Result2: @{density(-1, $var2)} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + + input_file = tmpdir / "input.txt" + with open(input_file, 'w') as f: + f.write(input_template) + + print("\n" + "=" * 70) + print("Java Funz Compatibility Example") + print("=" * 70) + + result = fzi(str(input_file), model=model) + + print("\nComplete result dictionary:") + print("-" * 70) + print(json.dumps({k: str(v) if callable(v) else v for k, v in result.items()}, indent=2)) + + print("\n" + "=" * 70) + print("Key Features:") + print("=" * 70) + print("✓ Constants defined with #@: syntax") + print("✓ Functions defined with multi-line #@: blocks") + print("✓ Unit tests #@? are properly ignored") + print("✓ Functions and constants available in formulas") + print("✓ All objects included in fzi result dictionary") + print("=" * 70) + + +if __name__ == "__main__": + example_with_constants() + example_with_functions() + example_java_funz_compatibility() diff --git a/examples/java_funz_syntax_example.py b/examples/java_funz_syntax_example.py new file mode 100644 index 0000000..1750d96 --- /dev/null +++ b/examples/java_funz_syntax_example.py @@ -0,0 +1,195 @@ +""" +Example demonstrating Java Funz compatible syntax + +This example shows how to use the original Java Funz syntax: +- Variables: $(var) with parentheses +- Formulas: @{expr} with braces +- Variable metadata: $(var~default;comment;bounds) +- Formula format: @{expr | 0.00} +- Function declarations: #@: func = ... +""" +import tempfile +from pathlib import Path +from fz import fzi, fzc, fzr + + +def example_java_funz_syntax(): + """Demonstrate Java Funz compatible syntax""" + + # Create a model with Java Funz syntax + model = { + "var_prefix": "$", + "var_delim": "()", # Variables use () like Java Funz + "formula_prefix": "@", + "formula_delim": "{}", # Formulas use {} like Java Funz + "commentline": "#", + "interpreter": "python", + "output": { + "result": "grep 'result =' output.txt | cut -d'=' -f2" + } + } + + # Create input template using Java Funz syntax + input_template = """# This is a Java Funz compatible input file + +# Declare variables with default values and metadata +# Variable 1: $(x~1.2;"length in cm") +# Variable 2: $(y~0.5;"width in cm";{0.1,0.5,1.0}) + +# Declare constants +#@: PI = 3.14159 +#@: conversion_factor = 10.0 + +# Declare functions +#@: def area(length, width): +#@: return length * width * PI + +# Unit tests (optional) +#@? area(1, 1) > 0 +#@? conversion_factor == 10.0 + +# Use variables in input +length = $(x) +width = $(y) + +# Use formulas with format specifiers +area_cm2 = @{area($x, $y) | 0.00} +area_mm2 = @{area($x, $y) * conversion_factor | 0.0000} + +# Formula without format +perimeter = @{2 * ($x + $y)} +""" + + # Create calculator script + calculator_script = """#!/bin/bash +# Simple calculator that processes the input +source input.txt 2>/dev/null || true + +# Calculate result +result=$(echo "$length * $width" | python -c "import sys; print(eval(input()))") + +# Write output +cat > output.txt << EOF +Input values: + length = $length + width = $width + area_cm2 = $area_cm2 + area_mm2 = $area_mm2 + perimeter = $perimeter + +Calculated: + result = $result +EOF +""" + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + + # Write input template + input_file = tmpdir / "input.txt" + with open(input_file, 'w') as f: + f.write(input_template) + + # Write calculator script + calc_file = tmpdir / "calc.sh" + with open(calc_file, 'w') as f: + f.write(calculator_script) + calc_file.chmod(0o755) + + print("=" * 60) + print("Java Funz Syntax Compatibility Example") + print("=" * 60) + + # Step 1: Parse variables + print("\n1. Parsing variables from template...") + variables = fzi(input_file, model=model) + print(f" Found variables: {list(variables.keys())}") + + # Step 2: Compile template + print("\n2. Compiling template with values...") + input_vars = {"x": 2.5, "y": 1.5} + resultsdir = tmpdir / "results" + fzc(input_file, model=model, input_variables=input_vars, output_dir=resultsdir) + + # Show compiled content + # Find the actual compiled file (directory name varies) + case_dirs = list(resultsdir.glob("*")) + if case_dirs: + compiled_file = case_dirs[0] / "input.txt" + with open(compiled_file) as f: + compiled = f.read() + print(f" Compiled to: {compiled_file}") + print("\n Key substitutions:") + for line in compiled.split('\n'): + if 'length =' in line or 'width =' in line or 'area_' in line or 'perimeter =' in line: + print(f" {line.strip()}") + + # Step 3: Run complete workflow + print("\n3. Running complete workflow with fzr()...") + calculator = f"sh://{calc_file}" + results = fzr( + input_file, + model=model, + input_variables=input_vars, + calculators=calculator, + results_dir=tmpdir / "fzr_results" + ) + + print(f" Results: {results}") + + print("\n" + "=" * 60) + print("Key Java Funz Syntax Features Demonstrated:") + print("=" * 60) + print("✓ Variables with (): $(x), $(y)") + print("✓ Variable metadata: $(x~1.2;\"length in cm\")") + print("✓ Formulas with {}: @{expression}") + print("✓ Format specifiers: @{expr | 0.00}") + print("✓ Function declarations: #@: def func()...") + print("✓ Constants: #@: PI = 3.14159") + print("✓ Unit tests: #@? assertion") + print("=" * 60) + + +def example_backward_compatibility(): + """Show that old ${var} syntax still works""" + + # Old-style model + model = { + "varprefix": "$", + "delim": "{}", # Old default + "formulaprefix": "@", + "commentline": "#", + } + + input_content = "Value: ${x}\nFormula: @{$x * 2}" + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + input_file = tmpdir / "input.txt" + with open(input_file, 'w') as f: + f.write(input_content) + + print("\n" + "=" * 60) + print("Backward Compatibility Example") + print("=" * 60) + print(f"Input: {input_content}") + + variables = fzi(input_file, model=model) + print(f"Variables: {list(variables.keys())}") + + resultsdir = tmpdir / "results" + fzc(input_file, model=model, input_variables={"x": 5}, output_dir=resultsdir) + + case_dirs = list(resultsdir.glob("*")) + if case_dirs: + compiled_file = case_dirs[0] / "input.txt" + with open(compiled_file) as f: + compiled = f.read() + print(f"Compiled: {compiled}") + print("✓ Old ${var} syntax still works!") + print("=" * 60) + + +if __name__ == "__main__": + example_java_funz_syntax() + example_backward_compatibility() diff --git a/fz/core.py b/fz/core.py index f8ae9b4..d5a09f4 100644 --- a/fz/core.py +++ b/fz/core.py @@ -75,7 +75,7 @@ def utf8_open( import shutil from .logging import log_error, log_warning, log_info, log_debug, log_progress -from .config import get_config +from .config import get_config, get_interpreter from .helpers import ( fz_temporary_directory, _get_result_directory, @@ -262,6 +262,103 @@ def _parse_argument(arg, alias_type=None): return arg +def _get_comment_char(model: Dict) -> str: + """ + Get comment character from model with support for multiple aliases + + Supported keys (in order of precedence): + - commentline + - comment_line + - comment_char + - commentchar + - comment + + Args: + model: Model definition dict + + Returns: + Comment character (default "#") + """ + return model.get( + "commentline", + model.get( + "comment_line", + model.get( + "comment_char", + model.get( + "commentchar", + model.get("comment", "#") + ) + ) + ) + ) + + +def _get_var_prefix(model: Dict) -> str: + """ + Get variable prefix from model with support for multiple aliases + + Supported keys (in order of precedence): + - var_prefix + - varprefix + - var_char + - varchar + + Args: + model: Model definition dict + + Returns: + Variable prefix (default "$") + """ + return model.get( + "var_prefix", + model.get( + "varprefix", + model.get( + "var_char", + model.get("varchar", "$") + ) + ) + ) + + +def _get_formula_prefix(model: Dict) -> str: + """ + Get formula prefix from model with support for multiple aliases + + Supported keys (in order of precedence): + - formula_prefix + - formulaprefix + - form_prefix + - formprefix + - formula_char + - form_char + + Args: + model: Model definition dict + + Returns: + Formula prefix (default "@") + """ + return model.get( + "formula_prefix", + model.get( + "formulaprefix", + model.get( + "form_prefix", + model.get( + "formprefix", + model.get( + "formula_char", + model.get("form_char", "@") + ) + ) + ) + ) + ) + + + # Import calculator-related functions from helpers from .helpers import ( _calculator_supports_model, @@ -845,16 +942,23 @@ def fzl(models: str = "*", calculators: str = "*", check: bool = False) -> Dict[ @with_helpful_errors -def fzi(input_path: str, model: Union[str, Dict]) -> Dict[str, None]: +def fzi(input_path: str, model: Union[str, Dict]) -> Dict[str, Any]: """ - Parse input file(s) to find variables + Parse input file(s) to find variables, formulas, and static objects Args: input_path: Path to input file or directory model: Model definition dict or alias string Returns: - Dict of variable names with None values + Dict with static objects, variable names, and formula expressions as keys, with their values (or None) + - Static object keys are the defined names (constants, functions): "PI", "my_function" + - Variable keys are just the variable name: "var1", "var2" + - Formula keys include the formula prefix and delimiters: "@{expr}" + + Static objects are defined with lines starting with commentline + formula_prefix + ":" + (e.g., "#@: PI = 3.14159" or "#@: def my_func(x): return x*2") + and are evaluated before variables and formulas. Raises: TypeError: If arguments have invalid types @@ -871,8 +975,17 @@ def fzi(input_path: str, model: Union[str, Dict]) -> Dict[str, None]: try: model = _resolve_model(model) - varprefix = model.get("varprefix", "$") - delim = model.get("delim", "()") + # Variable prefix: support multiple aliases + varprefix = _get_var_prefix(model) + # Variable delimiters: use var_delim if set, else delim if set, else default to () + var_delim = model.get("var_delim", model.get("delim", "()")) + + # Formula prefix: support multiple aliases + formulaprefix = _get_formula_prefix(model) + formula_delim = model.get("formula_delim", model.get("delim", "{}")) + + # Get interpreter + interpreter = model.get("interpreter", get_interpreter()) input_path = Path(input_path).resolve() @@ -880,9 +993,108 @@ def fzi(input_path: str, model: Union[str, Dict]) -> Dict[str, None]: if not input_path.exists(): raise FileNotFoundError(f"Input path '{input_path}' not found") - variables = parse_variables_from_path(input_path, varprefix, delim) + # Parse variables + variables = parse_variables_from_path(input_path, varprefix, var_delim) + + # Read content to extract defaults and formulas + if input_path.is_file(): + with open(input_path, 'r', encoding='utf-8') as f: + content = f.read() + else: + # For directories, concatenate all file contents + content = "" + for filepath in input_path.rglob("*"): + if filepath.is_file(): + try: + with open(filepath, 'r', encoding='utf-8') as f: + content += f.read() + "\n" + except UnicodeDecodeError: + # Skip binary files + pass + + # Extract default values from variables + from .interpreter import parse_formulas_from_content, evaluate_single_formula, parse_static_objects_from_content, evaluate_static_objects, parse_static_objects_with_expressions + + # Parse static objects to get their expressions (for returning in fzi) + commentline = _get_comment_char(model) + static_expressions = parse_static_objects_with_expressions(content, commentline, formulaprefix) + + # Also evaluate static objects for formula evaluation + static_lines = parse_static_objects_from_content(content, commentline, formulaprefix) + static_objects_evaluated = evaluate_static_objects(static_lines, interpreter) - return {var: None for var in sorted(variables)} + variable_defaults = {} + + # Pattern to match variables with defaults: $(var~default...) + if len(var_delim) == 2: + left_delim, right_delim = var_delim[0], var_delim[1] + esc_varprefix = re.escape(varprefix) + esc_left = re.escape(left_delim) + esc_right = re.escape(right_delim) + + # Match $(var~default...) patterns + default_pattern = rf"{esc_varprefix}{esc_left}([a-zA-Z_][a-zA-Z0-9_]*)~([^{esc_right};]*)" + + for match in re.finditer(default_pattern, content): + var_name = match.group(1) + default_value = match.group(2).strip() + + # Try to parse the default value + try: + # Try numeric conversion + if '.' in default_value or 'e' in default_value.lower(): + variable_defaults[var_name] = float(default_value) + else: + variable_defaults[var_name] = int(default_value) + except ValueError: + # Keep as string, removing quotes if present + if default_value.startswith('"') and default_value.endswith('"'): + variable_defaults[var_name] = default_value[1:-1] + elif default_value.startswith('[') or default_value.startswith('{'): + # Keep bounds/values as string for now + variable_defaults[var_name] = None + else: + variable_defaults[var_name] = default_value + + # Build result dict starting with static objects + result = {} + + # Add static objects first (as their original expressions, not evaluated values) + for name, expression in static_expressions.items(): + # Skip internal keys like _import_* + if not name.startswith('_'): + result[name] = expression + + # Add variables (without prefix/delimiters) + for var in sorted(variables): + result[var] = variable_defaults.get(var, None) + + # Parse formulas + formulas = parse_formulas_from_content(content, formulaprefix, formula_delim) + + # Merge variable defaults with evaluated static objects for formula evaluation + formula_context = {**static_objects_evaluated, **variable_defaults} + + for formula in formulas: + # Check if formula has a default format: expression|format + default_format = None + formula_expr = formula + if '|' in formula: + parts = formula.split('|', 1) + formula_expr = parts[0].strip() + default_format = parts[1].strip() + + # Try to evaluate with available defaults and static objects + value = evaluate_single_formula(formula, model, formula_context, interpreter) + + # If evaluation failed and there's a default format, use it as the value + if value is None and default_format: + value = default_format + + # Use just the formula expression as key (no prefix/delimiters) + result[formula_expr] = value + + return result finally: # Always restore the original working directory os.chdir(working_dir) diff --git a/fz/helpers.py b/fz/helpers.py index 5cecaf7..d6a7683 100644 --- a/fz/helpers.py +++ b/fz/helpers.py @@ -1275,8 +1275,10 @@ def compile_to_result_directories(input_path: str, model: Dict, input_variables: # Get the formula interpreter from model, or fall back to global setting interpreter = model.get("interpreter", get_interpreter()) - varprefix = model.get("varprefix", "$") - delim = model.get("delim", "{}") + # Variable prefix: use var_prefix if set, else varprefix (old name), else default to "$" + varprefix = model.get("var_prefix", model.get("varprefix", "$")) + # Variable delimiters: use var_delim if set, else delim if set, else default to () + delim = model.get("var_delim", model.get("delim", "()")) input_path = Path(input_path) # Determine if input_variables is non-empty diff --git a/fz/interpreter.py b/fz/interpreter.py index 2d185c0..954000e 100755 --- a/fz/interpreter.py +++ b/fz/interpreter.py @@ -8,18 +8,114 @@ from typing import Dict, List, Union, Any, Set -def parse_variables_from_content(content: str, varprefix: str = "$", delim: str = "{}") -> Set[str]: +def _get_comment_char(model: Dict) -> str: + """ + Get comment character from model with support for multiple aliases + + Supported keys (in order of precedence): + - commentline + - comment_line + - comment_char + - commentchar + - comment + + Args: + model: Model definition dict + + Returns: + Comment character (default "#") + """ + return model.get( + "commentline", + model.get( + "comment_line", + model.get( + "comment_char", + model.get( + "commentchar", + model.get("comment", "#") + ) + ) + ) + ) + + +def _get_var_prefix(model: Dict) -> str: + """ + Get variable prefix from model with support for multiple aliases + + Supported keys (in order of precedence): + - var_prefix + - varprefix + - var_char + - varchar + + Args: + model: Model definition dict + + Returns: + Variable prefix (default "$") + """ + return model.get( + "var_prefix", + model.get( + "varprefix", + model.get( + "var_char", + model.get("varchar", "$") + ) + ) + ) + + +def _get_formula_prefix(model: Dict) -> str: + """ + Get formula prefix from model with support for multiple aliases + + Supported keys (in order of precedence): + - formula_prefix + - formulaprefix + - form_prefix + - formprefix + - formula_char + - form_char + + Args: + model: Model definition dict + + Returns: + Formula prefix (default "@") + """ + return model.get( + "formula_prefix", + model.get( + "formulaprefix", + model.get( + "form_prefix", + model.get( + "formprefix", + model.get( + "formula_char", + model.get("form_char", "@") + ) + ) + ) + ) + ) + + +def parse_variables_from_content(content: str, varprefix: str = "$", delim: str = "()") -> Set[str]: """ Parse variables from text content using specified prefix and delimiters - Supports default value syntax: ${var~default} + Supports default value syntax: $(var~default) or $(var~default;comment;bounds) Args: content: Text content to parse varprefix: Variable prefix (e.g., "$") - delim: Delimiter characters (e.g., "{}") + delim: Delimiter characters (e.g., "()") Returns: - Set of variable names found (without default values) + Set of variable names found (without default values and metadata) """ variables = set() @@ -48,14 +144,14 @@ def parse_variables_from_content(content: str, varprefix: str = "$", delim: str return variables -def parse_variables_from_file(filepath: Path, varprefix: str = "$", delim: str = "{}") -> Set[str]: +def parse_variables_from_file(filepath: Path, varprefix: str = "$", delim: str = "()") -> Set[str]: """ Parse variables from a single file Args: filepath: Path to file to parse varprefix: Variable prefix (e.g., "$") - delim: Delimiter characters (e.g., "{}") + delim: Delimiter characters (e.g., "()") Returns: Set of variable names found @@ -70,14 +166,14 @@ def parse_variables_from_file(filepath: Path, varprefix: str = "$", delim: str = return parse_variables_from_content(content, varprefix, delim) -def parse_variables_from_path(input_path: Path, varprefix: str = "$", delim: str = "{}") -> Set[str]: +def parse_variables_from_path(input_path: Path, varprefix: str = "$", delim: str = "()") -> Set[str]: """ Parse variables from file or directory Args: input_path: Path to input file or directory varprefix: Variable prefix (e.g., "$") - delim: Delimiter characters (e.g., "{}") + delim: Delimiter characters (e.g., "()") Returns: Set of variable names found @@ -97,10 +193,10 @@ def parse_variables_from_path(input_path: Path, varprefix: str = "$", delim: str def replace_variables_in_content(content: str, input_variables: Dict[str, Any], - varprefix: str = "$", delim: str = "{}") -> str: + varprefix: str = "$", delim: str = "()") -> str: """ Replace variables in content with their values - Supports default value syntax: ${var~default} + Supports default value syntax: $(var~default) or $(var~default;comment;bounds) If a variable is not found in input_variables but has a default value, the default will be used and a warning will be printed. @@ -109,7 +205,7 @@ def replace_variables_in_content(content: str, input_variables: Dict[str, Any], content: Text content to process input_variables: Dict of variable values varprefix: Variable prefix (e.g., "$") - delim: Delimiter characters (e.g., "{}") + delim: Delimiter characters (e.g., "()") Returns: Content with variables replaced @@ -155,9 +251,413 @@ def replace_delimited(match): return content +def parse_formulas_from_content(content: str, formula_prefix: str = "@", delim: str = "{}") -> List[str]: + """ + Parse formulas from text content using specified prefix and delimiters + + Args: + content: Text content to parse + formula_prefix: Formula prefix (e.g., "@") + delim: Delimiter characters (e.g., "{}") + + Returns: + List of formula expressions found (without prefix and delimiters) + """ + formulas = [] + + if len(delim) != 2: + return formulas # No formulas without delimiters + + left_delim, right_delim = delim[0], delim[1] + esc_formulaprefix = re.escape(formula_prefix) + esc_left = re.escape(left_delim) + esc_right = re.escape(right_delim) + + # Use a more sophisticated pattern to handle nested parentheses + if left_delim == '(' and right_delim == ')': + formula_pattern = rf"{esc_formulaprefix}\(([^()]*(?:\([^()]*\)[^()]*)*)\)" + else: + formula_pattern = rf"{esc_formulaprefix}{esc_left}([^{esc_right}]+){esc_right}" + + matches = re.findall(formula_pattern, content) + for match in matches: + formulas.append(match) + + return formulas + + +def parse_static_objects_from_content(content: str, commentline: str = "#", formula_prefix: str = "@") -> List[str]: + """ + Parse static object definitions (constants, functions) from text content. + These are lines that start with commentline + formula_prefix + ":" + + Args: + content: Text content to parse + commentline: Comment character (e.g., "#") + formula_prefix: Formula prefix (e.g., "@") + + Returns: + List of code lines for static object definitions + """ + static_lines = [] + lines = content.split('\n') + + prefix = commentline + formula_prefix + + for line in lines: + stripped = line.strip() + if stripped.startswith(prefix): + # Extract the code part after the prefix + code_part = stripped[len(prefix):] + # Skip if it's a unit test (starts with ?) + if code_part.lstrip().startswith('?'): + continue + # ONLY process lines that start with : (colon for static objects) + if not code_part.lstrip().startswith(':'): + continue + # Skip if it's just a colon with nothing after + if not code_part.strip() or code_part.strip() == ':': + continue + # Remove leading : and one space if present + if code_part.lstrip().startswith(':'): + # Find where the colon is + colon_idx = code_part.index(':') + # Take everything after the colon + code_part = code_part[colon_idx + 1:] + # Remove one space if present (but preserve other indentation) + if code_part.startswith(' '): + code_part = code_part[1:] + static_lines.append(code_part) + + return static_lines + + +def parse_static_objects_with_expressions(content: str, commentline: str = "#", formula_prefix: str = "@") -> Dict[str, str]: + """ + Parse static object definitions and return a dict mapping object names to their original expressions. + These are lines that start with commentline + formula_prefix + ":" + + Args: + content: Text content to parse + commentline: Comment character (e.g., "#") + formula_prefix: Formula prefix (e.g., "@") + + Returns: + Dict mapping object names to their original expressions (e.g., {"PI": "3.14159", "func": "def func(x): ..."}) + """ + static_lines = parse_static_objects_from_content(content, commentline, formula_prefix) + + if not static_lines: + return {} + + # Join all lines and dedent + import textwrap + full_code = "\n".join(static_lines) + dedented_code = textwrap.dedent(full_code) + + # Parse to find defined names and their expressions + expressions = {} + + # Split into individual lines for parsing + lines = dedented_code.split('\n') + i = 0 + while i < len(lines): + line = lines[i].strip() + + if not line or line.startswith('#'): + i += 1 + continue + + # Check for Python assignment: name = value + if '=' in line and not line.startswith('def ') and '<-' not in line: + parts = line.split('=', 1) + if len(parts) == 2: + name = parts[0].strip() + # Remove any type hints + if ':' in name: + name = name.split(':')[0].strip() + value = parts[1].strip() + expressions[name] = value + i += 1 + continue + + # Check for R assignment: name <- value or name <- function(...) + if '<-' in line: + parts = line.split('<-', 1) + if len(parts) == 2: + name = parts[0].strip() + value_part = parts[1].strip() + + # Check if it's a function definition + if value_part.startswith('function('): + # Collect multi-line function (store only the RHS: function(...) {...}) + func_lines = [value_part] # Start with the function(...) part + i += 1 + # Continue collecting lines that are part of the function + while i < len(lines): + next_line = lines[i] + # If line is indented or empty, it's part of the function + if not next_line.strip(): + func_lines.append(next_line) + i += 1 + elif next_line.startswith(' ') or next_line.startswith('\t'): + func_lines.append(next_line) + i += 1 + # Check if this line contains the closing } + if '}' in next_line: + break + else: + break + + # Store only the function expression (RHS), not the assignment + expressions[name] = '\n'.join(func_lines) + else: + # Simple R assignment - store only the RHS + expressions[name] = value_part + i += 1 + continue + + # Check for Python function definition: def name(...): + if line.startswith('def '): + # Extract function name + match = re.match(r'def\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(', line) + if match: + func_name = match.group(1) + # Collect the full function definition (multi-line) + func_lines = [lines[i]] + i += 1 + # Continue collecting lines that are indented or part of the function + while i < len(lines): + next_line = lines[i] + # If line is indented or empty, it's part of the function + if not next_line.strip() or next_line.startswith(' ') or next_line.startswith('\t'): + func_lines.append(next_line) + i += 1 + else: + break + + # Store the function definition + expressions[func_name] = '\n'.join(func_lines) + continue + + # Check for other statements (import, etc.) + if line.startswith('import ') or line.startswith('from '): + # Store import statements with a generic key + import_key = f"_import_{i}" + expressions[import_key] = line + + i += 1 + + return expressions + + +def evaluate_static_objects(static_lines: List[str], interpreter: str = "python") -> Dict[str, Any]: + """ + Evaluate static object definitions and extract their values. + + Args: + static_lines: List of code lines to evaluate + interpreter: Interpreter to use ("python" or "R") + + Returns: + Dict of {name: value} for all defined constants and functions + """ + if not static_lines: + return {} + + static_objects = {} + + if interpreter.lower() == "python": + # Create execution environment + env = {} + + # Use textwrap.dedent for proper dedenting + import textwrap + full_code = "\n".join(static_lines) + dedented_code = textwrap.dedent(full_code) + + try: + exec(dedented_code, env) + # Extract defined names (skip builtins) + for name, value in env.items(): + if not name.startswith('__'): + static_objects[name] = value + except Exception as e: + print(f"Warning: Error executing static objects: {e}") + + elif interpreter.lower() == "r": + try: + from rpy2 import robjects as ro + from rpy2.robjects import conversion, default_converter + except ImportError: + print("Warning: rpy2 not installed, cannot evaluate R static objects") + return {} + + # Use textwrap.dedent for proper dedenting + import textwrap + full_code = "\n".join(static_lines) + dedented_code = textwrap.dedent(full_code) + + try: + ro.r(dedented_code) + # Extract defined names from R global environment + for name in ro.r('ls()'): + try: + value = ro.r[name] + # Convert R objects to Python + with conversion.localconverter(default_converter): + static_objects[name] = conversion.rpy2py(value) + except Exception: + # For functions or complex objects, store None + static_objects[name] = None + except Exception as e: + print(f"Warning: Error executing R static objects: {e}") + + return static_objects + + +def evaluate_single_formula(formula: str, model: Dict, input_variables: Dict, interpreter: str = "python") -> Any: + """ + Evaluate a single formula expression and return its value + + Args: + formula: Formula expression to evaluate (without prefix and delimiters) + model: Model definition dict + input_variables: Dict of variable values + interpreter: Interpreter for evaluation ("python", "R", etc.) + + Returns: + Evaluated value or None if evaluation fails + """ + commentline = _get_comment_char(model) + varprefix = _get_var_prefix(model) + var_delim = model.get("var_delim", model.get("delim", "()")) + + # Extract context lines from model if available + context_lines = [] + + # Setup interpreter environment + if interpreter.lower() == "python": + env = dict(input_variables) + + # Import common math functions + import math + env.update({ + 'sin': math.sin, 'cos': math.cos, 'tan': math.tan, + 'log': math.log, 'log10': math.log10, 'exp': math.exp, + 'sqrt': math.sqrt, 'abs': abs, 'floor': math.floor, + 'ceil': math.ceil, 'pow': pow + }) + + # Handle format suffix if present + format_spec = None + if '|' in formula: + formula, format_spec = formula.split('|', 1) + formula = formula.strip() + format_spec = format_spec.strip() + + # Replace variables in formula using the model's variable prefix + # Handle both delimited and non-delimited variables + for var, val in input_variables.items(): + if len(var_delim) == 2: + # Try with delimiters first: $(...) or V(...) + left_delim = var_delim[0] + right_delim = var_delim[1] + var_pattern_delim = rf'{re.escape(varprefix)}{re.escape(left_delim)}{re.escape(var)}{re.escape(right_delim)}' + formula = re.sub(var_pattern_delim, str(val), formula) + + # Also try without delimiters: $var or Vvar + var_pattern = rf'{re.escape(varprefix)}{re.escape(var)}\b' + formula = re.sub(var_pattern, str(val), formula) + + try: + result = eval(formula, env) + + # Apply format if specified + if format_spec and '.' in format_spec: + decimals = len(format_spec.split('.')[1]) + try: + return float(f"{float(result):.{decimals}f}") + except (ValueError, TypeError): + return result + + return result + except Exception as e: + # Return None if evaluation fails + return None + + elif interpreter.lower() == "r": + try: + from rpy2 import robjects + from rpy2.robjects import r + except ImportError: + return None + + # Set R variables + for var, val in input_variables.items(): + try: + if isinstance(val, (int, float)): + robjects.globalenv[var] = val + elif isinstance(val, str): + robjects.globalenv[var] = val + elif isinstance(val, (list, tuple)): + robjects.globalenv[var] = robjects.FloatVector(val) + elif hasattr(val, '__module__') and 'rpy2' in str(val.__module__): + # R object (function, vector, etc.) - assign directly + robjects.globalenv[var] = val + else: + robjects.globalenv[var] = str(val) + except Exception: + pass + + # Handle format suffix + format_spec = None + if '|' in formula: + formula, format_spec = formula.split('|', 1) + formula = formula.strip() + format_spec = format_spec.strip() + + # Replace variables in formula (remove variable prefix for R) + # Handle both delimited and non-delimited variables + r_formula = formula + for var in input_variables.keys(): + if len(var_delim) == 2: + # Try with delimiters first + left_delim = var_delim[0] + right_delim = var_delim[1] + var_pattern_delim = rf'{re.escape(varprefix)}{re.escape(left_delim)}{re.escape(var)}{re.escape(right_delim)}' + r_formula = re.sub(var_pattern_delim, var, r_formula) + + # Also try without delimiters + var_pattern = rf'{re.escape(varprefix)}{re.escape(var)}\b' + r_formula = re.sub(var_pattern, var, r_formula) + + try: + result = r(r_formula) + if hasattr(result, '__len__') and len(result) == 1: + value = result[0] + else: + value = result if not (hasattr(result, '__len__') and len(result) == 0) else result + + # Apply format if specified + if format_spec and '.' in format_spec: + decimals = len(format_spec.split('.')[1]) + try: + return float(f"{float(value):.{decimals}f}") + except (ValueError, TypeError): + return value + + return value + except Exception: + return None + + return None + + def evaluate_formulas(content: str, model: Dict, input_variables: Dict, interpreter: str = "python") -> str: """ Evaluate formulas in content using specified interpreter + Supports format specifier: @{expr | format} Args: content: Text content containing formulas @@ -168,9 +668,11 @@ def evaluate_formulas(content: str, model: Dict, input_variables: Dict, interpre Returns: Content with formulas evaluated """ - formulaprefix = model.get("formulaprefix", "@") - delim = model.get("delim", "{}") - commentline = model.get("commentline", "#") + # Formula prefix: support multiple aliases + formulaprefix = _get_formula_prefix(model) + # Formula delimiters: use formula_delim if set, else delim if set, else default to {} + delim = model.get("formula_delim", model.get("delim", "{}")) + commentline = _get_comment_char(model) # Only validate delim if it will be used (when we have delimiters) if len(delim) != 2 and len(delim) != 0: @@ -243,13 +745,33 @@ def evaluate_formulas(content: str, model: Dict, input_variables: Dict, interpre def replace_formula(match): formula = match.group(1) try: + # Handle format suffix (e.g., "expr | 0.0000" for number formatting) + format_spec = None + if '|' in formula: + formula, format_spec = formula.split('|', 1) + formula = formula.strip() + format_spec = format_spec.strip() + # Replace variables in formula with their values for var, val in input_variables.items(): var_pattern = rf'\${re.escape(var)}\b' formula = re.sub(var_pattern, str(val), formula) result = eval(formula, env) - return str(result) + + # Apply format if specified + if format_spec: + # Parse format like "0.0000" → 4 decimals + if '.' in format_spec: + decimals = len(format_spec.split('.')[1]) + try: + return f"{float(result):.{decimals}f}" + except (ValueError, TypeError): + return str(result) + else: + return str(result) + else: + return str(result) except Exception as e: print(f"Warning: Error evaluating formula '{formula}': {e}") return match.group(0) # Return original if evaluation fails diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index f49bb0f..4732976 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -176,6 +176,7 @@ def test_fzi_json_format(self, sample_input_file, sample_model, temp_workspace): assert result.returncode == 0 output = json.loads(result.stdout) assert isinstance(output, dict) + # Single-level dict with variables as keys assert "var1" in output assert "var2" in output assert "var3" in output diff --git a/tests/test_fzi_formulas.py b/tests/test_fzi_formulas.py new file mode 100644 index 0000000..3425881 --- /dev/null +++ b/tests/test_fzi_formulas.py @@ -0,0 +1,265 @@ +""" +Test fzi formula parsing and evaluation functionality +""" +import tempfile +from pathlib import Path +from fz import fzi + + +def test_fzi_returns_variables_and_formulas(): + """Test that fzi returns both variables and formulas""" + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + } + + content = """ +# Variables +Length: $(x) +Width: $(y) + +# Formulas +Area: @{$x * $y} +Perimeter: @{2 * ($x + $y)} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Should return flat dict + assert isinstance(result, dict) + + # Variables should be found + assert "x" in result + assert "y" in result + + # Formulas should be found (with @{} syntax) + assert any("@{" in k and "$x * $y" in k for k in result.keys()) + assert any("@{" in k and "2 * ($x + $y)" in k for k in result.keys()) + + +def test_fzi_extracts_variable_defaults(): + """Test that fzi extracts default values from variables""" + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + } + + content = """ +# Variables with defaults +Length: $(x~10) +Width: $(y~5.5) +Name: $(name~"test") +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Variables should have their default values + assert result["x"] == 10 + assert result["y"] == 5.5 + assert result["name"] == "test" + + +def test_fzi_evaluates_formulas_with_defaults(): + """Test that fzi evaluates formulas when all variables have defaults""" + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + content = """ +# Variables with defaults +Length: $(x~10) +Width: $(y~5) + +# Formulas +Area: @{$x * $y} +Perimeter: @{2 * ($x + $y)} +Sum: @{$x + $y} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Formulas should be evaluated + # Find the area formula + area_formula = [k for k in result.keys() if "@{" in k and "$x * $y" in k][0] + assert result[area_formula] == 50 + + # Find the sum formula + sum_formula = [k for k in result.keys() if "@{" in k and "$x + $y" in k and "2 *" not in k][0] + assert result[sum_formula] == 15 + + # Find the perimeter formula + perim_formula = [k for k in result.keys() if "@{" in k and "2 * ($x + $y)" in k][0] + assert result[perim_formula] == 30 + + +def test_fzi_formulas_without_defaults(): + """Test that formulas without defaults return None""" + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + content = """ +# Variables without defaults +Length: $(x) +Width: $(y) + +# Formulas (cannot be evaluated without variable values) +Area: @{$x * $y} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Variables should be None + assert result["x"] is None + assert result["y"] is None + + # Formulas should be None (cannot evaluate without values) + area_formula = [k for k in result.keys() if "@{" in k and "$x * $y" in k][0] + assert result[area_formula] is None + + +def test_fzi_formulas_with_format_specifier(): + """Test that fzi handles formula format specifiers""" + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + content = """ +# Variables with defaults +Value: $(x~1) + +# Formula with format specifier +Result: @{$x / 3 | 0.0000} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Formula should be evaluated with formatting + formula_key = [k for k in result.keys() if "@{" in k and "$x / 3" in k][0] + # Should be formatted to 4 decimal places: 1/3 = 0.3333 + assert abs(result[formula_key] - 0.3333) < 0.0001 + + +def test_fzi_mixed_defaults_and_no_defaults(): + """Test formulas when some variables have defaults and some don't""" + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + content = """ +# Variables - some with defaults, some without +Length: $(x~10) +Width: $(y) + +# Formulas +Area: @{$x * $y} +Double: @{$x * 2} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Formula using only x (which has default) should evaluate + double_formula = [k for k in result.keys() if "@{" in k and "$x * 2" in k][0] + assert result[double_formula] == 20 + + # Formula using both x and y (y has no default) should be None + area_formula = [k for k in result.keys() if "@{" in k and "$x * $y" in k][0] + assert result[area_formula] is None + + +def test_fzi_with_math_functions(): + """Test formulas with math functions""" + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + content = """ +# Variables with defaults +Radius: $(r~5) + +# Formulas with math functions +import math +Area: @{3.14159 * $r ** 2} +Sqrt: @{sqrt($r)} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Area formula + area_formula = [k for k in result.keys() if "@{" in k and "3.14159" in k][0] + assert abs(result[area_formula] - 78.53975) < 0.001 + + # Sqrt formula + sqrt_formula = [k for k in result.keys() if "@{" in k and "sqrt" in k][0] + assert abs(result[sqrt_formula] - 2.236) < 0.001 + + +if __name__ == "__main__": + import pytest + pytest.main([__file__, "-v"]) diff --git a/tests/test_java_funz_compatibility.py b/tests/test_java_funz_compatibility.py new file mode 100644 index 0000000..abd8b4a --- /dev/null +++ b/tests/test_java_funz_compatibility.py @@ -0,0 +1,350 @@ +""" +Test Java Funz syntax compatibility + +Verifies that FZ supports the original Java Funz syntax: +- Variables: $(var) instead of ${var} +- Formulas: @{expr} +- Variable metadata: $(var~default;comment;bounds) +- Formula format: @{expr | 0.00} +- Function declarations: #@: func = ... +- Unit tests: #@? assertion +""" +import tempfile +from pathlib import Path +from fz.interpreter import ( + parse_variables_from_content, + replace_variables_in_content, + evaluate_formulas, +) + + +def test_variable_syntax_with_parentheses(): + """Test that variables work with () delimiters like Java Funz""" + content = """ +CodeWord1 $(var1) +CodeWord2 $(var2) +""" + variables = parse_variables_from_content(content, varprefix="$", delim="()") + assert variables == {"var1", "var2"} + + +def test_variable_with_default_value(): + """Test Java Funz default value syntax: $(var~default)""" + content = "CodeWord $(var1~0.1)" + variables = parse_variables_from_content(content, varprefix="$", delim="()") + assert "var1" in variables + + +def test_variable_with_comment(): + """Test Java Funz comment syntax: $(var~"comment")""" + content = 'CodeWord $(var1~"length cm")' + variables = parse_variables_from_content(content, varprefix="$", delim="()") + assert "var1" in variables + + +def test_variable_with_bounds(): + """Test Java Funz bounds syntax: $(var~[0,1])""" + content = "CodeWord $(var1~[0,1])" + variables = parse_variables_from_content(content, varprefix="$", delim="()") + assert "var1" in variables + + +def test_variable_with_discrete_values(): + """Test Java Funz discrete values syntax: $(var~{0,0.1,0.2})""" + content = "CodeWord $(var1~{0,0.1,0.2})" + variables = parse_variables_from_content(content, varprefix="$", delim="()") + assert "var1" in variables + + +def test_variable_with_all_metadata(): + """Test Java Funz full metadata syntax: $(var~default;comment;{values})""" + content = 'CodeWord $(var1~0.1;"cm";{0,0.1,0.2})' + variables = parse_variables_from_content(content, varprefix="$", delim="()") + assert "var1" in variables + + +def test_variable_replacement_with_parentheses(): + """Test variable replacement with () delimiters""" + content = "Value is $(x)" + result = replace_variables_in_content( + content, {"x": 42}, varprefix="$", delim="()" + ) + assert result == "Value is 42" + + +def test_variable_replacement_with_default(): + """Test variable replacement with default value""" + content = "Value is $(x~100)" + # Variable not provided, should use default + result = replace_variables_in_content(content, {}, varprefix="$", delim="()") + assert result == "Value is 100" + + +def test_formula_with_braces(): + """Test that formulas work with {} delimiters like Java Funz""" + model = { + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + } + content = "Result: @{$x ** 3}" + result = evaluate_formulas(content, model, {"x": 2}, interpreter="python") + assert "8" in result + + +def test_formula_with_format_specifier(): + """Test Java Funz format specifier: @{expr | 0.00}""" + model = { + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + } + content = "Result: @{$x / 3 | 0.0000}" + result = evaluate_formulas(content, model, {"x": 1}, interpreter="python") + # Should format to 4 decimal places + assert "0.3333" in result + + +def test_function_declaration_with_colon_prefix(): + """Test Java Funz function declaration: #@: func = ...""" + model = { + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + } + content = """#@: Navo = 6.022141e+23 +Result: @{$x * Navo}""" + result = evaluate_formulas(content, model, {"x": 1}, interpreter="python") + assert "6.022141e+23" in result + + +def test_multiline_function_declaration(): + """Test Java Funz multiline function: #@: func = function(x) {...}""" + model = { + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + } + content = """#@: def density(l, m): +#@: if l < 0: +#@: return m / (0.9 + l**2) +#@: else: +#@: return m / l**3 +Result: @{density($var1, $var2) | 0.000}""" + result = evaluate_formulas(content, model, {"var1": 1, "var2": 0.2}, interpreter="python") + assert "0.200" in result + + +def test_backward_compatibility_with_braces(): + """Test that old ${var} syntax still works for backward compatibility""" + content = "Value is ${x}" + variables = parse_variables_from_content(content, varprefix="$", delim="{}") + assert "x" in variables + + result = replace_variables_in_content(content, {"x": 42}, varprefix="$", delim="{}") + assert result == "Value is 42" + + +def test_mixed_variable_and_formula_delimiters(): + """Test using () for variables and {} for formulas simultaneously""" + # This would be used in a real model + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + } + + # Parse variables with () delimiters + var_content = "Input: $(x)" + variables = parse_variables_from_content(var_content, varprefix="$", delim="()") + assert "x" in variables + + # Replace variables with () delimiters + var_result = replace_variables_in_content(var_content, {"x": 2}, varprefix="$", delim="()") + assert var_result == "Input: 2" + + # Evaluate formulas with {} delimiters + formula_content = "Result: @{$x ** 3}" + formula_result = evaluate_formulas(formula_content, model, {"x": 2}, interpreter="python") + assert "8" in formula_result + + +def test_java_funz_complete_example(): + """Test a complete Java Funz example from the documentation""" + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + } + + content = """# Declare variables +# First variable: $(var1~1.2;"length in cm") +# Second variable: $(var2~0.2;"mass in g") + +# Declare constants +#@: L_x = 0.9 + +# Declare functions +#@: def density(l, m): +#@: if l < 0: +#@: return m / (L_x + l**2) +#@: else: +#@: return m / l**3 + +... +CodeWord1 $(var1) +... +CodeWord2 [0.5, 0.7, $(var1), 1.0] +... +CodeWord3 @{density($var1, $var2) | 0.000} +...""" + + # Parse variables + variables = parse_variables_from_content(content, varprefix="$", delim="()") + assert "var1" in variables + assert "var2" in variables + + # Replace variables + var_result = replace_variables_in_content( + content, {"var1": 1.2, "var2": 0.2}, varprefix="$", delim="()" + ) + assert "CodeWord1 1.2" in var_result + assert "[0.5, 0.7, 1.2, 1.0]" in var_result + + # Evaluate formulas + final_result = evaluate_formulas(var_result, model, {"var1": 1.2, "var2": 0.2}, interpreter="python") + # density(1.2, 0.2) = 0.2 / 1.2^3 ≈ 0.116 + assert "0.116" in final_result + + +def test_delim_sets_both_delimiters(): + """Test that delim field sets both var_delim and formula_delim""" + model = { + "var_prefix": "$", + "formula_prefix": "@", + "delim": "{}", # Should apply to both variables and formulas + "commentline": "#", + } + + # Variables should use {} when only delim is set + var_content = "Value: ${x}" + variables = parse_variables_from_content(var_content, varprefix="$", delim="{}") + assert "x" in variables + + var_result = replace_variables_in_content(var_content, {"x": 42}, varprefix="$", delim="{}") + assert var_result == "Value: 42" + + # Formulas should also use {} when only delim is set + formula_content = "Result: @{$x * 2}" + formula_result = evaluate_formulas(formula_content, model, {"x": 5}, interpreter="python") + assert "10" in formula_result + + +def test_var_delim_overrides_delim(): + """Test that var_delim overrides delim for variables""" + model = { + "var_prefix": "$", + "formula_prefix": "@", + "delim": "{}", # Default for both + "var_delim": "()", # Override for variables only + "commentline": "#", + } + + # Variables should use () from var_delim + var_content = "Value: $(x)" + variables = parse_variables_from_content(var_content, varprefix="$", delim="()") + assert "x" in variables + + # Formulas should still use {} from delim + formula_content = "Result: @{$x * 2}" + formula_result = evaluate_formulas(formula_content, model, {"x": 5}, interpreter="python") + assert "10" in formula_result + + +def test_formula_delim_overrides_delim(): + """Test that formula_delim overrides delim for formulas""" + model = { + "var_prefix": "$", + "formula_prefix": "@", + "delim": "()", # Default for both + "formula_delim": "{}", # Override for formulas only + "commentline": "#", + } + + # Variables should use () from delim + var_content = "Value: $(x)" + variables = parse_variables_from_content(var_content, varprefix="$", delim="()") + assert "x" in variables + + # Formulas should use {} from formula_delim + formula_content = "Result: @{$x * 2}" + formula_result = evaluate_formulas(formula_content, model, {"x": 5}, interpreter="python") + assert "10" in formula_result + + +def test_old_field_names_still_work(): + """Test that old field names (varprefix, formulaprefix) still work for backward compatibility""" + model = { + "varprefix": "$", # Old name + "formulaprefix": "@", # Old name + "delim": "{}", + "commentline": "#", + } + + # Variables should work with old field name + var_content = "Value: ${x}" + variables = parse_variables_from_content(var_content, varprefix="$", delim="{}") + assert "x" in variables + + var_result = replace_variables_in_content(var_content, {"x": 99}, varprefix="$", delim="{}") + assert var_result == "Value: 99" + + # Formulas should work with old field name + formula_content = "Result: @{$x * 3}" + formula_result = evaluate_formulas(formula_content, model, {"x": 7}, interpreter="python") + assert "21" in formula_result + + +def test_new_field_names_override_old(): + """Test that new field names (var_prefix, formula_prefix) take priority over old names""" + model = { + "varprefix": "!", # Old name (should be ignored) + "var_prefix": "$", # New name (should be used) + "formulaprefix": "^", # Old name (should be ignored) + "formula_prefix": "@", # New name (should be used) + "delim": "{}", + "commentline": "#", + } + + # Should use $ from var_prefix, not ! from varprefix + var_content = "Value: ${x}" + variables = parse_variables_from_content(var_content, varprefix="$", delim="{}") + assert "x" in variables + + # Should use @ from formula_prefix, not ^ from formulaprefix + formula_content = "Result: @{$x * 2}" + formula_result = evaluate_formulas(formula_content, model, {"x": 4}, interpreter="python") + assert "8" in formula_result + + +def test_formprefix_alias(): + """Test that formprefix (short form) works as an alias for formulaprefix""" + model = { + "var_prefix": "$", + "formprefix": "@", # Short form alias + "delim": "{}", + "commentline": "#", + } + + formula_content = "Result: @{$x + 10}" + formula_result = evaluate_formulas(formula_content, model, {"x": 5}, interpreter="python") + assert "15" in formula_result + + +if __name__ == "__main__": + import pytest + pytest.main([__file__, "-v"]) diff --git a/tests/test_model_key_aliases.py b/tests/test_model_key_aliases.py new file mode 100644 index 0000000..e7396c3 --- /dev/null +++ b/tests/test_model_key_aliases.py @@ -0,0 +1,190 @@ +""" +Test all model key aliases for comment, var_prefix, and formula_prefix +""" +import tempfile +from pathlib import Path +from fz import fzi + + +def test_var_prefix_aliases(): + """Test that all var_prefix aliases work: var_prefix, varprefix, var_char, varchar""" + + aliases = [ + ("var_prefix", "$"), + ("varprefix", "%"), + ("var_char", "V"), + ("varchar", "!"), + ] + + for key, prefix in aliases: + model = { + key: prefix, + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + content = f""" +#@: CONST = 50 + +Variable: {prefix}(x~10) +Result: @{{CONST + {prefix}x}} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Check variable was parsed + assert "x" in result, f"Failed for alias '{key}': variable 'x' not found" + assert result["x"] == 10, f"Failed for alias '{key}': x != 10" + + # Check static constant + assert "CONST" in result, f"Failed for alias '{key}': CONST not found" + assert result["CONST"] == "50", f"Failed for alias '{key}': CONST != 50" + + # Check formula evaluated + # Formula key is CONST + {prefix}x + result_key = f"CONST + {prefix}x" + assert result[result_key] == 60, f"Failed for alias '{key}': formula didn't evaluate to 60" + + +def test_formula_prefix_aliases(): + """Test that all formula_prefix aliases work""" + + aliases = [ + ("formula_prefix", "@"), + ("formulaprefix", "F"), + ("form_prefix", "~"), + ("formprefix", "&"), + ("formula_char", "="), + ("form_char", "+"), + ] + + for key, prefix in aliases: + model = { + "var_prefix": "$", + "var_delim": "()", + key: prefix, + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + content = f""" +#{prefix}: PI = 3.14159 + +Radius: $(r~5) +Area: {prefix}{{PI * $r ** 2}} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Check static constant + assert "PI" in result, f"Failed for alias '{key}': PI not found" + assert result["PI"] == "3.14159", f"Failed for alias '{key}': PI value wrong" + + # Check variable + assert "r" in result, f"Failed for alias '{key}': r not found" + assert result["r"] == 5, f"Failed for alias '{key}': r != 5" + + # Check formula evaluated + formula_key = [k for k in result.keys() if prefix + "{" in k][0] + expected = 3.14159 * 25 # PI * r^2 + assert abs(result[formula_key] - expected) < 0.001, f"Failed for alias '{key}': formula value wrong" + + +def test_comment_char_aliases(): + """Test that all comment char aliases work""" + + aliases = [ + ("commentline", "#"), + ("comment_line", "*"), + ("comment_char", "%"), + ("commentchar", "//"), + ("comment", ">>"), + ] + + for key, char in aliases: + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + key: char, + "interpreter": "python", + } + + content = f""" +{char}@: VALUE = 100 + +Number: $(n~5) +Sum: @{{VALUE + $n}} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Check static constant + assert "VALUE" in result, f"Failed for alias '{key}': VALUE not found" + assert result["VALUE"] == "100", f"Failed for alias '{key}': VALUE != 100" + + # Check formula evaluated + sum_key = "VALUE + $n" + assert result[sum_key] == 105, f"Failed for alias '{key}': formula didn't evaluate" + + +def test_combined_aliases(): + """Test using all three alias types together""" + + model = { + "varchar": "V", # Using varchar alias for var_prefix + "var_delim": "()", + "form_char": "F", # Using form_char alias for formula_prefix + "formula_delim": "{}", + "comment": "*", # Using comment alias + "interpreter": "python", + } + + content = """ +*F: MULTIPLIER = 3 + +Input: V(val~7) +Output: F{V(val) * MULTIPLIER} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Check all parts work + assert "MULTIPLIER" in result + assert result["MULTIPLIER"] == "3" + assert "val" in result + assert result["val"] == 7 + + # Check formula + formula_key = [k for k in result.keys() if "F{" in k][0] + assert result[formula_key] == 21 # 7 * 3 + + +if __name__ == "__main__": + import pytest + pytest.main([__file__, "-v"]) diff --git a/tests/test_no_variables.py b/tests/test_no_variables.py index 712216a..14d1098 100644 --- a/tests/test_no_variables.py +++ b/tests/test_no_variables.py @@ -215,10 +215,11 @@ def test_fzi_with_empty_file(): } # Parse variables from empty file - variables = fzi(input_path=str(empty_file), model=model) + result = fzi(input_path=str(empty_file), model=model) - assert isinstance(variables, dict) - assert len(variables) == 0 or all(v is None for v in variables.values()) + assert isinstance(result, dict) + # Should return empty dict or dict with None values + assert len(result) == 0 or all(v is None for v in result.values()) def test_fzi_with_no_variables(): @@ -236,10 +237,11 @@ def test_fzi_with_no_variables(): } # Parse variables - variables = fzi(input_path=str(input_file), model=model) + result = fzi(input_path=str(input_file), model=model) - assert isinstance(variables, dict) - assert len(variables) == 0 or all(v is None for v in variables.values()) + assert isinstance(result, dict) + # Should return empty dict or dict with None values + assert len(result) == 0 or all(v is None for v in result.values()) def test_parse_with_invalid_varprefix(): diff --git a/tests/test_static_objects.py b/tests/test_static_objects.py new file mode 100644 index 0000000..3f51c0c --- /dev/null +++ b/tests/test_static_objects.py @@ -0,0 +1,578 @@ +""" +Test static object definitions (constants, functions) in fzi +""" +import tempfile +from pathlib import Path +from fz import fzi + + +def test_fzi_with_constant_definition(): + """Test that fzi parses constant definitions and returns their expressions""" + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + content = """ +# Static object: constant +#@: PI = 3.14159 + +# Variable with default +Radius: $(r~5) + +# Formula using the constant +Area: @{PI * $r ** 2} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Constant should be in result with its expression + assert "PI" in result + assert result["PI"] == "3.14159" # Expression, not evaluated value + + # Variable should be in result + assert "r" in result + assert result["r"] == 5 + + # Formula should be evaluated using the constant (formulas still evaluate) + # Formula key is now just the expression (no @{...}) + assert "PI * $r ** 2" in result + assert abs(result["PI * $r ** 2"] - 78.53975) < 0.001 + + +def test_fzi_with_function_definition(): + """Test that fzi parses and evaluates function definitions""" + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + content = """ +# Static object: function +#@: def double(x): +#@: return x * 2 + +# Variable with default +Value: $(val~10) + +# Formula using the function +Result: @{double($val)} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Function should be in result (as callable) + assert "double" in result + assert "def double(x):" in result["double"] + + # Variable should be in result + assert "val" in result + assert result["val"] == 10 + + # Formula should be evaluated using the function + # Formula key is now just the expression + result_key = "double($val)" + assert result[result_key] == 20 + + +def test_fzi_with_multiple_constants(): + """Test multiple constant definitions""" + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + content = """ +# Multiple constants +#@: R = 8.314 +#@: Navo = 6.022e23 +#@: c = 299792458 + +# Variables +Temperature: $(T~273.15) +Moles: $(n~1) + +# Formula using constants +Energy: @{$n * R * $T} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # All constants should be present + assert "R" in result + assert result["R"] == "8.314" + assert "Navo" in result + assert "6.022e23" in result["Navo"] + assert "c" in result + assert result["c"] == "299792458" + + +def test_fzi_with_multiline_function(): + """Test multiline function definition""" + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + content = """ +# Multiline function with if statement +#@: def classify(x): +#@: if x > 0: +#@: return "positive" +#@: elif x < 0: +#@: return "negative" +#@: else: +#@: return "zero" + +# Variable +Value: $(val~5) + +# Formula using function +Classification: @{classify($val)} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Function should exist + assert "classify" in result + assert "def classify(x):" in result["classify"] + + # Formula result + class_key = "classify($val)" + assert result[class_key] == "positive" + + +def test_fzi_static_objects_without_variables(): + """Test that static objects work even without variables""" + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + content = """ +# Constants only +#@: G = 6.674e-11 +#@: h = 6.626e-34 + +# Formula using only constants +Planck_per_G: @{h / G} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Constants should be present + assert "G" in result + assert "h" in result + + # Formula should evaluate + # Any formula key will do + formula_keys = [k for k in result.keys() if k not in ["G", "h"]] + formula_key = formula_keys[0] if formula_keys else None + if formula_key: + assert result[formula_key] is not None + + +def test_fzi_ignores_unit_tests(): + """Test that unit test lines (#@?) are ignored""" + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + content = """ +# Constant +#@: PI = 3.14159 + +# Unit test (should be ignored) +#@? PI > 3 + +# Variable +Radius: $(r~1) +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Constant should be present + assert "PI" in result + # Unit test should NOT create any weird keys + assert not any("?" in k for k in result.keys()) + + +def test_fzi_with_import_statement(): + """Test static objects with import statements""" + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + content = """ +# Import and use math module +#@: import math +#@: sqrt2 = math.sqrt(2) + +# Variable +Value: $(x~4) + +# Formula using math +SquareRoot: @{math.sqrt($x)} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Constant should be present (as expression) + assert "sqrt2" in result + assert "math.sqrt(2)" in result["sqrt2"] + + # Formula should evaluate (using the imported math module) + sqrt_key = "math.sqrt($x)" + assert abs(result[sqrt_key] - 2.0) < 0.001 + + +def test_fzi_static_objects_with_formula_no_variables(): + """Test formulas that use only static objects, no variables""" + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + content = """ +# Constants +#@: a = 10 +#@: b = 20 + +# Formula using only constants +Sum: @{a + b} +Product: @{a * b} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Constants should be present + assert result["a"] == "10" + assert result["b"] == "20" + + # Formulas should evaluate + sum_key = "a + b" + assert result[sum_key] == 30 + + prod_key = "a * b" + assert result[prod_key] == 200 + + +def test_fzi_java_funz_example_from_docs(): + """Test example from ParameterizingInputFiles.md""" + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + content = """ +# First variable: $(var1~1.2) +# Second variable: $(var2~0.2) + +# Declare constants +#@: L_x = 0.9 + +# Declare functions +#@: def density(l, m): +#@: if l < 0: +#@: return m / (L_x + l**2) +#@: else: +#@: return m / l**3 + +# Formula using function +Density1: @{density(1, $var2)} +Density2: @{density(-1, $var2)} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Constants should be present (as expressions) + assert "L_x" in result + assert result["L_x"] == "0.9" + + # Function should be present (as expression) + assert "density" in result + assert "def density(l, m):" in result["density"] + + # Variables should have defaults + assert result["var1"] == 1.2 + assert result["var2"] == 0.2 + + # Formulas should evaluate + density1_key = "density(1, $var2)" + assert result[density1_key] == 0.2 # 0.2 / 1^3 = 0.2 + + density2_key = "density(-1, $var2)" + expected = 0.2 / (0.9 + 1) # 0.2 / (L_x + (-1)^2) = 0.2 / 1.9 + assert abs(result[density2_key] - expected) < 0.001 + + +def test_fzi_static_objects_require_colon(): + """Test that only lines with #@: (colon) are treated as static objects, not #@ alone""" + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", + } + + content = """ +# This is a regular comment +#@ This is also just a comment (no colon after @) +#@: pi = 3.14159 + +# Variable +Radius: $(r~2) + +# Formula +Area: @{pi * $r ** 2} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Only pi should be in result (from #@: line) + assert "pi" in result + assert result["pi"] == "3.14159" + + # Words from #@ comment should NOT be in result + assert "This" not in result + assert "is" not in result + assert "also" not in result + assert "just" not in result + + # Variable should be present + assert "r" in result + assert result["r"] == 2 + + # Formula should evaluate + area_key = "pi * $r ** 2" + expected_area = 3.14159 * 4 # pi * r^2 = 3.14159 * 4 + assert abs(result[area_key] - expected_area) < 0.001 + + +def test_fzi_static_objects_with_R_interpreter(): + """Test that static objects work correctly with R interpreter""" + try: + import rpy2 + except ImportError: + import pytest + pytest.skip("rpy2 not installed") + + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "*", + "interpreter": "R", + } + + content = """ +*@: Pu_in_PuO2 = 0.88211 +*@: H_fiss_cm <- function(Pu_mass_kg, PuO2_dens_gcm3) { +*@: min(296, 1000*Pu_mass_kg/(pi/4*11.5^2*PuO2_dens_gcm3*Pu_in_PuO2)) +*@: } + +Mass: $(mass~34) +Density: $(dens~1) + +Height: @{H_fiss_cm($mass, $dens)} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Static constant should be present (as expression) + assert "Pu_in_PuO2" in result + assert result["Pu_in_PuO2"] == "0.88211" + + # Static function should be present (as expression) + assert "H_fiss_cm" in result + assert "function(Pu_mass_kg, PuO2_dens_gcm3)" in result["H_fiss_cm"] + + # Variables should have defaults + assert result["mass"] == 34 + assert result["dens"] == 1 + + # Formula should evaluate successfully (not None) + height_key = "H_fiss_cm($mass, $dens)" + assert result[height_key] is not None + # The function calculates height based on mass and density + assert result[height_key] > 0 + + +def test_fzi_comment_char_alias(): + """Test that comment_char is accepted as alias for commentline""" + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "comment_char": "*", # Using comment_char instead of commentline + "interpreter": "python", + } + + content = """ +*@: MY_CONSTANT = 42 + +Value: $(v~10) +Result: @{MY_CONSTANT + $v} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Constant should be present + assert "MY_CONSTANT" in result + assert result["MY_CONSTANT"] == "42" + + # Formula should evaluate + result_key = [k for k in result.keys() if "@{" in k][0] + assert result[result_key] == 52 # 42 + 10 + + +def test_fzi_all_comment_key_aliases(): + """Test that all comment key aliases work: commentline, comment_line, comment_char, commentchar, comment""" + + # Test each alias + aliases = [ + ("commentline", "*"), + ("comment_line", "%"), + ("comment_char", "!"), + ("commentchar", "//"), + ("comment", ">>"), + ] + + for key, char in aliases: + model = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + key: char, + "interpreter": "python", + } + + content = f""" +{char}@: CONST = 99 + +Variable: $(x~1) +Sum: @{{CONST + $x}} +""" + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + with open(input_file, 'w') as f: + f.write(content) + + result = fzi(str(input_file), model=model) + + # Check static object was parsed + assert "CONST" in result, f"Failed for alias '{key}': CONST not found" + assert result["CONST"] == "99", f"Failed for alias '{key}': CONST != 99" + + # Check formula evaluated + sum_key = [k for k in result.keys() if "@{" in k][0] + assert result[sum_key] == 100, f"Failed for alias '{key}': formula didn't evaluate" + + +if __name__ == "__main__": + import pytest + pytest.main([__file__, "-v"]) From e875073357dc3680129b2c808ee42c8f35d20378 Mon Sep 17 00:00:00 2001 From: yannrichet Date: Sat, 24 Jan 2026 22:59:02 +0100 Subject: [PATCH 02/11] better fzi output --- fz/core.py | 23 +++++++++++++++++++++-- tests/test_fzi_formulas.py | 26 +++++++++++++------------- tests/test_model_key_aliases.py | 12 ++++++------ tests/test_static_objects.py | 24 ++++++++++++------------ 4 files changed, 52 insertions(+), 33 deletions(-) diff --git a/fz/core.py b/fz/core.py index d5a09f4..ae42345 100644 --- a/fz/core.py +++ b/fz/core.py @@ -1091,8 +1091,27 @@ def fzi(input_path: str, model: Union[str, Dict]) -> Dict[str, Any]: if value is None and default_format: value = default_format - # Use just the formula expression as key (no prefix/delimiters) - result[formula_expr] = value + # Clean formula expression: remove variable prefix and delimiters + # E.g., "$r * 2" -> "r * 2", "$(x)" -> "x", "$x + $(y)" -> "x + y" + clean_expr = formula_expr + + # Remove variable references with delimiters: $(var) or V(var) + if len(var_delim) == 2: + left_d = re.escape(var_delim[0]) + right_d = re.escape(var_delim[1]) + var_prefix_esc = re.escape(varprefix) + # Pattern: $(...) or V(...) + pattern = rf'{var_prefix_esc}{left_d}([a-zA-Z_][a-zA-Z0-9_]*){right_d}' + clean_expr = re.sub(pattern, r'\1', clean_expr) + + # Remove simple variable prefix: $var or Vvar + var_prefix_esc = re.escape(varprefix) + # Pattern: $var (followed by non-alphanumeric or end of string) + pattern = rf'{var_prefix_esc}([a-zA-Z_][a-zA-Z0-9_]*)\b' + clean_expr = re.sub(pattern, r'\1', clean_expr) + + # Use cleaned formula expression as key + result[clean_expr] = value return result finally: diff --git a/tests/test_fzi_formulas.py b/tests/test_fzi_formulas.py index 3425881..4d4b3ca 100644 --- a/tests/test_fzi_formulas.py +++ b/tests/test_fzi_formulas.py @@ -40,9 +40,9 @@ def test_fzi_returns_variables_and_formulas(): assert "x" in result assert "y" in result - # Formulas should be found (with @{} syntax) - assert any("@{" in k and "$x * $y" in k for k in result.keys()) - assert any("@{" in k and "2 * ($x + $y)" in k for k in result.keys()) + # Formulas should be found (without @{} syntax now) + assert "x * y" in result + assert "2 * (x + y)" in result def test_fzi_extracts_variable_defaults(): @@ -106,15 +106,15 @@ def test_fzi_evaluates_formulas_with_defaults(): # Formulas should be evaluated # Find the area formula - area_formula = [k for k in result.keys() if "@{" in k and "$x * $y" in k][0] + area_formula = "x * y" assert result[area_formula] == 50 # Find the sum formula - sum_formula = [k for k in result.keys() if "@{" in k and "$x + $y" in k and "2 *" not in k][0] + sum_formula = "x + y" assert result[sum_formula] == 15 # Find the perimeter formula - perim_formula = [k for k in result.keys() if "@{" in k and "2 * ($x + $y)" in k][0] + perim_formula = "2 * (x + y)" assert result[perim_formula] == 30 @@ -150,7 +150,7 @@ def test_fzi_formulas_without_defaults(): assert result["y"] is None # Formulas should be None (cannot evaluate without values) - area_formula = [k for k in result.keys() if "@{" in k and "$x * $y" in k][0] + area_formula = "x * y" assert result[area_formula] is None @@ -181,7 +181,7 @@ def test_fzi_formulas_with_format_specifier(): result = fzi(str(input_file), model=model) # Formula should be evaluated with formatting - formula_key = [k for k in result.keys() if "@{" in k and "$x / 3" in k][0] + formula_key = "x / 3" # Should be formatted to 4 decimal places: 1/3 = 0.3333 assert abs(result[formula_key] - 0.3333) < 0.0001 @@ -215,11 +215,11 @@ def test_fzi_mixed_defaults_and_no_defaults(): result = fzi(str(input_file), model=model) # Formula using only x (which has default) should evaluate - double_formula = [k for k in result.keys() if "@{" in k and "$x * 2" in k][0] + double_formula = "x * 2" assert result[double_formula] == 20 # Formula using both x and y (y has no default) should be None - area_formula = [k for k in result.keys() if "@{" in k and "$x * $y" in k][0] + area_formula = "x * y" assert result[area_formula] is None @@ -251,12 +251,12 @@ def test_fzi_with_math_functions(): result = fzi(str(input_file), model=model) - # Area formula - area_formula = [k for k in result.keys() if "@{" in k and "3.14159" in k][0] + # Area formula (no @{} in keys anymore) + area_formula = "3.14159 * r ** 2" assert abs(result[area_formula] - 78.53975) < 0.001 # Sqrt formula - sqrt_formula = [k for k in result.keys() if "@{" in k and "sqrt" in k][0] + sqrt_formula = "sqrt(r)" assert abs(result[sqrt_formula] - 2.236) < 0.001 diff --git a/tests/test_model_key_aliases.py b/tests/test_model_key_aliases.py index e7396c3..e042531 100644 --- a/tests/test_model_key_aliases.py +++ b/tests/test_model_key_aliases.py @@ -50,7 +50,7 @@ def test_var_prefix_aliases(): # Check formula evaluated # Formula key is CONST + {prefix}x - result_key = f"CONST + {prefix}x" + result_key = f"CONST + x" assert result[result_key] == 60, f"Failed for alias '{key}': formula didn't evaluate to 60" @@ -98,8 +98,8 @@ def test_formula_prefix_aliases(): assert "r" in result, f"Failed for alias '{key}': r not found" assert result["r"] == 5, f"Failed for alias '{key}': r != 5" - # Check formula evaluated - formula_key = [k for k in result.keys() if prefix + "{" in k][0] + # Check formula evaluated (formula expression is the same regardless of prefix) + formula_key = "PI * r ** 2" expected = 3.14159 * 25 # PI * r^2 assert abs(result[formula_key] - expected) < 0.001, f"Failed for alias '{key}': formula value wrong" @@ -144,7 +144,7 @@ def test_comment_char_aliases(): assert result["VALUE"] == "100", f"Failed for alias '{key}': VALUE != 100" # Check formula evaluated - sum_key = "VALUE + $n" + sum_key = "VALUE + n" assert result[sum_key] == 105, f"Failed for alias '{key}': formula didn't evaluate" @@ -180,8 +180,8 @@ def test_combined_aliases(): assert "val" in result assert result["val"] == 7 - # Check formula - formula_key = [k for k in result.keys() if "F{" in k][0] + # Check formula (formula expression, not with prefix) + formula_key = "val * MULTIPLIER" assert result[formula_key] == 21 # 7 * 3 diff --git a/tests/test_static_objects.py b/tests/test_static_objects.py index 3f51c0c..c41bed7 100644 --- a/tests/test_static_objects.py +++ b/tests/test_static_objects.py @@ -45,8 +45,8 @@ def test_fzi_with_constant_definition(): # Formula should be evaluated using the constant (formulas still evaluate) # Formula key is now just the expression (no @{...}) - assert "PI * $r ** 2" in result - assert abs(result["PI * $r ** 2"] - 78.53975) < 0.001 + assert "PI * r ** 2" in result + assert abs(result["PI * r ** 2"] - 78.53975) < 0.001 def test_fzi_with_function_definition(): @@ -89,7 +89,7 @@ def test_fzi_with_function_definition(): # Formula should be evaluated using the function # Formula key is now just the expression - result_key = "double($val)" + result_key = "double(val)" assert result[result_key] == 20 @@ -174,7 +174,7 @@ def test_fzi_with_multiline_function(): assert "def classify(x):" in result["classify"] # Formula result - class_key = "classify($val)" + class_key = "classify(val)" assert result[class_key] == "positive" @@ -214,7 +214,7 @@ def test_fzi_static_objects_without_variables(): formula_keys = [k for k in result.keys() if k not in ["G", "h"]] formula_key = formula_keys[0] if formula_keys else None if formula_key: - assert result[formula_key] is not None + assert result[formula_key] is not None def test_fzi_ignores_unit_tests(): @@ -287,7 +287,7 @@ def test_fzi_with_import_statement(): assert "math.sqrt(2)" in result["sqrt2"] # Formula should evaluate (using the imported math module) - sqrt_key = "math.sqrt($x)" + sqrt_key = "math.sqrt(x)" assert abs(result[sqrt_key] - 2.0) < 0.001 @@ -381,10 +381,10 @@ def test_fzi_java_funz_example_from_docs(): assert result["var2"] == 0.2 # Formulas should evaluate - density1_key = "density(1, $var2)" + density1_key = "density(1, var2)" assert result[density1_key] == 0.2 # 0.2 / 1^3 = 0.2 - density2_key = "density(-1, $var2)" + density2_key = "density(-1, var2)" expected = 0.2 / (0.9 + 1) # 0.2 / (L_x + (-1)^2) = 0.2 / 1.9 assert abs(result[density2_key] - expected) < 0.001 @@ -434,7 +434,7 @@ def test_fzi_static_objects_require_colon(): assert result["r"] == 2 # Formula should evaluate - area_key = "pi * $r ** 2" + area_key = "pi * r ** 2" expected_area = 3.14159 * 4 # pi * r^2 = 3.14159 * 4 assert abs(result[area_key] - expected_area) < 0.001 @@ -488,7 +488,7 @@ def test_fzi_static_objects_with_R_interpreter(): assert result["dens"] == 1 # Formula should evaluate successfully (not None) - height_key = "H_fiss_cm($mass, $dens)" + height_key = "H_fiss_cm(mass, dens)" assert result[height_key] is not None # The function calculates height based on mass and density assert result[height_key] > 0 @@ -524,7 +524,7 @@ def test_fzi_comment_char_alias(): assert result["MY_CONSTANT"] == "42" # Formula should evaluate - result_key = [k for k in result.keys() if "@{" in k][0] + result_key = "MY_CONSTANT + v" assert result[result_key] == 52 # 42 + 10 @@ -569,7 +569,7 @@ def test_fzi_all_comment_key_aliases(): assert result["CONST"] == "99", f"Failed for alias '{key}': CONST != 99" # Check formula evaluated - sum_key = [k for k in result.keys() if "@{" in k][0] + sum_key = "CONST + x" assert result[sum_key] == 100, f"Failed for alias '{key}': formula didn't evaluate" From 9ac1a5952181730a107cd9e9619b9498261fe656 Mon Sep 17 00:00:00 2001 From: Yann Richet Date: Sun, 25 Jan 2026 10:37:00 +0100 Subject: [PATCH 03/11] Update fz/interpreter.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- fz/interpreter.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fz/interpreter.py b/fz/interpreter.py index 954000e..05c3e89 100755 --- a/fz/interpreter.py +++ b/fz/interpreter.py @@ -608,6 +608,8 @@ def evaluate_single_formula(formula: str, model: Dict, input_variables: Dict, in else: robjects.globalenv[var] = str(val) except Exception: + # Ignore errors for individual variable assignments so that other + # variables can still be set and the R formula evaluation can proceed. pass # Handle format suffix From 690d8e80c8711b06f368cb46baecb0d886c0dd25 Mon Sep 17 00:00:00 2001 From: Yann Richet Date: Sun, 25 Jan 2026 10:37:42 +0100 Subject: [PATCH 04/11] Update tests/test_java_funz_compatibility.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_java_funz_compatibility.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_java_funz_compatibility.py b/tests/test_java_funz_compatibility.py index abd8b4a..4ccec55 100644 --- a/tests/test_java_funz_compatibility.py +++ b/tests/test_java_funz_compatibility.py @@ -10,7 +10,6 @@ - Unit tests: #@? assertion """ import tempfile -from pathlib import Path from fz.interpreter import ( parse_variables_from_content, replace_variables_in_content, From 90c16b4415c394500076e2b77d6944935dd04992 Mon Sep 17 00:00:00 2001 From: Yann Richet Date: Sun, 25 Jan 2026 10:38:48 +0100 Subject: [PATCH 05/11] Update fz/core.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- fz/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fz/core.py b/fz/core.py index ae42345..3dd3271 100644 --- a/fz/core.py +++ b/fz/core.py @@ -954,7 +954,7 @@ def fzi(input_path: str, model: Union[str, Dict]) -> Dict[str, Any]: Dict with static objects, variable names, and formula expressions as keys, with their values (or None) - Static object keys are the defined names (constants, functions): "PI", "my_function" - Variable keys are just the variable name: "var1", "var2" - - Formula keys include the formula prefix and delimiters: "@{expr}" + - Formula keys are the cleaned formula expressions without prefixes or delimiters: "x * y" Static objects are defined with lines starting with commentline + formula_prefix + ":" (e.g., "#@: PI = 3.14159" or "#@: def my_func(x): return x*2") From 392664b1fd4581c5beae14868990505fffd7b936 Mon Sep 17 00:00:00 2001 From: Yann Richet Date: Sun, 25 Jan 2026 10:38:58 +0100 Subject: [PATCH 06/11] Update tests/test_java_funz_compatibility.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_java_funz_compatibility.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_java_funz_compatibility.py b/tests/test_java_funz_compatibility.py index 4ccec55..7f0a7ed 100644 --- a/tests/test_java_funz_compatibility.py +++ b/tests/test_java_funz_compatibility.py @@ -9,7 +9,6 @@ - Function declarations: #@: func = ... - Unit tests: #@? assertion """ -import tempfile from fz.interpreter import ( parse_variables_from_content, replace_variables_in_content, From 38e47b93a85586476d448d7372b20a574cfba618 Mon Sep 17 00:00:00 2001 From: yannrichet Date: Sun, 25 Jan 2026 10:46:11 +0100 Subject: [PATCH 07/11] try fix none/nan testsoutput (depending on availbale deps) --- tests/test_cache_none_outputs.py | 21 ++++++++++++++++++--- tests/test_complete_parallel_execution.py | 21 ++++++++++++++++++--- tests/test_examples_advanced.py | 19 +++++++++++++++++-- tests/test_retry_mechanism.py | 19 +++++++++++++++++-- 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/tests/test_cache_none_outputs.py b/tests/test_cache_none_outputs.py index e2eec39..8422d5d 100644 --- a/tests/test_cache_none_outputs.py +++ b/tests/test_cache_none_outputs.py @@ -7,6 +7,21 @@ from pathlib import Path from fz import fzr +try: + import pandas as pd + PANDAS_AVAILABLE = True +except ImportError: + PANDAS_AVAILABLE = False + + +def is_none_or_nan(value): + """Check if value is None or NaN (pandas converts None to NaN in DataFrames)""" + if value is None: + return True + if PANDAS_AVAILABLE: + return pd.isna(value) + return False + def test_cache_none_outputs(): """Test that cases with None outputs are re-computed instead of using cache""" @@ -58,7 +73,7 @@ def test_cache_none_outputs(): assert all(result1['status'] == ['done', 'failed', 'done']), f"Unexpected statuses: {result1['status']}" # check no 'result' for x=2 case - assert result1['result'][1] is None, f"Expected None result for x=2 case, got: {result1['result'][1]}" + assert is_none_or_nan(result1['result'][1]), f"Expected None/NaN result for x=2 case, got: {result1['result'][1]}" # check error message for x=2 case assert 'exit code 123' in result1.get('error', [''])[1], f"Expected error message for x=2 case, got: {result1.get('error', [''])[1]}" @@ -75,8 +90,8 @@ def test_cache_none_outputs(): print(f"\nSecond run: "+str(result2.to_dict())) print(f"\nSecond run results: {result2['result']}") - none_count_2 = sum(1 for r in result2['result'] if r is None) - print(f"Cases with None outputs after cache+fix: {none_count_2}") + none_count_2 = sum(1 for r in result2['result'] if is_none_or_nan(r)) + print(f"Cases with None/NaN outputs after cache+fix: {none_count_2}") assert all(result2['status'] == ['done', 'done', 'done']), f"Unexpected statuses in second run: {result2['status']}" assert none_count_2 == 0, f"Expected no None results in second run, got {none_count_2} None results" diff --git a/tests/test_complete_parallel_execution.py b/tests/test_complete_parallel_execution.py index 1fbed7e..665eaa2 100644 --- a/tests/test_complete_parallel_execution.py +++ b/tests/test_complete_parallel_execution.py @@ -13,6 +13,21 @@ import fz +try: + import pandas as pd + PANDAS_AVAILABLE = True +except ImportError: + PANDAS_AVAILABLE = False + + +def is_none_or_nan(value): + """Check if value is None or NaN (pandas converts None to NaN in DataFrames)""" + if value is None: + return True + if PANDAS_AVAILABLE: + return pd.isna(value) + return False + def test_complete_parallel_execution(): """Test that all cases complete successfully with results""" @@ -115,8 +130,8 @@ def test_complete_parallel_execution(): pressure_values = result["pressure"] print(f" Pressure values: {pressure_values}") - successful_cases = len([p for p in pressure_values if p is not None]) - failed_cases = len([p for p in pressure_values if p is None]) + successful_cases = len([p for p in pressure_values if not is_none_or_nan(p)]) + failed_cases = len([p for p in pressure_values if is_none_or_nan(p)]) print(f" Successful cases: {successful_cases}/{len(variables['T_celsius'])}") print(f" Failed cases: {failed_cases}") @@ -125,7 +140,7 @@ def test_complete_parallel_execution(): if "T_celsius" in result: for i, temp in enumerate(result["T_celsius"]): pressure = pressure_values[i] if i < len(pressure_values) else None - status = "✅ SUCCESS" if pressure is not None else "❌ FAILED" + status = "✅ SUCCESS" if not is_none_or_nan(pressure) else "❌ FAILED" print(f" Case {i}: T_celsius={temp} → pressure={pressure} {status}") # Verify timing diff --git a/tests/test_examples_advanced.py b/tests/test_examples_advanced.py index aaf7c77..e564f98 100644 --- a/tests/test_examples_advanced.py +++ b/tests/test_examples_advanced.py @@ -12,6 +12,21 @@ import fz +try: + import pandas as pd + PANDAS_AVAILABLE = True +except ImportError: + PANDAS_AVAILABLE = False + + +def is_none_or_nan(value): + """Check if value is None or NaN (pandas converts None to NaN in DataFrames)""" + if value is None: + return True + if PANDAS_AVAILABLE: + return pd.isna(value) + return False + @pytest.fixture def advanced_setup(tmp_path): @@ -224,8 +239,8 @@ def test_failure_always_fails(advanced_setup): assert len(result) == 12 # All should fail assert all(result["status"] == "failed") - # All pressure values should be None - assert all(p is None for p in result["pressure"]) + # All pressure values should be None/NaN + assert all(is_none_or_nan(p) for p in result["pressure"]) def test_failure_cache_with_fallback(advanced_setup): diff --git a/tests/test_retry_mechanism.py b/tests/test_retry_mechanism.py index 9c53e01..786858a 100644 --- a/tests/test_retry_mechanism.py +++ b/tests/test_retry_mechanism.py @@ -10,6 +10,21 @@ from fz import fzr import time +try: + import pandas as pd + PANDAS_AVAILABLE = True +except ImportError: + PANDAS_AVAILABLE = False + + +def is_none_or_nan(value): + """Check if value is None or NaN (pandas converts None to NaN in DataFrames)""" + if value is None: + return True + if PANDAS_AVAILABLE: + return pd.isna(value) + return False + @pytest.fixture(autouse=True) def test_files(): """Create test files for retry mechanism""" @@ -122,8 +137,8 @@ def test_all_fail(): # Assert all calculators failed as expected assert result['status'][0] in ['failed', 'error'], \ f"Expected status 'failed' or 'error', got: {result['status'][0]}" - assert result['result'][0] is None, \ - f"Expected None result when all calculators fail, got: {result['result'][0]}" + assert is_none_or_nan(result['result'][0]), \ + f"Expected None/NaN result when all calculators fail, got: {result['result'][0]}" assert 'error' in result and result['error'][0] is not None, \ "Expected error message when all calculators fail" From 56a068f696e96c3faa290c8de8e3d02c6f57e641 Mon Sep 17 00:00:00 2001 From: yannrichet Date: Sun, 25 Jan 2026 11:08:18 +0100 Subject: [PATCH 08/11] factorize model dict keys equiv. --- fz/core.py | 100 ++--------------------------------------------------- 1 file changed, 3 insertions(+), 97 deletions(-) diff --git a/fz/core.py b/fz/core.py index 3dd3271..ce3103b 100644 --- a/fz/core.py +++ b/fz/core.py @@ -101,6 +101,9 @@ def utf8_open( from .interpreter import ( parse_variables_from_path, cast_output, + _get_comment_char, + _get_var_prefix, + _get_formula_prefix, ) from .runners import resolve_calculators, run_calculation @@ -262,103 +265,6 @@ def _parse_argument(arg, alias_type=None): return arg -def _get_comment_char(model: Dict) -> str: - """ - Get comment character from model with support for multiple aliases - - Supported keys (in order of precedence): - - commentline - - comment_line - - comment_char - - commentchar - - comment - - Args: - model: Model definition dict - - Returns: - Comment character (default "#") - """ - return model.get( - "commentline", - model.get( - "comment_line", - model.get( - "comment_char", - model.get( - "commentchar", - model.get("comment", "#") - ) - ) - ) - ) - - -def _get_var_prefix(model: Dict) -> str: - """ - Get variable prefix from model with support for multiple aliases - - Supported keys (in order of precedence): - - var_prefix - - varprefix - - var_char - - varchar - - Args: - model: Model definition dict - - Returns: - Variable prefix (default "$") - """ - return model.get( - "var_prefix", - model.get( - "varprefix", - model.get( - "var_char", - model.get("varchar", "$") - ) - ) - ) - - -def _get_formula_prefix(model: Dict) -> str: - """ - Get formula prefix from model with support for multiple aliases - - Supported keys (in order of precedence): - - formula_prefix - - formulaprefix - - form_prefix - - formprefix - - formula_char - - form_char - - Args: - model: Model definition dict - - Returns: - Formula prefix (default "@") - """ - return model.get( - "formula_prefix", - model.get( - "formulaprefix", - model.get( - "form_prefix", - model.get( - "formprefix", - model.get( - "formula_char", - model.get("form_char", "@") - ) - ) - ) - ) - ) - - - # Import calculator-related functions from helpers from .helpers import ( _calculator_supports_model, From 3de01bea87d718753cb3471a6e7acfdc82c3724d Mon Sep 17 00:00:00 2001 From: yannrichet Date: Sun, 25 Jan 2026 11:20:46 +0100 Subject: [PATCH 09/11] fix slurm ? --- .github/workflows/slurm-localhost.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/slurm-localhost.yml b/.github/workflows/slurm-localhost.yml index 7c1bf6c..57a4326 100644 --- a/.github/workflows/slurm-localhost.yml +++ b/.github/workflows/slurm-localhost.yml @@ -258,6 +258,7 @@ jobs: # Define model model = { + "delim": "{}", # Use braces for ${x} syntax "output": { "result": "grep 'result = ' output.txt | awk '{print \$3}'" } @@ -323,6 +324,7 @@ jobs: # Define model model = { + "delim": "{}", # Use braces for ${x} syntax "output": { "result": "grep 'result = ' output.txt | awk '{print \$3}'" } @@ -383,6 +385,7 @@ jobs: # Define model model = { + "delim": "{}", # Use braces for ${x} syntax "output": { "result": "grep 'result = ' output.txt | awk '{print \$3}'" } From 34c0324ea15af665e53c449abad8e241414188a7 Mon Sep 17 00:00:00 2001 From: yannrichet Date: Sun, 25 Jan 2026 17:12:01 +0100 Subject: [PATCH 10/11] better support var assign --- fz/interpreter.py | 33 +++++--- tests/test_assignment_detection.py | 118 +++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 tests/test_assignment_detection.py diff --git a/fz/interpreter.py b/fz/interpreter.py index 05c3e89..927e9ae 100755 --- a/fz/interpreter.py +++ b/fz/interpreter.py @@ -367,19 +367,28 @@ def parse_static_objects_with_expressions(content: str, commentline: str = "#", if not line or line.startswith('#'): i += 1 continue - + # Check for Python assignment: name = value - if '=' in line and not line.startswith('def ') and '<-' not in line: - parts = line.split('=', 1) - if len(parts) == 2: - name = parts[0].strip() - # Remove any type hints - if ':' in name: - name = name.split(':')[0].strip() - value = parts[1].strip() - expressions[name] = value - i += 1 - continue + # Use regex to match only actual assignments, not comparison operators (==, !=, <=, >=) + # Pattern: start with optional whitespace, valid identifier, optional whitespace, + # single = not followed by another = or preceded by !, <, > + assignment_pattern = r'^\s*([a-zA-Z_][a-zA-Z0-9_]*(?:\s*:\s*[a-zA-Z_][a-zA-Z0-9_.\[\]]*)?)\s*=\s*(?!=)' + assignment_match = re.match(assignment_pattern, line) + + if assignment_match and not line.startswith('def ') and '<-' not in line: + # Ensure it's not !=, <=, >= by checking the character before = + # The regex already handles =, but we need to check for !=, <=, >= + if '!=' not in line[:assignment_match.end()] and '<=' not in line[:assignment_match.end()] and '>=' not in line[:assignment_match.end()]: + parts = line.split('=', 1) + if len(parts) == 2: + name = parts[0].strip() + # Remove any type hints + if ':' in name: + name = name.split(':')[0].strip() + value = parts[1].strip() + expressions[name] = value + i += 1 + continue # Check for R assignment: name <- value or name <- function(...) if '<-' in line: diff --git a/tests/test_assignment_detection.py b/tests/test_assignment_detection.py new file mode 100644 index 0000000..7127054 --- /dev/null +++ b/tests/test_assignment_detection.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +Test that assignment detection correctly distinguishes between +assignments and comparison operators. +""" +import pytest +from fz.interpreter import parse_static_objects_with_expressions + + +def test_comparison_operators_not_detected_as_assignments(): + """Test that comparison operators (==, !=, <=, >=) are not detected as assignments""" + code = """#@: +#@: if x == 5: +#@: pass +#@: if y <= 10: +#@: pass +#@: if z >= 0: +#@: pass +#@: if a != b: +#@: pass +""" + expressions = parse_static_objects_with_expressions(code) + + # Should not detect any assignments + assert len(expressions) == 0, f"Comparison operators incorrectly detected as assignments: {expressions}" + + +def test_actual_assignments_detected(): + """Test that actual assignments are correctly detected""" + code = """#@: +#@: x = 5 +#@: y = 10 +#@: z = x + y +""" + expressions = parse_static_objects_with_expressions(code) + + # Should detect all three assignments + assert len(expressions) == 3 + assert 'x' in expressions + assert 'y' in expressions + assert 'z' in expressions + assert expressions['x'] == '5' + assert expressions['y'] == '10' + assert expressions['z'] == 'x + y' + + +def test_mixed_assignments_and_comparisons(): + """Test mixed code with both assignments and comparisons""" + code = """#@: +#@: value = 42 +#@: if value == 42: +#@: result = "correct" +#@: elif value != 42: +#@: result = "incorrect" +""" + expressions = parse_static_objects_with_expressions(code) + + # Should only detect 'value' and 'result', not the comparisons + assert len(expressions) == 2 + assert 'value' in expressions + assert 'result' in expressions + assert expressions['value'] == '42' + # Result gets the last assignment + assert expressions['result'] == '"incorrect"' + + # Should NOT detect comparison operators as assignments + assert 'if value' not in expressions + assert 'elif value' not in expressions + + +def test_type_hints_in_assignments(): + """Test that assignments with type hints are correctly detected""" + code = """#@: +#@: x: int = 5 +#@: name: str = "test" +""" + expressions = parse_static_objects_with_expressions(code) + + # Should detect assignments with type hints + assert len(expressions) == 2 + assert 'x' in expressions + assert 'name' in expressions + assert expressions['x'] == '5' + assert expressions['name'] == '"test"' + + +def test_compound_comparison_operators(): + """Test that compound comparison operators are not detected as assignments""" + code = """#@: +#@: while x <= 100: +#@: x = x * 2 +#@: if result >= threshold: +#@: status = "high" +""" + expressions = parse_static_objects_with_expressions(code) + + # Should only detect actual assignments (x and status), not comparisons + assert 'x' in expressions + assert 'status' in expressions + # Should NOT detect comparison operators + assert 'while x <' not in expressions + assert 'if result >' not in expressions + + +def test_equality_in_assert_statements(): + """Test that == in assert statements is not detected as assignment""" + code = """#@: +#@: assert x == expected +#@: assert result != None +""" + expressions = parse_static_objects_with_expressions(code) + + # Should not detect any assignments + assert len(expressions) == 0, f"Assert comparisons incorrectly detected as assignments: {expressions}" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From d67680348bdd3857e1d603410b6e5fc8e28ffe96 Mon Sep 17 00:00:00 2001 From: yannrichet Date: Sun, 25 Jan 2026 17:44:10 +0100 Subject: [PATCH 11/11] better handle num format --- fz/core.py | 16 ++-- tests/test_numeric_formats.py | 144 ++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 tests/test_numeric_formats.py diff --git a/fz/core.py b/fz/core.py index ce3103b..75e7422 100644 --- a/fz/core.py +++ b/fz/core.py @@ -946,20 +946,22 @@ def fzi(input_path: str, model: Union[str, Dict]) -> Dict[str, Any]: default_value = match.group(2).strip() # Try to parse the default value + # Use ast.literal_eval to handle various Python literal formats: + # - Hexadecimal (0x1F), octal (0o77), binary (0b1010) + # - Numbers with underscores (1_000_000) + # - Scientific notation (1e6) + # - Regular integers and floats try: - # Try numeric conversion - if '.' in default_value or 'e' in default_value.lower(): - variable_defaults[var_name] = float(default_value) - else: - variable_defaults[var_name] = int(default_value) - except ValueError: - # Keep as string, removing quotes if present + variable_defaults[var_name] = ast.literal_eval(default_value) + except (ValueError, SyntaxError): + # If literal_eval fails, try to interpret as string if default_value.startswith('"') and default_value.endswith('"'): variable_defaults[var_name] = default_value[1:-1] elif default_value.startswith('[') or default_value.startswith('{'): # Keep bounds/values as string for now variable_defaults[var_name] = None else: + # Keep as raw string variable_defaults[var_name] = default_value # Build result dict starting with static objects diff --git a/tests/test_numeric_formats.py b/tests/test_numeric_formats.py new file mode 100644 index 0000000..1d49b73 --- /dev/null +++ b/tests/test_numeric_formats.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +""" +Test that various Python numeric formats are correctly parsed +""" +import pytest +import tempfile +from pathlib import Path +from fz import fzi +from fz.interpreter import cast_output + + +class TestCastOutput: + """Test cast_output function handles various numeric formats""" + + def test_hexadecimal_numbers(self): + """Test that hexadecimal numbers are correctly parsed""" + assert cast_output("0x1F") == 31 + assert cast_output("0xFF") == 255 + assert cast_output("0x0") == 0 + + def test_octal_numbers(self): + """Test that octal numbers are correctly parsed""" + assert cast_output("0o77") == 63 + assert cast_output("0o10") == 8 + assert cast_output("0o0") == 0 + + def test_binary_numbers(self): + """Test that binary numbers are correctly parsed""" + assert cast_output("0b1010") == 10 + assert cast_output("0b1111") == 15 + assert cast_output("0b0") == 0 + + def test_numbers_with_underscores(self): + """Test that numbers with underscores are correctly parsed""" + assert cast_output("1_000_000") == 1000000 + assert cast_output("1_234_567") == 1234567 + assert cast_output("3.14_15_93") == 3.141593 + + def test_scientific_notation(self): + """Test that scientific notation works""" + assert cast_output("1e6") == 1000000.0 + assert cast_output("1.5e-3") == 0.0015 + assert cast_output("2.5E+2") == 250.0 + + def test_regular_integers(self): + """Test that regular integers still work""" + assert cast_output("42") == 42 + assert cast_output("0") == 0 + assert cast_output("-10") == -10 + + def test_regular_floats(self): + """Test that regular floats still work""" + assert cast_output("3.14") == 3.14 + assert cast_output("-2.5") == -2.5 + assert cast_output("0.0") == 0.0 + + +class TestFziDefaultValues: + """Test fzi function handles various numeric formats in default values""" + + def test_hexadecimal_default(self): + """Test hexadecimal default values in variable declarations""" + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + input_file.write_text("x = ${x~0x1F}\n") + + model = {"delim": "{}"} + result = fzi(str(input_file), model) + + assert 'x' in result + # Should parse 0x1F as integer 31 + assert result['x'] == 31, \ + f"Expected 31, got {result['x']} (type: {type(result['x'])})" + assert isinstance(result['x'], int), \ + f"Expected int type, got {type(result['x'])}" + + def test_octal_default(self): + """Test octal default values""" + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + input_file.write_text("perms = ${perms~0o755}\n") + + model = {"delim": "{}"} + result = fzi(str(input_file), model) + + assert 'perms' in result + # Should parse 0o755 as integer 493 + assert result['perms'] == 493, \ + f"Expected 493, got {result['perms']} (type: {type(result['perms'])})" + assert isinstance(result['perms'], int), \ + f"Expected int type, got {type(result['perms'])}" + + def test_binary_default(self): + """Test binary default values""" + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + input_file.write_text("flags = ${flags~0b1010}\n") + + model = {"delim": "{}"} + result = fzi(str(input_file), model) + + assert 'flags' in result + # Should parse 0b1010 as integer 10 + assert result['flags'] == 10, \ + f"Expected 10, got {result['flags']} (type: {type(result['flags'])})" + assert isinstance(result['flags'], int), \ + f"Expected int type, got {type(result['flags'])}" + + def test_underscore_default(self): + """Test numbers with underscores in default values""" + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + input_file.write_text("count = ${count~1_000_000}\n") + + model = {"delim": "{}"} + result = fzi(str(input_file), model) + + assert 'count' in result + # Should parse 1_000_000 as integer 1000000 + assert result['count'] == 1000000, \ + f"Expected 1000000, got {result['count']} (type: {type(result['count'])})" + assert isinstance(result['count'], int), \ + f"Expected int type, got {type(result['count'])}" + + def test_regular_numeric_defaults_still_work(self): + """Test that regular numeric defaults still work after the fix""" + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "input.txt" + input_file.write_text(""" +x = ${x~42} +y = ${y~3.14} +z = ${z~1e6} +""") + + model = {"delim": "{}"} + result = fzi(str(input_file), model) + + assert result['x'] == 42 + assert result['y'] == 3.14 + assert result['z'] == 1000000.0 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])