From 615d9dabd8dcef160338f36ed742cfd66a01c812 Mon Sep 17 00:00:00 2001 From: RICHET-YAN Date: Thu, 6 Aug 2026 18:50:48 +0200 Subject: [PATCH 1/2] Make input_variables optional for non-parametric datasets, add simplified variable syntax fzr/fzc (CLI and Python) no longer require input_variables when the input files declare no variables: the CLI can omit --input_variables, and the Python API accepts fzr(input_path, model=model, ...) without it. When the input files do declare variables and it's omitted, a clear error lists them instead of failing the generic "argument required" check. Also adds a simplified --input_variables/--input_vars syntax as an alternative to inline JSON, e.g. "a=1,b=[4,5,6],c=[0;1]", for fzc/fzr/fzd. Co-Authored-By: Claude Sonnet 5 --- NEWS.md | 8 +++ README.md | 12 +++- fz/cli.py | 134 +++++++++++++++++++++++++++++++++---- fz/core.py | 79 ++++++++++++++++------ skills/fz/SKILL.md | 5 +- skills/fz/reference.md | 3 + tests/test_cli_aliases.py | 54 +++++++++++++++ tests/test_cli_commands.py | 63 +++++++++++++++++ tests/test_no_variables.py | 75 +++++++++++++++++++++ 9 files changed, 395 insertions(+), 38 deletions(-) diff --git a/NEWS.md b/NEWS.md index ca30a5d..4872ffb 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,14 @@ ## Unreleased +### `--input_variables` no longer required for variable-free datasets + +- `fzc`/`fzr` CLI (standalone and `fz compile`/`fz run`) no longer require + `--input_variables` when the input files declare no variables. If they + omit it and the model does declare variables, the CLI now errors out + listing the variable(s) it found, instead of failing the generic + "required argument" check before even looking at the input files. + ### Formula number formatting (`@{expr | pattern}`) - Formula format specifiers now support the full `java.text.DecimalFormat` diff --git a/README.md b/README.md index 012e9d3..0c72320 100644 --- a/README.md +++ b/README.md @@ -985,7 +985,11 @@ fz.fzc( **Parameters**: - `input_path`: Path to input file or directory -- `input_variables`: Dictionary of variable values (scalar or list) +- `input_variables`: Dictionary of variable values (scalar or list). Optional (default + `None`) when the input files declare no variables (non-parametric dataset) — omit it + and pass `model` as a keyword argument: `fz.fzc(input_path, model=model)`. If the + input files do declare variables and it's omitted, `fzc` raises a `ValueError` naming + them. - `model`: Model definition (dict or alias name) - `output_dir`: Output directory path @@ -1070,7 +1074,11 @@ print(results) **Parameters**: - `input_path`: Input file or directory path -- `input_variables`: Variable values - dict (factorial) or DataFrame (non-factorial) +- `input_variables`: Variable values - dict (factorial) or DataFrame (non-factorial). + Optional (default `None`) when the input files declare no variables (non-parametric + dataset) — omit it and pass `model` as a keyword argument: `fz.fzr(input_path, + model=model, calculators=calculators)`. If the input files do declare variables and + it's omitted, `fzr` raises a `ValueError` naming them. - `model`: Model definition (dict or alias) - `calculators`: Calculator URI(s) - string or list - `results_dir`: Results directory path diff --git a/fz/cli.py b/fz/cli.py index 2a7cc58..3f753c0 100644 --- a/fz/cli.py +++ b/fz/cli.py @@ -125,8 +125,95 @@ def parse_model(model_str): return parse_argument(model_str, alias_type='models') -def parse_variables(var_str): - """Parse variables from JSON string or JSON file""" +def _split_top_level(s, sep=','): + """Split s on sep, ignoring occurrences of sep nested inside [...]""" + parts = [] + depth = 0 + current = [] + for ch in s: + if ch == '[': + depth += 1 + current.append(ch) + elif ch == ']': + depth -= 1 + current.append(ch) + elif ch == sep and depth == 0: + parts.append(''.join(current)) + current = [] + else: + current.append(ch) + parts.append(''.join(current)) + return parts + + +def _coerce_scalar(s): + """Convert a string to int/float when possible, else return it unchanged""" + s = s.strip() + try: + return int(s) + except ValueError: + pass + try: + return float(s) + except ValueError: + return s + + +def _parse_simple_variables(var_str, as_strings=False): + """ + Parse the simplified 'a=1,b=[4,5,6],c=[0;1]' variable syntax into a dict. + + - Plain scalars ('a=1') become int/float when possible. + - Bracketed, comma/semicolon-separated lists ('b=[4,5,6]', 'c=[0;1]') become a + list of coerced elements — fzr/fzc use this as a factorial grid; a 2-element + '[min;max]'/'[min,max]' list also happens to be exactly the range syntax fzd's + algorithms expect (see algorithms.parse_input_vars), so it round-trips there too. + - When as_strings is True (fzd), values are kept as plain strings instead + (fzd's algorithms.py parses "[min;max]" / numeric strings itself). + """ + result = {} + for part in _split_top_level(var_str, ','): + part = part.strip() + if not part: + continue + if '=' not in part: + raise ValueError( + f"Invalid variable assignment '{part}': expected 'name=value' " + "(e.g. \"a=1,b=[4,5,6],c=[0;1]\")" + ) + name, value = part.split('=', 1) + name = name.strip() + value = value.strip() + + if as_strings: + result[name] = value + continue + + if value.startswith('[') and value.endswith(']'): + elements = _split_top_level(value[1:-1], ',') + elements = [e for part_ in elements for e in _split_top_level(part_, ';')] + result[name] = [_coerce_scalar(e) for e in elements] + else: + result[name] = _coerce_scalar(value) + + return result + + +def parse_variables(var_str, as_strings=False): + """ + Parse variables from JSON string, JSON file, or the simplified + 'a=1,b=[4,5,6],c=[0;1]' syntax (used when the value isn't JSON/a .json path). + """ + if not var_str: + return None + + stripped = var_str.strip() + if stripped.startswith(('{', '[')) or var_str.endswith('.json'): + return parse_argument(var_str, alias_type=None) + + if '=' in stripped: + return _parse_simple_variables(stripped, as_strings=as_strings) + return parse_argument(var_str, alias_type=None) @@ -231,8 +318,29 @@ def _resolve_model(parser, args): def _add_variables_arg(parser, required=True): + help_text = "Variable values (JSON file or inline JSON)" + if not required: + help_text += " (omit if the input files declare no variables)" parser.add_argument("--input_variables", "--variables", "-v", dest="input_variables", - required=required, help="Variable values (JSON file or inline JSON)") + required=required, default=None, help=help_text) + + +def _resolve_variables(parser, args, input_path, model): + """Resolve --input_variables, auto-falling back to {} for variable-free models. + + input_variables is only truly required when the input files actually + declare variables; otherwise requiring an empty --input_variables '{}' + on every call would be needless friction for non-parametric datasets. + """ + if args.input_variables is None: + found_variables = fzi_func(input_path, model) + if found_variables: + parser.error( + "--input_variables is required: input files declare variable(s) " + f"{', '.join(sorted(found_variables))}" + ) + return {} + return parse_variables(args.input_variables) def _add_calculators_arg(parser): @@ -560,7 +668,7 @@ def fzc_main(): parser.add_argument("--version", action="version", version=f"fzc {get_version()}") _add_input_path_args(parser) _add_model_args(parser) - _add_variables_arg(parser) + _add_variables_arg(parser, required=False) _add_input_static_arg(parser) parser.add_argument("--output_dir", "--output", "-o", dest="output_dir", default="output", help="Output directory (default: output)") @@ -570,7 +678,7 @@ def fzc_main(): try: input_path = _resolve_path(parser, args.input_path, args.input_path_pos, "input_path") model = _resolve_model(parser, args) - variables = parse_variables(args.input_variables) + variables = _resolve_variables(parser, args, input_path, model) fzc_func(input_path, variables, model, output_dir=args.output_dir, input_static=_resolve_input_static(args)) print(f"Compiled input saved to {args.output_dir}") @@ -627,7 +735,7 @@ def fzr_main(): parser.add_argument("--version", action="version", version=f"fzr {get_version()}") _add_input_path_args(parser) _add_model_args(parser) - _add_variables_arg(parser) + _add_variables_arg(parser, required=False) parser.add_argument("--results_dir", "--results", "-r", dest="results_dir", default="results", help="Results directory (default: results)") parser.add_argument("--case_naming", dest="case_naming", default=None, @@ -644,7 +752,7 @@ def fzr_main(): try: input_path = _resolve_path(parser, args.input_path, args.input_path_pos, "input_path") model = _resolve_model(parser, args) - variables = parse_variables(args.input_variables) + variables = _resolve_variables(parser, args, input_path, model) calculators = _resolve_calculators(args) result = fzr_func(input_path, variables, model, @@ -696,7 +804,7 @@ def fzd_main(): try: model = parse_model(args.model) - variables = parse_variables(args.input_vars) + variables = parse_variables(args.input_vars, as_strings=True) calculators = parse_calculators(args.calculators) if args.calculators else None algo_options = parse_algorithm_options(args.options) if args.options else {} @@ -756,7 +864,7 @@ def main(): parser_compile = subparsers.add_parser("compile", help="Compile input with variable values") _add_input_path_args(parser_compile) _add_model_args(parser_compile) - _add_variables_arg(parser_compile) + _add_variables_arg(parser_compile, required=False) _add_input_static_arg(parser_compile) parser_compile.add_argument("--output_dir", "--output", "-o", dest="output_dir", default="output", help="Output directory (default: output)") @@ -771,7 +879,7 @@ def main(): parser_run = subparsers.add_parser("run", help="Run full parametric calculations") _add_input_path_args(parser_run) _add_model_args(parser_run) - _add_variables_arg(parser_run) + _add_variables_arg(parser_run, required=False) parser_run.add_argument("--results_dir", "--results", "-r", dest="results_dir", default="results", help="Results directory (default: results)") parser_run.add_argument("--case_naming", dest="case_naming", default=None, @@ -858,7 +966,7 @@ def main(): elif args.command == "compile": input_path = _resolve_path(parser, args.input_path, args.input_path_pos, "input_path") model = _resolve_model(parser, args) - variables = parse_variables(args.input_variables) + variables = _resolve_variables(parser, args, input_path, model) fzc_func(input_path, variables, model, output_dir=args.output_dir, input_static=_resolve_input_static(args)) print(f"Compiled input saved to {args.output_dir}") @@ -872,7 +980,7 @@ def main(): elif args.command == "run": input_path = _resolve_path(parser, args.input_path, args.input_path_pos, "input_path") model = _resolve_model(parser, args) - variables = parse_variables(args.input_variables) + variables = _resolve_variables(parser, args, input_path, model) calculators = _resolve_calculators(args) result = fzr_func(input_path, variables, model, @@ -884,7 +992,7 @@ def main(): elif args.command == "design": model = parse_model(args.model) - variables = parse_variables(args.input_vars) + variables = parse_variables(args.input_vars, as_strings=True) calculators = None calculators = parse_calculators(args.calculators) if args.calculators else None diff --git a/fz/core.py b/fz/core.py index d1066a7..94a8edf 100644 --- a/fz/core.py +++ b/fz/core.py @@ -1138,8 +1138,8 @@ def fzi(input_path: str, model: Union[str, Dict], input_static: Optional[List[st @with_helpful_errors def fzc( input_path: str, - input_variables: Dict, - model: Union[str, Dict], + input_variables: Optional[Dict] = None, + model: Union[str, Dict] = None, output_dir: str = "output", input_static: Optional[List[str]] = None, ) -> None: @@ -1148,7 +1148,12 @@ def fzc( Args: input_path: Path to input file or directory - input_variables: Dict of variable values or lists/numpy arrays of values for grid + input_variables: Dict of variable values or lists/numpy arrays of values for grid. + Optional (default None) when the input files declare no variables + (non-parametric dataset) — in that case omit it and pass model as a + keyword argument, e.g. fzc(input_path, model=model). If the input files + do declare variables, it's still required and a ValueError is raised + naming the variable(s) found. model: Model definition dict or alias string output_dir: Output directory for compiled files input_static: Files identical across every case (see fzr()'s input_static); @@ -1156,16 +1161,15 @@ def fzc( Raises: TypeError: If arguments have invalid types - ValueError: If model is invalid + ValueError: If model is invalid, or input_variables is omitted but required FileNotFoundError: If input_path doesn't exist """ # Validate input arguments if not isinstance(input_path, (str, Path)): raise TypeError(f"input_path must be a string or Path, got {type(input_path).__name__}") - # Allow dict or pandas DataFrame for input_variables - if not isinstance(input_variables, (dict, pd.DataFrame)): - raise TypeError(f"input_variables must be a dictionary or DataFrame, got {type(input_variables).__name__}") + if model is None: + raise TypeError("fzc() missing required argument: 'model'") if not isinstance(output_dir, (str, Path)): raise TypeError(f"output_dir must be a string or Path, got {type(output_dir).__name__}") @@ -1187,6 +1191,19 @@ def fzc( # Check if any input_variable keys are missing in input files found_variables = fzi(str(input_path), model, input_static=input_static) + + if input_variables is None: + if found_variables: + raise ValueError( + "input_variables is required: input files declare variable(s) " + f"{', '.join(sorted(found_variables))}" + ) + input_variables = {} + + # Allow dict or pandas DataFrame for input_variables + if not isinstance(input_variables, (dict, pd.DataFrame)): + raise TypeError(f"input_variables must be a dictionary or DataFrame, got {type(input_variables).__name__}") + missing_vars = set(input_variables.keys()) - set(found_variables.keys()) if missing_vars: log_warning(f"⚠️ Warning: The following input variables are not found in input files: {', '.join(sorted(missing_vars))}") @@ -1506,8 +1523,8 @@ def fzo( @with_helpful_errors def fzr( input_path: str, - input_variables: Union[Dict, "pandas.DataFrame"], - model: Union[str, Dict], + input_variables: Optional[Union[Dict, "pandas.DataFrame"]] = None, + model: Union[str, Dict] = None, results_dir: str = "results", calculators: Union[str, Dict, List[Union[str, Dict]]] = None, callbacks: Optional[Dict[str, callable]] = None, @@ -1523,6 +1540,11 @@ def fzr( input_variables: Dict of variable values or lists/numpy arrays of values for factorial grid, or pandas DataFrame for non-factorial designs (each row is one case). Numpy arrays are automatically converted to lists. + Optional (default None) when the input files declare no variables + (non-parametric dataset) — in that case omit it and pass model as a + keyword argument, e.g. fzr(input_path, model=model). If the input + files do declare variables, it's still required and a ValueError is + raised naming the variable(s) found. model: Model definition dict or alias string results_dir: Results directory calculators: Calculator specifications @@ -1560,25 +1582,16 @@ def fzr( Raises: TypeError: If arguments have invalid types - ValueError: If model is invalid or calculators are invalid + ValueError: If model is invalid, calculators are invalid, or input_variables is + omitted but required FileNotFoundError: If input_path doesn't exist """ # Validate input arguments if not isinstance(input_path, (str, Path)): raise TypeError(f"input_path must be a string or Path, got {type(input_path).__name__}") - # Allow dict or pandas DataFrame for input_variables - if not isinstance(input_variables, (dict, pd.DataFrame)): - raise TypeError(f"input_variables must be a dictionary or DataFrame, got {type(input_variables).__name__}") - - # Reject duplicate rows in a DataFrame design: each row must be a distinct case - # (duplicate rows would map to the same temp directory and silently overwrite results) - if isinstance(input_variables, pd.DataFrame) and input_variables.duplicated().any(): - dup_idx = input_variables[input_variables.duplicated(keep=False)].index.tolist() - raise ValueError( - f"input_variables DataFrame contains duplicate rows (indices {dup_idx}). " - "Each case must have a unique combination of input values." - ) + if model is None: + raise TypeError("fzr() missing required argument: 'model'") if not isinstance(results_dir, (str, Path)): raise TypeError(f"results_dir must be a string or Path, got {type(results_dir).__name__}") @@ -1645,6 +1658,28 @@ def fzr( # Check if any input_variable keys are missing in input files found_variables = fzi(str(input_path), model) + + if input_variables is None: + if found_variables: + raise ValueError( + "input_variables is required: input files declare variable(s) " + f"{', '.join(sorted(found_variables))}" + ) + input_variables = {} + + # Allow dict or pandas DataFrame for input_variables + if not isinstance(input_variables, (dict, pd.DataFrame)): + raise TypeError(f"input_variables must be a dictionary or DataFrame, got {type(input_variables).__name__}") + + # Reject duplicate rows in a DataFrame design: each row must be a distinct case + # (duplicate rows would map to the same temp directory and silently overwrite results) + if isinstance(input_variables, pd.DataFrame) and input_variables.duplicated().any(): + dup_idx = input_variables[input_variables.duplicated(keep=False)].index.tolist() + raise ValueError( + f"input_variables DataFrame contains duplicate rows (indices {dup_idx}). " + "Each case must have a unique combination of input values." + ) + missing_vars = set(input_variables.keys()) - set(found_variables.keys()) if missing_vars: log_warning(f"⚠️ Warning: The following input variables are not found in input files: {', '.join(sorted(missing_vars))}") diff --git a/skills/fz/SKILL.md b/skills/fz/SKILL.md index 78437f4..f842c8d 100644 --- a/skills/fz/SKILL.md +++ b/skills/fz/SKILL.md @@ -309,7 +309,10 @@ read [algorithm-wrapper.md](algorithm-wrapper.md). period/scientific-notation values (returning a wrong "max"); computing min/max in `awk` (one pass) is more robust than `sort | head/tail`. - `fzr` argument order in Python is `(input_path, input_variables, model, results_dir=..., - calculators=...)` — use keyword arguments to stay safe. + calculators=...)` — use keyword arguments to stay safe. `input_variables` (and `fzc`'s) + default to `None`: for a non-parametric dataset (no variables in the input files) call + `fzr(input_path, model=model, ...)` and omit it; if the input files do declare variables + and it's omitted, fz raises a `ValueError` naming them. - Concurrency: repeat the same calculator URI N times (or set `FZ_MAX_WORKERS`) to run N cases in parallel. - Long studies: run `fzr` in the background, then monitor `results/*/log.txt` and the diff --git a/skills/fz/reference.md b/skills/fz/reference.md index b63e197..59bfb2e 100644 --- a/skills/fz/reference.md +++ b/skills/fz/reference.md @@ -183,6 +183,9 @@ repeatable to add several. See `input_static` in `fz.fzr`'s signature above. `--results_dir`, `--output` = `--output_dir`. (fz 1.0 required the canonical flag names and had no positional form; the canonical flags work everywhere — prefer them.) - `--format` accepts: `json`, `csv`, `html`, `markdown`, `table`. +- `--input_variables` (fzc/fzr only) can be omitted when the input files declare no + variables (a non-parametric dataset) — omitting it otherwise errors out listing the + variable(s) found, so it's still required whenever the model actually has any. - `--model` and `--input_variables` auto-detect their format: alias (bare name) → JSON file path (ends in `.json`) → inline JSON. `--calculators` takes a URI, JSON file path, a bare alias name, or an inline JSON list (`'["cache://run1", "sh://bash calc.sh"]'`); diff --git a/tests/test_cli_aliases.py b/tests/test_cli_aliases.py index fb1b48f..76a8b02 100644 --- a/tests/test_cli_aliases.py +++ b/tests/test_cli_aliases.py @@ -119,6 +119,60 @@ def test_canonical_flags_still_work(self): assert Path("compiled_canonical/x=9/input.txt").exists() +class TestSimpleVariablesSyntax: + """--input_variables also accepts 'a=1,b=[4,5,6],c=[0;1]' besides JSON""" + + def test_fzc_simple_scalar(self): + _write_input() + result = run_fz_cli_function("fzc_main", [ + "input.txt", "--model", '{"varprefix": "$"}', + "--variables", "x=5", "--output", "compiled", + ]) + assert result.returncode == 0, result.stderr + assert Path("compiled/x=5/input.txt").exists() + + def test_fzr_simple_grid_and_fixed(self): + _write_input("a=$a\nb=$b\n") + _write_file("calc.sh", "#!/bin/bash\necho 42 > output.txt\n") + result = run_fz_cli_function("fzr_main", [ + "input.txt", + "--model", '{"varprefix": "$", "output": {"y": "cat output.txt"}}', + "--variables", "a=1,b=[4,5,6]", + "--calculators", "sh://bash calc.sh", + "--results", "results_grid", + "--format", "json", + ]) + assert result.returncode == 0, result.stderr + records = json.loads(result.stdout) + assert len(records) == 3 + assert all(r["a"] == 1 for r in records) + assert sorted(r["b"] for r in records) == [4, 5, 6] + + def test_semicolon_bracket_becomes_two_element_list(self): + _write_input("c=$c\n") + result = run_fz_cli_function("fzc_main", [ + "input.txt", "--model", '{"varprefix": "$"}', + "--variables", "c=[0;1]", "--output", "compiled_range", + ]) + assert result.returncode == 0, result.stderr + # c is a 2-value grid [0, 1] -> one subdirectory per value + assert Path("compiled_range/c=0/input.txt").exists() + assert Path("compiled_range/c=1/input.txt").exists() + + def test_invalid_simple_syntax_is_reported(self): + _write_input() + result = run_fz_cli_function("fzc_main", [ + "input.txt", "--model", '{"varprefix": "$"}', + "--variables", "not_an_assignment", "--output", "compiled_bad", + ]) + assert result.returncode != 0 + + def test_fzd_keeps_range_as_string(self): + """fzd's simplified --input_vars values stay strings (algorithms.py parses them)""" + from fz.cli import parse_variables + assert parse_variables("x=[0;1],z=0.5", as_strings=True) == {"x": "[0;1]", "z": "0.5"} + + class TestInlineModel: def test_fzi_inline_model_without_model_flag(self): _write_input() diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 43898ea..384c0b9 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -260,6 +260,34 @@ def test_fzc_basic(self, sample_input_file, sample_model, sample_variables, temp assert result.returncode == 0 assert output_dir.exists() + def test_fzc_omitted_input_variables_when_none_in_input(self, sample_model, temp_workspace): + """--input_variables can be omitted when the input file declares no variables""" + input_file = temp_workspace / "input.txt" + input_file.write_text("x = 1\ny = 2\n") + output_dir = temp_workspace / "output" + + result = run_fz_cli_function('fzc_main', [ + "--input_path", str(input_file), + "--model", json.dumps(sample_model), + "--output_dir", str(output_dir) + ]) + + assert result.returncode == 0 + assert output_dir.exists() + + def test_fzc_omitted_input_variables_when_variables_present(self, sample_input_file, sample_model, temp_workspace): + """--input_variables is still required when the input file declares variables""" + result = run_fz_cli_function('fzc_main', [ + "--input_path", str(sample_input_file), + "--model", json.dumps(sample_model), + "--output_dir", str(temp_workspace / "output") + ]) + + assert result.returncode != 0 + output = result.stdout + result.stderr + assert "input_variables" in output.lower() + assert "var1" in output + class TestFzoCommand: """Test fzo command (parse output files)""" @@ -335,6 +363,41 @@ def test_fzr_with_shell_calculator(self, sample_input_file, sample_model, sample # May fail if calculator execution has issues, that's OK for this test assert result.returncode in [0, 1] + @pytest.mark.skipif(IS_WINDOWS, reason="Complex test, skip on Windows for now") + def test_fzr_omitted_input_variables_when_none_in_input(self, sample_model, temp_workspace): + """--input_variables can be omitted for a non-parametric (variable-free) dataset""" + input_file = temp_workspace / "input.txt" + input_file.write_text("x = 1\ny = 2\n") + + calc_script = temp_workspace / "calc.sh" + calc_script.write_text("#!/bin/bash\necho 'result = 10'") + calc_script.chmod(0o755) + + result = run_cli_command([ + get_python_executable(), "-m", "fz.cli", "run", + "--input_path", str(input_file), + "--model", json.dumps(sample_model), + "--results_dir", str(temp_workspace / "results"), + "--calculators", json.dumps({"local": {"type": "shell", "command": str(calc_script)}}), + "--format", "json" + ], cwd=str(temp_workspace), check=False) + + assert result.returncode in [0, 1] + assert "input_variables" not in (result.stdout + result.stderr).lower() + + def test_fzr_omitted_input_variables_when_variables_present(self, sample_input_file, sample_model, temp_workspace): + """--input_variables is still required when the input file declares variables""" + result = run_fz_cli_function('fzr_main', [ + "--input_path", str(sample_input_file), + "--model", json.dumps(sample_model), + "--results_dir", str(temp_workspace / "results"), + ]) + + assert result.returncode != 0 + output = result.stdout + result.stderr + assert "input_variables" in output.lower() + assert "var1" in output + class TestFzMainCommand: """Test fz main command with subcommands""" diff --git a/tests/test_no_variables.py b/tests/test_no_variables.py index 14d1098..5c093b7 100644 --- a/tests/test_no_variables.py +++ b/tests/test_no_variables.py @@ -406,6 +406,81 @@ def test_fzr_with_empty_input_variables(): assert results is not None +def test_fzr_omitted_input_variables_when_none_in_input(): + """fzr(input_path, model=model) works when the input files declare no variables""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + + input_file = tmpdir / "input.txt" + input_file.write_text("constant = 42\n") + + calc_script = tmpdir / "calc.sh" + calc_script.write_text("#!/bin/bash\necho 'result = 100' > output.txt\n") + calc_script.chmod(0o755) + + model = { + "varprefix": "$", + "delim": "{}", + "output": { + "result": "grep 'result' output.txt | awk '{print $3}'" + } + } + + # input_variables omitted entirely + results = fzr( + str(input_file), + calculators=f"sh://{calc_script}", + results_dir=str(tmpdir / "results"), + model=model + ) + + assert results is not None + + +def test_fzr_omitted_input_variables_when_variables_present(): + """fzr(input_path, model=model) raises ValueError when the input files declare variables""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + + input_file = tmpdir / "input.txt" + input_file.write_text("x = ${x}\n") + + model = {"varprefix": "$", "delim": "{}"} + + with pytest.raises(ValueError, match="input_variables"): + fzr(str(input_file), model=model) + + +def test_fzc_omitted_input_variables_when_none_in_input(): + """fzc(input_path, model=model) works when the input files declare no variables""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + + input_file = tmpdir / "input.txt" + input_file.write_text("constant = 42\n") + + model = {"varprefix": "$", "delim": "{}"} + output_dir = tmpdir / "output" + + fzc(str(input_file), model=model, output_dir=str(output_dir)) + + assert output_dir.exists() + + +def test_fzc_omitted_input_variables_when_variables_present(): + """fzc(input_path, model=model) raises ValueError when the input files declare variables""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + + input_file = tmpdir / "input.txt" + input_file.write_text("x = ${x}\n") + + model = {"varprefix": "$", "delim": "{}"} + + with pytest.raises(ValueError, match="input_variables"): + fzc(str(input_file), model=model, output_dir=str(tmpdir / "output")) + + if __name__ == "__main__": # Run tests manually for debugging pytest.main([__file__, "-v"]) From c8de1841a39d9d7effb3089c6037aab14a86e73f Mon Sep 17 00:00:00 2001 From: RICHET-YAN Date: Thu, 6 Aug 2026 18:52:58 +0200 Subject: [PATCH 2/2] Detect file encoding in output read() instead of assuming UTF-8 SCALE (and other tools) can emit locale-encoded text (e.g. French month names in cp1252), which crashed grep-based output extraction with UnicodeDecodeError. read() now uses charset-normalizer to detect the actual encoding, falling back to Latin-1 (never fails) if detection is inconclusive. Co-Authored-By: Claude Sonnet 5 --- fz/outparsers.py | 13 ++++++++++++- pyproject.toml | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/fz/outparsers.py b/fz/outparsers.py index bf75a4d..fc5ab93 100644 --- a/fz/outparsers.py +++ b/fz/outparsers.py @@ -89,6 +89,8 @@ from pathlib import Path from typing import Any, Callable, Dict, Optional, Union +from charset_normalizer import from_bytes as _from_bytes + from .logging import log_debug #: Prefix marking a model output entry as a native Python expression @@ -223,7 +225,16 @@ def _resolve(path: Union[str, Path]) -> Path: def read(path: Union[str, Path]) -> str: """Return the full content of a file as a string.""" - return _resolve(path).read_text() + data = _resolve(path).read_bytes() + try: + return data.decode("utf-8") + except UnicodeDecodeError: + # Some tools (e.g. SCALE) emit locale-encoded text (cp1252, + # iso-8859-*, ...). Detect the actual encoding rather than + # guessing, falling back to Latin-1 (never fails: every byte + # 0x00-0xFF maps to a character) if detection is inconclusive. + best = _from_bytes(data).best() + return str(best) if best is not None else data.decode("latin-1") def lines(path: Union[str, Path]) -> list: """Return the list of lines of a file (without line endings).""" diff --git a/pyproject.toml b/pyproject.toml index 6948565..3f2b2b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ requires-python = ">=3.8" dependencies = [ "paramiko>=2.7.0", "pandas>=1.0.0", + "charset-normalizer>=3.0.0", ] [project.optional-dependencies]