From 1b45cde653184753fe167a84751e6abce03dea22 Mon Sep 17 00:00:00 2001 From: rhdzmota Date: Sun, 22 Mar 2026 17:40:57 -0600 Subject: [PATCH 1/6] LAM-5 Update parser to support nested expressions --- fred-mlambda/src/main/fred/mlambda/parser.py | 171 ++++++++++++++++--- 1 file changed, 143 insertions(+), 28 deletions(-) diff --git a/fred-mlambda/src/main/fred/mlambda/parser.py b/fred-mlambda/src/main/fred/mlambda/parser.py index 7efefb4..8c84895 100644 --- a/fred-mlambda/src/main/fred/mlambda/parser.py +++ b/fred-mlambda/src/main/fred/mlambda/parser.py @@ -7,12 +7,12 @@ from fred.mlambda.interface import Arguments, MLambda from fred.mlambda.catalog import MLambdaCatalog -# Matches: ${path.to.function: param_line} -# Group 1 (dotpath): "path.to.function" -# Group 2 (param_line): "arg1,arg2,kwarg1=value1,..." -_MLAMBDA_PATTERN = re.compile( - r"^\$\{\s*(?P[A-Za-z_][A-Za-z0-9_.]*)\s*:\s*(?P[^}]*)\}$" -) +# Matches innermost ${...} — i.e. no $, {, or } inside the braces. +# Used by _resolve_nested to find leaf-level expressions to evaluate first. +_INNER_PATTERN = re.compile(r"\$\{[^${}]*\}") + +# Validates the funref portion: "path.to.function" or "ALIAS" +_FUNREF_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$") # Supported type annotations via the "::" syntax, e.g. "42::int" MLAMBDA_TYPES = Optional[Union[int, float, bool, str]] @@ -25,6 +25,85 @@ } +def _serialize(value: Any) -> str: + """ + Convert an execution result back into a string token that cast() can + handle — used when embedding a nested result into its parent param_line. + + Examples: + None -> "null" + True -> "true" + False -> "false" + 42 -> "42" + 3.14 -> "3.14" + "alice" -> "alice" + """ + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def _extract_outer(string: str) -> tuple[str, str]: + """ + Parse the outermost ${funref: param_line} shell using a brace-depth + counter so that nested '}' inside param_line are handled correctly. + + Returns: + (funref, raw_param_line) + + Raises: + ValueError: if the string is not a valid outer MLambda expression. + """ + s = string.strip() + + if not s.startswith("${"): + raise ValueError( + f"Invalid MLambda expression: {s!r}\n" + "Expected format: ${funref: arg1,arg2,kwarg=value,...}" + ) + + # Walk from position 1 (the '{') counting brace depth + depth = 0 + closing = -1 + for i in range(1, len(s)): + if s[i] == "{": + depth += 1 + elif s[i] == "}": + depth -= 1 + if depth == 0: + closing = i + break + + if closing == -1: + raise ValueError(f"Unmatched '{{' in MLambda expression: {s!r}") + if closing != len(s) - 1: + raise ValueError( + f"Unexpected characters after closing '}}' in: {s!r}" + ) + + inner = s[2:closing] # content between ${ and } + + colon_idx = inner.find(":") + if colon_idx == -1: + raise ValueError( + f"Missing ':' separator in MLambda expression: {s!r}\n" + "Expected format: ${funref: param_line}" + ) + + funref = inner[:colon_idx].strip() + param_line = inner[colon_idx + 1:] + + if not _FUNREF_RE.match(funref): + raise ValueError( + f"Invalid function reference {funref!r}. " + "Must be an identifier or dotted path (e.g. 'MY_ALIAS' or 'path.to.func')." + ) + + return funref, param_line + + @dataclass(frozen=True, slots=True) class MLambdaParser: mlambda: MLambda @@ -45,7 +124,7 @@ def cast(raw: str, disable_autoinfer: bool = False) -> MLAMBDA_TYPES: # Early exit for None values IF autoinfer is enabled if not disable_autoinfer and raw.lower() in ("null", "none", ""): return None - # Check for type annotation + # Check for explicit type annotation if "::" in raw: value_part, _, type_name = raw.rpartition("::") caster = _TYPE_CASTERS.get(type_name.strip()) @@ -98,38 +177,74 @@ def parse_line(cls, param_line: str) -> tuple[list[MLAMBDA_TYPES], dict[str, MLA return args, kwargs + @classmethod + def _resolve_nested(cls, param_line: str) -> str: + """ + Resolve all nested ${...} expressions within a param_line string, + evaluating innermost expressions first and working outward. + + For each iteration, _INNER_PATTERN finds expressions with no nested + braces (guaranteed to be fully flat), evaluates them via from_string, + and serializes the result back as a plain token string. Repeats until + no ${...} remain. + + Example: + "${RAND: alice, bob, carol}" + -> (evaluates RAND) -> "alice" + + "${STROPS: ${RAND: hello, world}, upper}" + -> pass 1: "${RAND: hello, world}" -> "world" + -> param becomes "world, upper" (no more ${) + -> parse_line sees: args=["world"], kwargs={"upper": ...} + ... Wait: that's positional, so: args=["world", "upper"] + """ + while "${" in param_line: + resolved = _INNER_PATTERN.sub( + lambda m: _serialize(cls.from_string(m.group(0)).execute()), + param_line, + ) + if resolved == param_line: + # No substitution made — malformed inner expression + raise ValueError( + f"Could not resolve nested MLambda expression in: {param_line!r}" + ) + param_line = resolved + return param_line + @classmethod def from_string(cls, string: str) -> "MLambdaParser": + """ + Parse a (potentially nested) MLambda expression string. + + Supports expressions at arbitrary nesting depth, e.g.: + ${COUNT: ${RAND: alice, bob, carol}} + ${STROPS: ${RAND: hello, world}, upper} + ${A: ${B: ${C: x}}} + """ payload = string.strip() - match = _MLAMBDA_PATTERN.match(payload) - if not match: - raise ValueError( - f"Invalid MLambda expression: {payload!r}\n" - "Expected format: ${path.to.function: arg1,arg2,kwarg1=value1,...}" - ) - # Get the function reference and param_line from the match - funref: str = match.group("funref") - param_line: str = match.group("param_line") - # Parse the CSV-like parameter line - args, kwargs = cls.parse_line(param_line) - arguments = Arguments( - args=args, - kwargs=kwargs, - ) - # If the function reference is an alias, get the MLambda from the catalog + # Step 1: extract the outer ${funref: raw_param_line} shell + # using a stack-based approach that correctly handles nested '}' + funref, raw_param_line = _extract_outer(payload) + + # Step 2: resolve any nested ${...} within the param_line + resolved_param_line = cls._resolve_nested(raw_param_line) + + # Step 3: parse the now-flat param_line + args, kwargs = cls.parse_line(resolved_param_line) + arguments = Arguments(args=args, kwargs=kwargs) + + # Step 4: resolve the function reference if "." not in funref: + # Bare alias — look up in catalog / settings return cls( mlambda=MLambdaCatalog.get_or_create(funref, fail=True), arguments=arguments, ) - # Split "path.to.function" -> import_pattern="path.to", fname="function" + # Dotted path — construct MLambda directly import_pattern, fname = funref.rsplit(".", 1) return cls( - mlambda=MLambda( - name=fname, - import_pattern=import_pattern - ), + mlambda=MLambda(name=fname, import_pattern=import_pattern), arguments=arguments, ) From dab046f4caef867b06a4d7ec2c36862edabbb991 Mon Sep 17 00:00:00 2001 From: rhdzmota Date: Sun, 22 Mar 2026 17:41:16 -0600 Subject: [PATCH 2/6] LAM-5 Update minor version --- fred-mlambda/src/main/fred/mlambda/version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fred-mlambda/src/main/fred/mlambda/version b/fred-mlambda/src/main/fred/mlambda/version index 341cf11..9325c3c 100644 --- a/fred-mlambda/src/main/fred/mlambda/version +++ b/fred-mlambda/src/main/fred/mlambda/version @@ -1 +1 @@ -0.2.0 \ No newline at end of file +0.3.0 \ No newline at end of file From fed4cbd0da75cceebbad34dfa59141ea4d9911dc Mon Sep 17 00:00:00 2001 From: rhdzmota Date: Sun, 22 Mar 2026 17:41:56 -0600 Subject: [PATCH 3/6] LAM-5 Include new test cases for the parser --- .../test_fred/test_mlambda/test_parser.py | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/fred-mlambda/src/test/test_fred/test_mlambda/test_parser.py b/fred-mlambda/src/test/test_fred/test_mlambda/test_parser.py index 5655e84..14b9b17 100644 --- a/fred-mlambda/src/test/test_fred/test_mlambda/test_parser.py +++ b/fred-mlambda/src/test/test_fred/test_mlambda/test_parser.py @@ -136,3 +136,104 @@ def test_execute_runs_function(self): expr = "${math.factorial: 6}" parsed = MLambdaParser.from_string(expr) assert parsed.execute() == 720 + + +# --------------------------------------------------------------------------- +# _extract_outer() and _serialize() — internal helpers +# --------------------------------------------------------------------------- + +from fred.mlambda.parser import _extract_outer, _serialize + + +class TestExtractOuter: + def test_flat_expression(self): + funref, param_line = _extract_outer("${math.factorial: 5}") + assert funref == "math.factorial" + assert param_line.strip() == "5" + + def test_nested_expression_outer_only(self): + funref, param_line = _extract_outer("${count: ${RAND: alice, bob}}") + assert funref == "count" + assert "${RAND: alice, bob}" in param_line + + def test_missing_colon_raises(self): + with pytest.raises(ValueError, match="Missing ':'"): + _extract_outer("${RAND}") + + def test_unmatched_brace_raises(self): + with pytest.raises(ValueError, match="Unmatched"): + _extract_outer("${RAND: alice") + + def test_trailing_chars_raises(self): + with pytest.raises(ValueError, match="Unexpected characters"): + _extract_outer("${RAND: alice} extra") + + def test_invalid_funref_raises(self): + with pytest.raises(ValueError, match="Invalid function reference"): + _extract_outer("${123bad: x}") + + +class TestSerialize: + def test_none(self): + assert _serialize(None) == "null" + + def test_true(self): + assert _serialize(True) == "true" + + def test_false(self): + assert _serialize(False) == "false" + + def test_int(self): + assert _serialize(42) == "42" + + def test_float(self): + assert _serialize(3.14) == "3.14" + + def test_string(self): + assert _serialize("alice") == "alice" + + +# --------------------------------------------------------------------------- +# Nested expression integration tests +# --------------------------------------------------------------------------- + +_POPULATION = ("alice", "bob", "carol") + + +class TestNested: + def test_one_level_nested_execute(self): + # RAND picks one name; COUNT returns its length + expr = "${count: ${RAND: alice, bob, carol}}" + result = MLambdaParser.from_string(expr).execute() + assert isinstance(result, int) + assert result in {len(n) for n in _POPULATION} + + def test_two_level_nested_execute(self): + # ${COUNT: ${STROPS: hello, upper}} -> COUNT("HELLO") -> 5 + expr = "${count: ${STROPS: hello, upper}}" + result = MLambdaParser.from_string(expr).execute() + assert result == 5 + + def test_sibling_nested_args(self): + # ${STROPS: ${RAND: hello, world}, upper} -> "HELLO" or "WORLD" + expr = "${STROPS: ${RAND: hello, world}, upper}" + result = MLambdaParser.from_string(expr).execute() + assert result in ("HELLO", "WORLD") + + def test_three_levels_deep(self): + # RAND -> "hi" or "hello" -> upper -> "HI" or "HELLO" -> count -> 2 or 5 + expr = "${count: ${STROPS: ${RAND: hi, hello}, upper}}" + result = MLambdaParser.from_string(expr).execute() + assert result in (2, 5) + + def test_nested_does_not_break_flat(self): + assert MLambdaParser.from_string("${count: hello}").execute() == 5 + assert MLambdaParser.from_string("${math.factorial: 5}").execute() == 120 + + def test_resolve_nested_flat_passthrough(self): + flat = "alice, bob" + assert MLambdaParser._resolve_nested(flat) == flat + + def test_resolve_nested_replaces_inner(self): + result = MLambdaParser._resolve_nested("${STROPS: hello, upper}") + assert result == "HELLO" From b751c2026fe2654040462e4f4771b33aa54cb543 Mon Sep 17 00:00:00 2001 From: rhdzmota Date: Sun, 22 Mar 2026 17:42:12 -0600 Subject: [PATCH 4/6] LAM-5 Update REAMDE file with latest documentation --- README.md | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f6f4483..b84be1d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,97 @@ # fred-mlambda -FRED Micro-Lambda DSL + +`FRED Micro-Lambda DSL` allows defining and execution small functions in a declarative way. + +For examples: + +``` +${COUNT: example} +``` +* Output: `7` + +``` +${RAND: alice, bob, carol} +``` +* Output: `alice` or `bob` or `carol` + +Nested expressions are also supported: + +``` +${COUNT: ${RAND: alice, bob}} +``` +* Output: `3` or `5` + +## Installation + +```bash +$ pip install fred-mlambda +``` + +## How this works + +The main entry point is the `MLambdaParser` class, which can be used to parse and execute a micro-lambda expression. A expression is defined as a string that follows the format `${path.to.function: arg1,arg2,kwarg1=value1,...}`. The function can be referenced by its full import path or by an alias defined in the environment variables. + +Consider the following pattern: + +``` +${path.to.function: arg1, arg2, ..., key1=val1, key2=val2, ...} +``` + +The parser automatically identifies the following components: +* `import_path`: The import path to the function (e.g., `path.to`) +* `function_name`: The name of the function to execute (e.g., `function`) +* `args`: Positional arguments to pass to the function +* `kwargs`: Keyword arguments to pass to the function + +Such that the following is possible in general terms: + +```python +from path.to import function + +result = function(*args, **kwargs) +``` + +We also allow providing aliases to faciliate quick reference to the functions. An alias can be defined in the following ways: +* Static definition via the catalog (i.e., `MLambdaCatalog` available at `fred.mlambda.catalog`) +* Dynamic definition via the environment variable `FRED_MLAMBDA_ALIASES` in the following format: `alias=import_path`. Multiple aliases can be defined by using a separator, which by default is `;`. + +## Examples + +```python +from fred.mlambda.parser import MLambdaParser + +# Parse and execute a micro-lambda expression by defining the full import path +out1 = MLambdaParser.from_string("${fred.mlambda._count.count: example}").execute() +print(out1) + +# Parse and execute a micro-lambda expression by referencing an existing alias +out2 = MLambdaParser.from_string("${rand: alice, bob, carol}").execute() +print(out2) + +# Parse and execute a micro-lambda expression with nested expressions +out3 = MLambdaParser.from_string("${COUNT: ${RAND: alice, bob}}").execute() +print(out3) +``` + +## Type Coercion + +The implementation is designed such that the data types will be inferred automatically. However, it is possible to explicitly define the type of an argument by using the `::` syntax. For example: + +``` +${count: example::str} +``` +* Output: 7 + +``` +${rand: alice, bob, carol, k=2::int} +``` +* Output example: `['alice', 'carol']` + +The available data-types are: + +| Type | Description | +|------|-------------| +| `int` | Integer | +| `float` | Float | +| `bool` | Boolean | +| `str` | String | From 593f0910b1148f4b5a81843185035aa1c1039bfc Mon Sep 17 00:00:00 2001 From: rhdzmota Date: Sun, 22 Mar 2026 17:56:22 -0600 Subject: [PATCH 5/6] LAM-5 Fix stylecheck --- fred-mlambda/src/test/test_fred/test_mlambda/test_parser.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/fred-mlambda/src/test/test_fred/test_mlambda/test_parser.py b/fred-mlambda/src/test/test_fred/test_mlambda/test_parser.py index 14b9b17..1d13d06 100644 --- a/fred-mlambda/src/test/test_fred/test_mlambda/test_parser.py +++ b/fred-mlambda/src/test/test_fred/test_mlambda/test_parser.py @@ -2,6 +2,7 @@ from fred.mlambda.parser import MLambdaParser from fred.mlambda.interface import Arguments, MLambda +from fred.mlambda.parser import _extract_outer, _serialize # --------------------------------------------------------------------------- @@ -142,8 +143,6 @@ def test_execute_runs_function(self): # _extract_outer() and _serialize() — internal helpers # --------------------------------------------------------------------------- -from fred.mlambda.parser import _extract_outer, _serialize - class TestExtractOuter: def test_flat_expression(self): From 0ac511248ee89e69e34ea96bb0c3136fe114d7d3 Mon Sep 17 00:00:00 2001 From: rhdzmota Date: Sun, 22 Mar 2026 17:57:21 -0600 Subject: [PATCH 6/6] LAM-5 Remove fred-oss dependency --- fred-mlambda/requirements.txt | 2 - fred-mlambda/src/main/fred/mlambda/_count.py | 8 +--- fred-mlambda/src/main/fred/mlambda/_rand.py | 5 --- fred-mlambda/src/main/fred/mlambda/_strops.py | 6 --- fred-mlambda/src/main/fred/mlambda/catalog.py | 5 --- .../src/main/fred/mlambda/settings.py | 10 ++--- fred-mlambda/src/main/fred/mlambda/version.py | 43 ++++++++++++++++--- 7 files changed, 43 insertions(+), 36 deletions(-) diff --git a/fred-mlambda/requirements.txt b/fred-mlambda/requirements.txt index 1a3c8c5..e69de29 100644 --- a/fred-mlambda/requirements.txt +++ b/fred-mlambda/requirements.txt @@ -1,2 +0,0 @@ -# Install the baseline fred-oss package -fred-oss==0.66.0 diff --git a/fred-mlambda/src/main/fred/mlambda/_count.py b/fred-mlambda/src/main/fred/mlambda/_count.py index ce270dc..625c895 100644 --- a/fred-mlambda/src/main/fred/mlambda/_count.py +++ b/fred-mlambda/src/main/fred/mlambda/_count.py @@ -1,9 +1,4 @@ -from typing import Any, Optional - -from fred.settings import logger_manager - - -logger = logger_manager.get_logger(__name__) +from typing import Any def count(value: Any, fail: bool = False) -> int: @@ -13,7 +8,6 @@ def count(value: Any, fail: bool = False) -> int: if isinstance(value, Sized): return len(value) error = f"Unknown type: {type(value)}" - logger.warning(error) if fail: raise ValueError(error) return 0 diff --git a/fred-mlambda/src/main/fred/mlambda/_rand.py b/fred-mlambda/src/main/fred/mlambda/_rand.py index c64d6c5..2ebc478 100644 --- a/fred-mlambda/src/main/fred/mlambda/_rand.py +++ b/fred-mlambda/src/main/fred/mlambda/_rand.py @@ -1,11 +1,6 @@ import random from typing import Any -from fred.settings import logger_manager - - -logger = logger_manager.get_logger(__name__) - def rand(*args, k=1, disable_autoflat: bool = False) -> list[Any]: if not disable_autoflat and k == 1: diff --git a/fred-mlambda/src/main/fred/mlambda/_strops.py b/fred-mlambda/src/main/fred/mlambda/_strops.py index c8aefdc..90bc82a 100644 --- a/fred-mlambda/src/main/fred/mlambda/_strops.py +++ b/fred-mlambda/src/main/fred/mlambda/_strops.py @@ -1,10 +1,5 @@ from typing import Optional -from fred.settings import logger_manager - - -logger = logger_manager.get_logger(__name__) - def strops(string: str, ops: str, fail: bool = False) -> Optional[str]: match ops: @@ -26,5 +21,4 @@ def strops(string: str, ops: str, fail: bool = False) -> Optional[str]: msg = f"Unknown operation: {ops}" if fail: raise ValueError(msg) - logger.warning(msg) return None diff --git a/fred-mlambda/src/main/fred/mlambda/catalog.py b/fred-mlambda/src/main/fred/mlambda/catalog.py index a7c3747..e767e90 100644 --- a/fred-mlambda/src/main/fred/mlambda/catalog.py +++ b/fred-mlambda/src/main/fred/mlambda/catalog.py @@ -1,12 +1,9 @@ from enum import Enum from typing import Optional -from fred.settings import logger_manager from fred.mlambda.settings import FRED_MLAMBDA_PARSED_ALIASES from fred.mlambda.interface import MLambda -logger = logger_manager.get_logger(__name__) - class MLambdaCatalog(Enum): STROPS = MLambda( @@ -41,7 +38,6 @@ def get_or_create(cls, target: str, fail: bool = False) -> Optional[MLambda]: @classmethod def find(cls, alias: str, fail: bool = False, disable_variants: bool = False) -> Optional[MLambda]: if "." in alias: - logger.warning("The target is a dotpath, not an alias. Use MLambdaParser.from_string() instead.") return None variants: list[str] = [alias, alias.upper(), alias.lower()] for variant in variants[:(1 if disable_variants else len(variants))]: @@ -56,7 +52,6 @@ def find(cls, alias: str, fail: bool = False, disable_variants: bool = False) -> elif variant in cls.keys(): return cls[variant].value error = f"Unknown MLambda: {alias}" - logger.warning(error) if fail: raise ValueError(error) return None diff --git a/fred-mlambda/src/main/fred/mlambda/settings.py b/fred-mlambda/src/main/fred/mlambda/settings.py index f2431b5..48e5a8c 100644 --- a/fred-mlambda/src/main/fred/mlambda/settings.py +++ b/fred-mlambda/src/main/fred/mlambda/settings.py @@ -1,18 +1,16 @@ import os -from fred.settings import get_environ_variable - -FRED_MLAMBDA_ALIASES_SEP = get_environ_variable( +FRED_MLAMBDA_ALIASES_SEP = os.environ.get( "FRED_MLAMBDA_ALIASES_SEP", - default=";" + ";", ) FRED_MLAMBDA_ALIASES = [ alias - for line in get_environ_variable( + for line in os.environ.get( "FRED_MLAMBDA_ALIASES", - default="", + "", ).split(FRED_MLAMBDA_ALIASES_SEP) if "=" in line and (alias := line.strip().split("=")) ] diff --git a/fred-mlambda/src/main/fred/mlambda/version.py b/fred-mlambda/src/main/fred/mlambda/version.py index 9687f09..b45d262 100644 --- a/fred-mlambda/src/main/fred/mlambda/version.py +++ b/fred-mlambda/src/main/fred/mlambda/version.py @@ -1,9 +1,42 @@ import os +from dataclasses import dataclass -from fred.version import Version +@dataclass(frozen=True, slots=True) +class Version: + name: str + value: str -version = Version.from_path( - name="fred.mlambda", - dirpath=os.path.dirname(__file__) -) + def components(self, as_int: bool = False) -> list: + return [int(val) if as_int else val for val in self.value.split(".")] + + @property + def major(self) -> int: + component, *_ = self.components(as_int=True) + return component + + @property + def minor(self) -> int: + _, component, *_ = self.components(as_int=True) + return component + + @property + def patch(self) -> int: + *_, component = self.components(as_int=True) + return component + + @classmethod + def from_path(cls, dirpath: str, name: str): + for file in os.listdir(dirpath): + if file.lower().endswith("version"): + filepath = os.path.join(dirpath, file) + break + else: + raise ValueError("Version file not found for package name: " + name) + + with open(filepath, "r") as version_file: + version_value = version_file.readline().strip() # TODO: Validate version pattern via regex + return cls(name=name, value=version_value) + + +version = Version.from_path(name="fred.mlambda", dirpath=os.path.dirname(__file__))