Evaluate a simple expression against the namespace. Supports: - Dot-path access: ``steps.specify.output.file`` - Comparisons: ``==``, ``!=``, ``>``, ``<``, ``>=``, ``<=`` - Boolean operators: ``and``, ``or``, ``not`` - ``in``, ``not in`` - Pipe filters: ``| default('...')``,
(expr: str, namespace: dict[str, Any])
| 247 | |
| 248 | |
| 249 | def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: |
| 250 | """Evaluate a simple expression against the namespace. |
| 251 | |
| 252 | Supports: |
| 253 | - Dot-path access: ``steps.specify.output.file`` |
| 254 | - Comparisons: ``==``, ``!=``, ``>``, ``<``, ``>=``, ``<=`` |
| 255 | - Boolean operators: ``and``, ``or``, ``not`` |
| 256 | - ``in``, ``not in`` |
| 257 | - Pipe filters: ``| default('...')``, ``| join(', ')``, ``| contains('...')``, ``| from_json``, ``| map('...')`` |
| 258 | - String and numeric literals |
| 259 | """ |
| 260 | expr = expr.strip() |
| 261 | |
| 262 | # String literal — only when the WHOLE expression is one quoted string, |
| 263 | # i.e. the opening quote's matching close is the final character. Checking |
| 264 | # startswith/endswith alone would also grab `'a' == 'b'` and strip it to the |
| 265 | # garbage `a' == 'b`; a genuine single literal short-circuits here so quoted |
| 266 | # strings containing `|` or operator keywords are not mis-parsed downstream. |
| 267 | if expr[:1] in ("'", '"') and expr.find(expr[0], 1) == len(expr) - 1: |
| 268 | return expr[1:-1] |
| 269 | |
| 270 | # Handle pipe filters. Detect the pipe at the top level only, so a literal |
| 271 | # '|' inside a quoted operand (e.g. `inputs.x == 'a|b'`) or nested brackets is |
| 272 | # not mistaken for a filter separator — mirroring the operator parsing below. |
| 273 | pipe_idx = _find_top_level(expr, "|") |
| 274 | if pipe_idx != -1: |
| 275 | value = _evaluate_simple_expression(expr[:pipe_idx].strip(), namespace) |
| 276 | filter_expr = expr[pipe_idx + 1:].strip() |
| 277 | |
| 278 | # `from_json` is strict: it takes no arguments and tolerates no |
| 279 | # trailing tokens. Match on the leading filter name and require the |
| 280 | # whole filter to be exactly `from_json`, so every mis-wired form |
| 281 | # (`from_json()`, `from_json('x')`, `from_json)`, `from_json extra`) |
| 282 | # fails loudly instead of silently falling through to the |
| 283 | # unknown-filter path and returning the unparsed value. (filter_expr |
| 284 | # is already stripped above.) |
| 285 | leading = re.match(r"\w+", filter_expr) |
| 286 | if leading and leading.group(0) == "from_json": |
| 287 | if filter_expr != "from_json": |
| 288 | raise ValueError( |
| 289 | "from_json: expected '| from_json' with no arguments or " |
| 290 | f"trailing tokens, got '| {filter_expr}'" |
| 291 | ) |
| 292 | return _filter_from_json(value) |
| 293 | |
| 294 | # Parse filter name and argument |
| 295 | filter_match = re.match(r"(\w+)\((.+)\)", filter_expr) |
| 296 | if filter_match: |
| 297 | fname = filter_match.group(1) |
| 298 | farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace) |
| 299 | if fname == "default": |
| 300 | return _filter_default(value, farg) |
| 301 | if fname == "join": |
| 302 | return _filter_join(value, farg) |
| 303 | if fname == "map": |
| 304 | return _filter_map(value, farg) |
| 305 | if fname == "contains": |
| 306 | return _filter_contains(value, farg) |
no test coverage detected