Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 3 additions & 35 deletions fz/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 = {}
Expand Down
16 changes: 13 additions & 3 deletions fz/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
}
Comment on lines +1876 to +1880

# 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
Expand Down
52 changes: 52 additions & 0 deletions fz/interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
192 changes: 192 additions & 0 deletions tests/test_fzc_defaults.py
Original file line number Diff line number Diff line change
@@ -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"])
Loading