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
19 changes: 19 additions & 0 deletions fred-mlambda/src/main/fred/mlambda/_count.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from typing import Any, Optional

from fred.settings import logger_manager


logger = logger_manager.get_logger(__name__)


def count(value: Any, fail: bool = False) -> int:
if hasattr(value, "__len__"):
return len(value)
from collections.abc import Sized
if isinstance(value, Sized):
return len(value)
error = f"Unknown type: {type(value)}"
logger.warning(error)
if fail:
raise ValueError(error)
return 0
17 changes: 17 additions & 0 deletions fred-mlambda/src/main/fred/mlambda/_rand.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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:
out, *_ = rand(*args, k=k, disable_autoflat=True)
return out
return random.choices(
population=args,
k=k,
)
30 changes: 30 additions & 0 deletions fred-mlambda/src/main/fred/mlambda/_strops.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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:
case "lower":
return string.lower()
case "upper":
return string.upper()
case "title":
return string.title()
case "capitalize":
return string.capitalize()
case "strip":
return string.strip()
case "lstrip":
return string.lstrip()
case "rstrip":
return string.rstrip()
case _:
msg = f"Unknown operation: {ops}"
if fail:
raise ValueError(msg)
logger.warning(msg)
return None
62 changes: 62 additions & 0 deletions fred-mlambda/src/main/fred/mlambda/catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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(
name="strops",
import_pattern="fred.mlambda._strops",
)
RAND = MLambda(
name="rand",
import_pattern="fred.mlambda._rand",
)

@classmethod
def keys(cls) -> list[str]:
return [
mem.name
for mem in cls
]

@classmethod
def get_or_create(cls, target: str, fail: bool = False) -> Optional[MLambda]:
if "." in target:
*import_path, function_name = target.split(".")
return MLambda(
name=function_name,
import_pattern=".".join(import_path),
)
return cls.find(
alias=target,
fail=fail,
)

@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))]:
# Check if the target is an alias registered in the environment or defaults
if variant in FRED_MLAMBDA_PARSED_ALIASES:
*import_path, function_name = FRED_MLAMBDA_PARSED_ALIASES[variant].split(".")
return MLambda(
name=function_name,
import_pattern=".".join(import_path),
)
# Check if the alias is a registered MLambdaCatalog Enum
elif variant in cls.keys():
return cls[variant].value
error = f"Unknown MLambda: {alias}"
logger.warning(error)
if fail:
raise ValueError(error)
return None
36 changes: 36 additions & 0 deletions fred-mlambda/src/main/fred/mlambda/interface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from dataclasses import dataclass
from typing import Callable


@dataclass(frozen=True, slots=True)
class Arguments:
args: list
kwargs: dict


@dataclass(frozen=True, slots=True)
class MLambda:
name: str
import_pattern: str

@property
def function(self) -> Callable:
import importlib
# Import the module
module = importlib.import_module(self.import_pattern)
# Get the function from the module
if not (fn := getattr(module, self.name, None)):
raise ValueError(f"Function {self.name} not found in module {self.import_pattern}")
return fn

def run(self, arguments: Arguments):
return self.function(*arguments.args, **arguments.kwargs)

def __call__(self, *args, **kwargs):
arguments = Arguments(
args=args,
kwargs=kwargs
)
return self.run(
arguments=arguments
)
137 changes: 137 additions & 0 deletions fred-mlambda/src/main/fred/mlambda/parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import re
import io
import csv
from dataclasses import dataclass
from typing import Any, Callable, Union, Optional

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>[^}]*)\}$"
)

# Supported type annotations via the "::" syntax, e.g. "42::int"
MLAMBDA_TYPES = Optional[Union[int, float, bool, str]]
_NULL_VALUES = ("null", "none", "")
_TYPE_CASTERS: dict[str, Callable] = {
"int": int,
"float": float,
"bool": lambda v: v.strip().lower() not in ("false", "0", "no", ""),
"str": str,
}


@dataclass(frozen=True, slots=True)
class MLambdaParser:
mlambda: MLambda
arguments: Arguments

@staticmethod
def cast(raw: str, disable_autoinfer: bool = False) -> MLAMBDA_TYPES:
"""
Parse a raw token string, applying an optional '::type' suffix.

Examples:
"hello" -> "hello" (str)
"42::int" -> 42 (int)
"3.14::float" -> 3.14 (float)
"true::bool" -> True (bool)
"""
raw = raw.strip()
# 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
if "::" in raw:
value_part, _, type_name = raw.rpartition("::")
caster = _TYPE_CASTERS.get(type_name.strip())
if caster is None:
raise ValueError(
f"Unknown type annotation '{type_name}'. "
f"Supported: {list(_TYPE_CASTERS)}"
)
return caster(value_part.strip())
if not disable_autoinfer and raw.isdigit():
return int(raw)
if not disable_autoinfer and raw.replace(".", "", 1).isdigit():
return float(raw)
if not disable_autoinfer and raw.lower() in ("true", "false"):
return raw.lower() == "true"
return raw

@classmethod
def parse_line(cls, param_line: str) -> tuple[list[MLAMBDA_TYPES], dict[str, MLAMBDA_TYPES]]:
"""
Split a CSV-like parameter string into positional args and keyword args.

The CSV reader handles:
- Comma-separated tokens
- Quoted values (e.g. "hello, world" treated as a single token)

Each token is classified as:
- kwarg if it contains '=' (first '=' is the separator)
- positional arg otherwise

Type coercion via '::type' is applied to every value.
"""
args: list[MLAMBDA_TYPES] = []
kwargs: dict[str, MLAMBDA_TYPES] = {}

if not param_line.strip():
return args, kwargs

reader = csv.reader(io.StringIO(param_line), skipinitialspace=True)
for row in reader:
for token in row:
token = token.strip()
if not token:
continue
if "=" in token:
key, _, raw_value = token.partition("=")
kwargs[key.strip()] = cls.cast(raw_value)
else:
args.append(cls.cast(token))

return args, kwargs

@classmethod
def from_string(cls, string: str) -> "MLambdaParser":
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
if "." not in funref:
return cls(
mlambda=MLambdaCatalog.get_or_create(funref, fail=True),
arguments=arguments,
)
# Split "path.to.function" -> import_pattern="path.to", fname="function"
import_pattern, fname = funref.rsplit(".", 1)
return cls(
mlambda=MLambda(
name=fname,
import_pattern=import_pattern
),
arguments=arguments,
)

def execute(self) -> Any:
return self.mlambda.run(arguments=self.arguments)
26 changes: 26 additions & 0 deletions fred-mlambda/src/main/fred/mlambda/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import os

from fred.settings import get_environ_variable


FRED_MLAMBDA_ALIASES_SEP = get_environ_variable(
"FRED_MLAMBDA_ALIASES_SEP",
default=";"
)

FRED_MLAMBDA_ALIASES = [
alias
for line in get_environ_variable(
"FRED_MLAMBDA_ALIASES",
default="",
).split(FRED_MLAMBDA_ALIASES_SEP)
if "=" in line and (alias := line.strip().split("="))
]

FRED_MLAMBDA_PARSED_ALIASES = {
"count": "fred.mlambda._count.count",
**{
key: val
for key, val in FRED_MLAMBDA_ALIASES
}
}
2 changes: 1 addition & 1 deletion fred-mlambda/src/main/fred/mlambda/version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.0
0.2.0
Loading
Loading