Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@

from ._errors import DeclarativeWorkflowError
from ._powerfx_functions import (
_after_trailing_trivia, # pyright: ignore[reportPrivateUsage]
_iter_powerfx_expression_indices, # pyright: ignore[reportPrivateUsage]
_skip_powerfx_opaque_token, # pyright: ignore[reportPrivateUsage]
whole_call_args,
)
from ._powerfx_limits import _PowerFxStateBudget, _validate_powerfx_state # pyright: ignore[reportPrivateUsage]
Expand All @@ -70,6 +72,76 @@


_ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)")
_POWERFX_IDENTIFIER_RE = re.compile(r"[^\W\d]\w*")


def _read_powerfx_identifier(formula: str, start: int) -> tuple[str, int] | None:
"""Read a plain or single-quoted PowerFx identifier at ``start``.

Returns the identifier name and the index after it, or ``None`` when no
identifier starts there.
"""
if formula.startswith("'", start):
end = _skip_powerfx_opaque_token(formula, start)
if end - start < 3 or formula[end - 1] != "'":
return None
return formula[start + 1 : end - 1].replace("''", "'"), end
match = _POWERFX_IDENTIFIER_RE.match(formula, start)
return (match.group(), match.end()) if match else None


def _read_member_access(formula: str, start: int) -> tuple[str, int] | None:
"""Read ``.<identifier>`` at ``start``, allowing whitespace and comments around the dot."""
dot = _after_trailing_trivia(formula, start)
if not formula.startswith(".", dot):
return None
return _read_powerfx_identifier(formula, _after_trailing_trivia(formula, dot + 1))


def _referenced_input_names(formula: str) -> set[str]:
"""Return the field names of ``inputs.<name>`` / ``Workflow.Inputs.<name>`` references in code.

String literals and comments are skipped, so input-like text inside them is not a reference.
"""
names: set[str] = set()
for cursor in _iter_powerfx_expression_indices(formula):
if cursor > 0 and (formula[cursor - 1].isalnum() or formula[cursor - 1] in "_."):
continue
root = _read_powerfx_identifier(formula, cursor)
if root is None:
continue
root_name, after_root = root
if root_name == "Workflow":
member = _read_member_access(formula, after_root)
if member is None or member[0] != "Inputs":
continue
after_root = member[1]
elif root_name != "inputs":
continue
field_reference = _read_member_access(formula, after_root)
if field_reference is not None:
names.add(field_reference[0])
return names


def _bind_missing_input_references(formula: str, symbols: dict[str, Any]) -> dict[str, Any]:
"""Expose inputs the formula references but the caller did not supply as blank.

PowerFx rejects ``inputs.name`` at compile time when ``name`` is not a field of the
inputs record, which turns the whole expression into ``None`` and defeats defaults
such as ``If(IsBlank(inputs.name), "World", inputs.name)``. Binding the missing
fields to blank only for this evaluation makes them behave like an explicit ``None``
input without changing the workflow state.
"""
inputs = symbols.get("inputs")
if not isinstance(inputs, Mapping):
return symbols
input_values = cast(Mapping[str, Any], inputs)
missing = {name for name in _referenced_input_names(formula) if name not in input_values}
if not missing:
return symbols
bound_inputs: dict[str, Any] = {**input_values, **dict.fromkeys(sorted(missing))}
return {**symbols, "Workflow": {**symbols["Workflow"], "Inputs": bound_inputs}, "inputs": bound_inputs}


@dataclass(frozen=True)
Expand Down Expand Up @@ -605,7 +677,7 @@ def eval(self, expression: str) -> Any:
f"Install dotnet and the powerfx package for full PowerFx support."
)

symbols = self._to_powerfx_symbols()
symbols = _bind_missing_input_references(formula, self._to_powerfx_symbols())
# Use setlocale(category) query form so we can restore the exact prior value.
# getlocale() returns a normalized tuple and is not always a lossless
# round-trip for setlocale across platforms/locales.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,44 @@ async def test_if_with_isblank(self, mock_state):
result = state.eval('=If(IsBlank(Workflow.Inputs.name), "World", Workflow.Inputs.name)')
assert result == "Alice"

@pytest.mark.parametrize(
"expression",
[
'=If(IsBlank(inputs.name), "World", inputs.name)',
'=If(IsBlank(Workflow.Inputs.name), "World", Workflow.Inputs.name)',
"=If(IsBlank(inputs.'name'), \"World\", inputs.'name')",
"=If(IsBlank(Workflow.Inputs.'name'), \"World\", Workflow.Inputs.'name')",
'=If(IsBlank(inputs . name), "World", inputs /* field */ . name)',
'=If(IsBlank(Workflow . Inputs . name), "World", Workflow // root\n.Inputs.name)',
'=$"{If(IsBlank(inputs.name), "World", inputs.name)}"',
],
)
async def test_if_with_isblank_when_input_is_missing(self, mock_state, expression):
"""Test that an input the caller did not supply is blank, so IsBlank defaults apply."""
state = DeclarativeWorkflowState(mock_state)
state.initialize({"other": 1})

assert state.eval(expression) == "World"
assert state.eval("=IsBlank(inputs.name)") is True
# The missing input is only bound for evaluation, not written to state.
assert "name" not in state.get_state_data()["Inputs"]

@pytest.mark.parametrize(
"expression",
[
"=inputs /* inputs.name */",
"=inputs // Workflow.Inputs.name",
'=If(Len("inputs.name") > 0, inputs, inputs)',
'=If(Len($"Workflow.Inputs.name") > 0, inputs, inputs)',
],
)
async def test_input_references_in_comments_and_literals_are_not_bound(self, mock_state, expression):
"""Test that input-like text in comments and string literals does not add blank inputs."""
state = DeclarativeWorkflowState(mock_state)
state.initialize({"other": 1})

assert state.eval(expression) == {"other": 1}

async def test_not_function(self, mock_state):
"""Test Not function."""
state = DeclarativeWorkflowState(mock_state)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ async def main() -> None:
# Run with a custom name
print("\nRunning with custom name 'Alice':")
result = await workflow.run({"name": "Alice"})
for output in result.get_outputs():
print(f" Output: {output}")
print("\n" + "-" * 40)
print("Workflow completed!")

Expand Down
Loading