From f912ab6d7d14ced5a2db3f8b745b519efd5c0014 Mon Sep 17 00:00:00 2001 From: RICHET-YAN Date: Wed, 9 Sep 2026 11:36:30 +0200 Subject: [PATCH] fzc/fzr: pre-evaluate formulas from inline variable defaults When an input file declared a variable default ($(x~3)) and a formula using it (@{x * 2}), fzi pre-evaluated both but fzc/fzr only substituted the variable and left the formula uncompiled (y = @{x * 2} instead of y = 6). compile_to_result_directories only passed the caller-supplied input_variables to evaluate_formulas and never looked at the inline $(var~default) values. Factor fzi's default extraction into the shared helper interpreter.parse_variable_defaults_from_content and use it during compilation to seed the substitution/formula context. Explicitly passed input_variables still take precedence over inline defaults; list/bounds defaults that don't resolve to a scalar are skipped. Add tests/test_fzc_defaults.py covering the regression for fzc and fzr plus unit tests for the shared helper. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UCiJZSajaqyKYJ9sNoE6fn --- NEWS.md | 8 ++ fz/core.py | 38 +------- fz/helpers.py | 16 +++- fz/interpreter.py | 52 ++++++++++ tests/test_fzc_defaults.py | 192 +++++++++++++++++++++++++++++++++++++ 5 files changed, 268 insertions(+), 38 deletions(-) create mode 100644 tests/test_fzc_defaults.py diff --git a/NEWS.md b/NEWS.md index 07fdd88..fb416ae 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,14 @@ ## Unreleased +### `fzc`/`fzr` now pre-evaluate formulas from inline variable defaults + +- When an input file declares a variable default (`$(x~3)`) and a formula that + uses it (`@{x * 2}`), `fzc`/`fzr` now substitute the default and evaluate the + formula (`x = 3`, `y = 6`), matching what `fzi` already reported. Previously + the variable was substituted but the formula was left uncompiled. Explicitly + passed `input_variables` still override the inline defaults. + ## Version 1.2 (2026-09-04) ### Claude Code plugin: slash commands added diff --git a/fz/core.py b/fz/core.py index 5aa8013..a661a7e 100644 --- a/fz/core.py +++ b/fz/core.py @@ -1027,7 +1027,7 @@ def fzi(input_path: str, model: Union[str, Dict], input_static: Optional[List[st 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 + from .interpreter import parse_formulas_from_content, evaluate_single_formula, parse_static_objects_from_content, evaluate_static_objects, parse_static_objects_with_expressions, parse_variable_defaults_from_content # Parse static objects to get their expressions (for returning in fzi) commentline = _get_comment_char(model) @@ -1037,40 +1037,8 @@ def fzi(input_path: str, model: Union[str, Dict], input_static: Optional[List[st static_lines = parse_static_objects_from_content(content, commentline, formulaprefix) static_objects_evaluated = evaluate_static_objects(static_lines, interpreter) - 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 - # 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: - 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 + # Extract inline default values ($(var~default...)); shared with fzc() + variable_defaults = parse_variable_defaults_from_content(content, varprefix, var_delim) # Build result dict starting with static objects result = {} diff --git a/fz/helpers.py b/fz/helpers.py index 1bc9a6f..3cd95a9 100644 --- a/fz/helpers.py +++ b/fz/helpers.py @@ -1788,7 +1788,7 @@ def compile_to_result_directories(input_path: str, model: Dict, input_variables: computed once by the caller (from fzr()'s/fzc()'s input_static argument) rather than per case """ - from .interpreter import replace_variables_in_content, evaluate_formulas + from .interpreter import replace_variables_in_content, evaluate_formulas, parse_variable_defaults_from_content from .io import create_hash_file from .config import get_interpreter @@ -1869,11 +1869,21 @@ def compile_file(src_path: Path, dst_path: Path): shutil.copy2(src_path, dst_path) return + # Seed inline defaults ($(var~default)) for any variable the caller + # did not provide, so formulas referencing such a variable can still + # be pre-evaluated here (matching fzi()'s pre-evaluation). Explicit + # values in var_combo always win; list/bounds defaults (None) are skipped. + inline_defaults = parse_variable_defaults_from_content(content, varprefix, delim) + effective_combo = { + **{k: v for k, v in inline_defaults.items() if v is not None}, + **var_combo, + } + # Replace variables - substituted = replace_variables_in_content(content, var_combo, varprefix, delim) + substituted = replace_variables_in_content(content, effective_combo, varprefix, delim) # Evaluate formulas - substituted = evaluate_formulas(substituted, model, var_combo, interpreter) + substituted = evaluate_formulas(substituted, model, effective_combo, interpreter) _maybe_warn_static_candidate(src_path, has_variables=(substituted != content)) # Write compiled content diff --git a/fz/interpreter.py b/fz/interpreter.py index 104d281..bab689f 100755 --- a/fz/interpreter.py +++ b/fz/interpreter.py @@ -232,6 +232,58 @@ def parse_variables_from_content(content: str, varprefix: str = "$", delim: str return variables +def parse_variable_defaults_from_content(content: str, varprefix: str = "$", + delim: str = "()") -> Dict[str, Any]: + """ + Extract inline default values declared with the ``$(var~default)`` syntax + (optionally ``$(var~default;comment;bounds)``). + + Returns a dict mapping variable name -> parsed default value: + + - anything ``ast.literal_eval`` accepts is returned as that value: numbers, + quoted strings, hex/oct/bin, scientific notation, underscores, and valid + list/dict literals (e.g. ``$(b~[0,1])`` -> ``[0, 1]``); + - a bare token that is not a valid literal is kept as a raw string + (e.g. ``$(host~localhost)`` -> ``"localhost"``); + - a token that starts with ``[`` or ``{`` but does not parse (truncated + bounds metadata) maps to ``None``. + + Text after a ``;`` separator (comment / bounds metadata) is ignored, and + variables without an inline default are absent from the returned dict. + + Shared by fzi() (pre-evaluation of variables/formulas) and fzc()'s + compilation, so both use the same defaults. + """ + defaults: Dict[str, Any] = {} + + if len(delim) != 2: + return defaults + + left_delim, right_delim = delim[0], delim[1] + esc_varprefix = re.escape(varprefix) + esc_left = re.escape(left_delim) + esc_right = re.escape(right_delim) + + # Match $(var~default...) up to the closing delimiter or a ';' metadata separator + 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: + defaults[var_name] = ast.literal_eval(default_value) + except (ValueError, SyntaxError): + if default_value.startswith('"') and default_value.endswith('"'): + defaults[var_name] = default_value[1:-1] + elif default_value.startswith('[') or default_value.startswith('{'): + # Bounds/range literal, not a usable scalar default + defaults[var_name] = None + else: + defaults[var_name] = default_value + + return defaults + + def parse_variables_from_file(filepath: Path, varprefix: str = "$", delim: str = "()") -> Set[str]: """ Parse variables from a single file diff --git a/tests/test_fzc_defaults.py b/tests/test_fzc_defaults.py new file mode 100644 index 0000000..083af97 --- /dev/null +++ b/tests/test_fzc_defaults.py @@ -0,0 +1,192 @@ +""" +Regression tests: pre-evaluation of inline variable defaults in formulas. + +Bug (fixed) +----------- +Given an input file that declares a variable default AND a formula that uses it:: + + x = $(x~3) + y = @{x * 2} + +``fzi`` already pre-evaluated both (``{'x': 3, 'x * 2': 6}``), but ``fzc`` / +``fzr`` only substituted the variable and left the formula uncompiled:: + + x = 3 + y = @{x * 2} # <-- should have been "y = 6" + +Cause: ``compile_to_result_directories`` passed only the caller-supplied +``input_variables`` to ``evaluate_formulas`` and never looked at the +``$(var~default)`` defaults embedded in the file. + +Fix: the default extraction used by ``fzi`` was factored into +``fz.interpreter.parse_variable_defaults_from_content`` and is now also used +during compilation to seed the substitution/formula context. Explicitly passed +``input_variables`` still take precedence over inline defaults. +""" +import os + +import pytest + +from fz import fzc, fzi, fzr +from fz.interpreter import parse_variable_defaults_from_content + +MODEL = { + "var_prefix": "$", + "var_delim": "()", + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + "interpreter": "python", +} + +# input file used by most tests: one default, one formula that depends on it +CONTENT = "x = $(x~3)\ny = @{x * 2}\n" + + +def _write(name, content): + # tests run in a per-test temp cwd (see tests/conftest.py) + with open(name, "w", newline="\n") as f: + f.write(content) + return name + + +def _fzc_compile(content, input_variables, out="output"): + _write("in.txt", content) + fzc("in.txt", input_variables, MODEL, output_dir=out) + compiled = [ + os.path.join(root, "in.txt") + for root, _, files in os.walk(out) + if "in.txt" in files + ] + assert len(compiled) == 1, compiled + with open(compiled[0]) as f: + return f.read() + + +# --------------------------------------------------------------------------- # +# reference behaviour: fzi already did this (guards against an fzi regression) +# --------------------------------------------------------------------------- # + +def test_fzi_preevaluates_default_and_formula(): + _write("in.txt", CONTENT) + result = fzi("in.txt", model=MODEL) + assert result["x"] == 3 + assert result["x * 2"] == 6 + + +# --------------------------------------------------------------------------- # +# fzc +# --------------------------------------------------------------------------- # + +def test_fzc_preevaluates_formula_from_inline_default(): + # Regression: previously produced "y = @{x * 2}" + compiled = _fzc_compile(CONTENT, {}) + assert "x = 3" in compiled + assert "y = 6" in compiled + assert "@{" not in compiled + + +def test_fzc_matches_fzi_preevaluation(): + _write("in.txt", CONTENT) + pre = fzi("in.txt", model=MODEL) + compiled = _fzc_compile(CONTENT, {}) + assert f"y = {pre['x * 2']}" in compiled + + +def test_fzc_explicit_value_overrides_inline_default(): + compiled = _fzc_compile(CONTENT, {"x": 10}) + assert "x = 10" in compiled + assert "y = 20" in compiled + + +def test_fzc_formula_left_unevaluated_when_a_needed_var_has_no_default(): + # y has no default and is not provided -> the formula that needs it cannot + # be evaluated and is left unevaluated (same as fzi returning None); the + # formula that only needs the defaulted x is still evaluated. + content = ( + "a = $(x~10)\n" + "b = $(y)\n" + "area = @{$x * $y}\n" + "double = @{$x * 2}\n" + ) + compiled = _fzc_compile(content, {}) + assert "double = 20" in compiled + # x still gets substituted from its default, but the formula is not resolved + assert "@{" in compiled + assert "$y" in compiled + + +def test_fzc_default_still_used_when_partial_vars_given(): + content = ( + "a = $(x~10)\n" + "b = $(y)\n" + "area = @{$x * $y}\n" + "double = @{$x * 2}\n" + ) + compiled = _fzc_compile(content, {"y": 4}) + assert "area = 40" in compiled # x from default, y provided + assert "double = 20" in compiled + + +def test_fzc_float_default_in_formula(): + compiled = _fzc_compile("r = $(r~2.5)\narea = @{3.14 * r * r}\n", {}) + assert "r = 2.5" in compiled + assert "area = 19.625" in compiled + + +# --------------------------------------------------------------------------- # +# fzr (goes through the same compile path -> must behave like fzc) +# --------------------------------------------------------------------------- # + +def test_fzr_preevaluates_formula_from_inline_default(): + _write("in.txt", CONTENT) + model = dict(MODEL, output={"y": "sed -n 's/^y = //p' in.txt"}) + df = fzr("in.txt", {}, model, calculators=["sh://echo done"], results_dir="res") + + # formula evaluated from the inline default and parsed back from output + assert list(df["y"]) == [6] + + # and the compiled input file kept in the results dir shows it too + with open(os.path.join("res", "in.txt")) as f: + compiled = f.read() + assert "x = 3" in compiled + assert "y = 6" in compiled + + +def test_fzr_explicit_value_overrides_inline_default(): + _write("in.txt", CONTENT) + model = dict(MODEL, output={"y": "sed -n 's/^y = //p' in.txt"}) + df = fzr("in.txt", {"x": [4, 7]}, model, + calculators=["sh://echo done"], results_dir="res") + assert sorted(df["y"]) == [8, 14] + + +# --------------------------------------------------------------------------- # +# the shared helper that both paths now use +# --------------------------------------------------------------------------- # + +def test_parse_variable_defaults_from_content(): + content = ( + "$(i~42) $(f~3.5) $(s~hello) $(q~\"quoted\") $(sci~1e3) " + "$(nodefault) $(lst~[0,1]) $(truncated~[0,1) $(withmeta~7;a comment;[0,10])" + ) + got = parse_variable_defaults_from_content(content, "$", "()") + assert got == { + "i": 42, + "f": 3.5, + "s": "hello", + "q": "quoted", + "sci": 1000.0, + "lst": [0, 1], # valid list literal -> kept as-is + "truncated": None, # starts with '[' but does not parse -> None + "withmeta": 7, # metadata after ';' is ignored + } + assert "nodefault" not in got + + +def test_parse_variable_defaults_empty_without_delim(): + assert parse_variable_defaults_from_content("$x $y", "$", "") == {} + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])