Formulas & Safe Eval
Some values in Fullfinity are not written by a developer — they are authored as data: a
statutory tax-return box, a configurable financial-report line, a payroll salary component.
Each carries a formula string the user (or a seed file) typed in. Evaluating those strings
with Python’s built-in eval would let a formula execute arbitrary code. safe_eval is the
blessed way to evaluate them: it parses the expression into an AST and walks it against a
strict whitelist, so a malformed or hostile formula raises FormulaError and never runs
anything.
Never hand-roll eval/exec for a user- or data-authored formula. Always use
safe_eval. It lives in fullfinity/engine/safe_eval.py and safe_eval/FormulaError
are re-exported from engine/base.py, so they are available in every module’s
from fullfinity.engine.base import *.
The API
Section titled “The API”def safe_eval(expr: str, names: dict | None = None, functions: dict | None = None): """Evaluate `expr` over `names`. Returns a number. Raises `FormulaError`."""expr— the formula string, e.g."(revenue - cogs) * 0.7".names— maps each bare identifier in the expression to a numeric value ({"revenue": 1000.0, "cogs": 400.0}). A name used in the formula but absent from this mapping raisesFormulaError.functions— overrides the default helper allow-list. When omitted, the defaults (abs,min,max,round) apply. Pass your own dict to add domain helpers (see the payroll bracket-table example below) — note that passing a dict replaces the defaults rather than extending them, so include_DEFAULT_FUNCTIONSif you still want them.
from fullfinity.engine.base import safe_eval, FormulaError
result = safe_eval("(revenue - cogs) * margin", { "revenue": 1000.0, "cogs": 400.0, "margin": 0.5,})# result == 300.0FormulaError subclasses ValidationError, so it surfaces to the user as a toast (no
traceback) and is caught by except FullfinityError.
What is allowed — and what is blocked
Section titled “What is allowed — and what is blocked”The evaluator walks the parsed AST and accepts only:
- Numeric literals —
intandfloat.boolis rejected explicitly soTrue/Falsecannot masquerade as1/0. - Names bound in the supplied
namesmapping (referenced bare in the expression). - Binary operators
+ - * /, unary+ -, and parentheses. - Calls to a name in the allow-list (
abs,min,max,roundby default).
Everything else raises FormulaError: attribute access, subscripts, comprehensions,
boolean/comparison operators, calls to any name not in the allow-list, and keyword
arguments in a call. A syntax error in the string also raises FormulaError (with the
offending expression and the parser’s message).
safe_eval("__import__('os').system('rm -rf /')") # FormulaError — calls a disallowed namesafe_eval("revenue.balance", {"revenue": 1.0}) # FormulaError — attribute access blockedsafe_eval("a if b else c", {...}) # FormulaError — disallowed elementsafe_eval("True * 5") # FormulaError — bool literal rejectedsafe_eval("round(x, ndigits=2)", {"x": 1.5}) # FormulaError — keyword args blockedDivision by zero
Section titled “Division by zero”Division by zero does not raise — a / 0 evaluates to 0.0. The design intent is that
a blank report line or tax box reads as zero rather than crashing the whole report when one
denominator happens to be empty.
safe_eval("net / base", {"net": 100.0, "base": 0.0}) # -> 0.0Dependency ordering with extract_names
Section titled “Dependency ordering with extract_names”When formula lines reference each other (line C = A + B), you must evaluate the
dependencies first. extract_names returns the set of bare names a formula references,
without evaluating it — exactly what a report engine needs to topologically order lines and
to validate that every referenced code exists.
def extract_names(expr: str) -> set: """Return the set of bare names referenced in `expr` (for dependency ordering)."""extract_names is not re-exported from engine/base.py — import it directly:
from fullfinity.engine.safe_eval import safe_eval, extract_names, FormulaErrorThe configurable financial-report engine uses it to resolve each formula line’s references before evaluating, raising a clear error for an unknown referenced code:
# enterprise/accounting — evaluating a Formula report linens = {}for nm in extract_names(line.formula): ref = by_code.get(nm) if ref is None: raise FormulaError(f"Formula on '{line.label}' references unknown line code '{nm}'") ns[nm] = await amount_of(ref) # compute each dependency (recursively)value = float(safe_eval(line.formula, ns))extract_names is also useful at save time to validate a formula before it is ever run —
parse the names, then dry-run the formula with every name bound to 0.0:
from fullfinity.engine.safe_eval import safe_eval, extract_names, FormulaError
try: names = extract_names(formula or "") safe_eval(formula, {n: 0.0 for n in names})except FormulaError as e: raise UserError(f"Invalid formula: {e}")Custom helper functions
Section titled “Custom helper functions”Pass a functions dict to extend the callable allow-list — for example a payroll engine that
exposes each tax-bracket table as a callable by its code. Because passing functions
replaces the defaults, start from _DEFAULT_FUNCTIONS:
from fullfinity.engine.safe_eval import safe_eval, FormulaError, _DEFAULT_FUNCTIONS
funcs = dict(_DEFAULT_FUNCTIONS)funcs[bracket_table.code] = bracket_table.as_callable() # e.g. income_tax(base)
try: amount = float(safe_eval(component.formula, dict(ctx), funcs))except FormulaError as e: raise UserError(f"Salary component '{component.name}' has an invalid formula: {e}")Here ctx is the names mapping — the payroll variables in scope (wage, base, prior
component codes, …) — and the formula may call only the helpers in funcs.
Where it is used
Section titled “Where it is used”- Configurable financial reports (
enterprise/accounting) — aFormulareport line computes from other lines’ codes. - Payroll salary components (
hr_payroll) — aCode-type component’s formula computes the line amount from payroll variables and bracket-table helpers. - Statutory tax-return boxes — a box’s
formulaaggregates other box values.
Any new “the user types a formula” feature should reuse safe_eval rather than introducing a
second evaluator.