Skip to content
Merged
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
97 changes: 96 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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 |
2 changes: 0 additions & 2 deletions fred-mlambda/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +0,0 @@
# Install the baseline fred-oss package
fred-oss==0.66.0
8 changes: 1 addition & 7 deletions fred-mlambda/src/main/fred/mlambda/_count.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
5 changes: 0 additions & 5 deletions fred-mlambda/src/main/fred/mlambda/_rand.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
6 changes: 0 additions & 6 deletions fred-mlambda/src/main/fred/mlambda/_strops.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
5 changes: 0 additions & 5 deletions fred-mlambda/src/main/fred/mlambda/catalog.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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))]:
Expand All @@ -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
171 changes: 143 additions & 28 deletions fred-mlambda/src/main/fred/mlambda/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<funref>[A-Za-z_][A-Za-z0-9_.]*)\s*:\s*(?P<param_line>[^}]*)\}$"
)
# 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]]
Expand All @@ -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
Expand All @@ -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())
Expand Down Expand Up @@ -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,
)

Expand Down
Loading
Loading