From 0685b94935c10e85c71889d0d4f7d3f23f4f8fe1 Mon Sep 17 00:00:00 2001 From: rhdzmota Date: Sun, 22 Mar 2026 16:58:24 -0600 Subject: [PATCH 1/7] LAM-3 Allow setting aliases via env.vars (to faciliate configuration) --- .../src/main/fred/mlambda/settings.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 fred-mlambda/src/main/fred/mlambda/settings.py diff --git a/fred-mlambda/src/main/fred/mlambda/settings.py b/fred-mlambda/src/main/fred/mlambda/settings.py new file mode 100644 index 0000000..f2431b5 --- /dev/null +++ b/fred-mlambda/src/main/fred/mlambda/settings.py @@ -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 + } +} From d59b01894c522b639d99aee6217fc4905d675a12 Mon Sep 17 00:00:00 2001 From: rhdzmota Date: Sun, 22 Mar 2026 16:59:18 -0600 Subject: [PATCH 2/7] LAM-3 Create the core interface for the MLambda and Arguments entities --- .../src/main/fred/mlambda/interface.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 fred-mlambda/src/main/fred/mlambda/interface.py diff --git a/fred-mlambda/src/main/fred/mlambda/interface.py b/fred-mlambda/src/main/fred/mlambda/interface.py new file mode 100644 index 0000000..678d0a2 --- /dev/null +++ b/fred-mlambda/src/main/fred/mlambda/interface.py @@ -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 + ) From 4116cf1a4fd49b0bef2c4529772ffb128aca5192 Mon Sep 17 00:00:00 2001 From: rhdzmota Date: Sun, 22 Mar 2026 16:59:55 -0600 Subject: [PATCH 3/7] LAM-3 Create 3 example functions --- fred-mlambda/src/main/fred/mlambda/_count.py | 19 ++++++++++++ fred-mlambda/src/main/fred/mlambda/_rand.py | 17 +++++++++++ fred-mlambda/src/main/fred/mlambda/_strops.py | 30 +++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 fred-mlambda/src/main/fred/mlambda/_count.py create mode 100644 fred-mlambda/src/main/fred/mlambda/_rand.py create mode 100644 fred-mlambda/src/main/fred/mlambda/_strops.py diff --git a/fred-mlambda/src/main/fred/mlambda/_count.py b/fred-mlambda/src/main/fred/mlambda/_count.py new file mode 100644 index 0000000..ce270dc --- /dev/null +++ b/fred-mlambda/src/main/fred/mlambda/_count.py @@ -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 diff --git a/fred-mlambda/src/main/fred/mlambda/_rand.py b/fred-mlambda/src/main/fred/mlambda/_rand.py new file mode 100644 index 0000000..c64d6c5 --- /dev/null +++ b/fred-mlambda/src/main/fred/mlambda/_rand.py @@ -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, + ) diff --git a/fred-mlambda/src/main/fred/mlambda/_strops.py b/fred-mlambda/src/main/fred/mlambda/_strops.py new file mode 100644 index 0000000..c8aefdc --- /dev/null +++ b/fred-mlambda/src/main/fred/mlambda/_strops.py @@ -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 From bbdb44df9ab8ab04cf97fc054e84d3aa7a8099a9 Mon Sep 17 00:00:00 2001 From: rhdzmota Date: Sun, 22 Mar 2026 17:01:32 -0600 Subject: [PATCH 4/7] LAM-3 Create a catalog to facilitate referencing the functions by name (alias) and include constructs such as find/get_or_create to faciliatite usage --- fred-mlambda/src/main/fred/mlambda/catalog.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 fred-mlambda/src/main/fred/mlambda/catalog.py diff --git a/fred-mlambda/src/main/fred/mlambda/catalog.py b/fred-mlambda/src/main/fred/mlambda/catalog.py new file mode 100644 index 0000000..a7c3747 --- /dev/null +++ b/fred-mlambda/src/main/fred/mlambda/catalog.py @@ -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 From d3f4394965e8966967f3c724436bd4a4cdb9d393 Mon Sep 17 00:00:00 2001 From: rhdzmota Date: Sun, 22 Mar 2026 17:02:07 -0600 Subject: [PATCH 5/7] LAM-3 Create a simple parser that takes a string and extracts the function name and arguments --- fred-mlambda/src/main/fred/mlambda/parser.py | 137 +++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 fred-mlambda/src/main/fred/mlambda/parser.py diff --git a/fred-mlambda/src/main/fred/mlambda/parser.py b/fred-mlambda/src/main/fred/mlambda/parser.py new file mode 100644 index 0000000..7efefb4 --- /dev/null +++ b/fred-mlambda/src/main/fred/mlambda/parser.py @@ -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[A-Za-z_][A-Za-z0-9_.]*)\s*:\s*(?P[^}]*)\}$" +) + +# 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) From 5736f5a030e248774fc2470ebc829a26ed8d473a Mon Sep 17 00:00:00 2001 From: rhdzmota Date: Sun, 22 Mar 2026 17:02:32 -0600 Subject: [PATCH 6/7] LAM-3 Register tests cases --- .../test_fred/test_mlambda/test_catalog.py | 91 ++++++++++++ .../test/test_fred/test_mlambda/test_count.py | 29 ++++ .../test_fred/test_mlambda/test_interface.py | 54 +++++++ .../test_fred/test_mlambda/test_parser.py | 138 ++++++++++++++++++ .../test/test_fred/test_mlambda/test_rand.py | 36 +++++ .../test_fred/test_mlambda/test_strops.py | 26 ++++ 6 files changed, 374 insertions(+) create mode 100644 fred-mlambda/src/test/test_fred/test_mlambda/test_catalog.py create mode 100644 fred-mlambda/src/test/test_fred/test_mlambda/test_count.py create mode 100644 fred-mlambda/src/test/test_fred/test_mlambda/test_interface.py create mode 100644 fred-mlambda/src/test/test_fred/test_mlambda/test_parser.py create mode 100644 fred-mlambda/src/test/test_fred/test_mlambda/test_rand.py create mode 100644 fred-mlambda/src/test/test_fred/test_mlambda/test_strops.py diff --git a/fred-mlambda/src/test/test_fred/test_mlambda/test_catalog.py b/fred-mlambda/src/test/test_fred/test_mlambda/test_catalog.py new file mode 100644 index 0000000..651090a --- /dev/null +++ b/fred-mlambda/src/test/test_fred/test_mlambda/test_catalog.py @@ -0,0 +1,91 @@ +import pytest + +from fred.mlambda.catalog import MLambdaCatalog +from fred.mlambda.interface import MLambda + + +# --------------------------------------------------------------------------- +# MLambdaCatalog.keys() +# --------------------------------------------------------------------------- + +def test_catalog_keys_returns_list_of_strings(): + keys = MLambdaCatalog.keys() + assert isinstance(keys, list) + assert all(isinstance(k, str) for k in keys) + + +def test_catalog_known_entries_present(): + keys = MLambdaCatalog.keys() + assert "STROPS" in keys + assert "RAND" in keys + + +# --------------------------------------------------------------------------- +# MLambdaCatalog.find() +# --------------------------------------------------------------------------- + +def test_catalog_find_by_exact_name(): + result = MLambdaCatalog.find("STROPS") + assert isinstance(result, MLambda) + assert result.name == "strops" + + +def test_catalog_find_is_case_insensitive(): + # "strops" (lowercase) should resolve to the STROPS enum entry + result = MLambdaCatalog.find("strops") + assert isinstance(result, MLambda) + + +def test_catalog_find_by_settings_alias(): + # "count" is registered in FRED_MLAMBDA_PARSED_ALIASES via settings.py + result = MLambdaCatalog.find("count") + assert isinstance(result, MLambda) + assert result.name == "count" + + +def test_catalog_find_unknown_returns_none(): + result = MLambdaCatalog.find("totally_unknown_alias") + assert result is None + + +def test_catalog_find_unknown_raises_on_fail(): + with pytest.raises(ValueError, match="Unknown MLambda"): + MLambdaCatalog.find("totally_unknown_alias", fail=True) + + +def test_catalog_find_dotpath_warns_and_returns_none(): + # A dotpath should warn and return None from find() + result = MLambdaCatalog.find("some.dotpath") + assert result is None + + +def test_catalog_find_disable_variants(): + # With disable_variants=True only the exact casing is tried + result_exact = MLambdaCatalog.find("STROPS", disable_variants=True) + assert isinstance(result_exact, MLambda) + + result_wrong_case = MLambdaCatalog.find("strops", disable_variants=True) + # "strops" (lowercase) is NOT a catalog key (keys are "STROPS"), + # but it IS a settings alias, so may still resolve; just ensure no crash + assert result_wrong_case is None or isinstance(result_wrong_case, MLambda) + + +# --------------------------------------------------------------------------- +# MLambdaCatalog.get_or_create() +# --------------------------------------------------------------------------- + +def test_get_or_create_with_dotpath(): + result = MLambdaCatalog.get_or_create("math.factorial") + assert isinstance(result, MLambda) + assert result.name == "factorial" + assert result.import_pattern == "math" + + +def test_get_or_create_with_alias(): + result = MLambdaCatalog.get_or_create("STROPS") + assert isinstance(result, MLambda) + + +def test_get_or_create_unknown_raises_on_fail(): + with pytest.raises(ValueError): + MLambdaCatalog.get_or_create("nonexistent", fail=True) diff --git a/fred-mlambda/src/test/test_fred/test_mlambda/test_count.py b/fred-mlambda/src/test/test_fred/test_mlambda/test_count.py new file mode 100644 index 0000000..38c531c --- /dev/null +++ b/fred-mlambda/src/test/test_fred/test_mlambda/test_count.py @@ -0,0 +1,29 @@ +import pytest + +from fred.mlambda._count import count + + +@pytest.mark.parametrize("value,expected", [ + ("hello", 5), + ("", 0), + ([1, 2, 3], 3), + ([], 0), + ({"a": 1, "b": 2}, 2), + ({}, 0), + ({1, 2, 3}, 3), + (set(), 0), + ((1, 2), 2), +]) +def test_count_sized_types(value, expected): + assert count(value) == expected + + +def test_count_unsized_returns_zero(): + # integers have no __len__ + result = count(42, fail=False) + assert result == 0 + + +def test_count_unsized_raises_on_fail(): + with pytest.raises(ValueError, match="Unknown type"): + count(42, fail=True) diff --git a/fred-mlambda/src/test/test_fred/test_mlambda/test_interface.py b/fred-mlambda/src/test/test_fred/test_mlambda/test_interface.py new file mode 100644 index 0000000..af473b5 --- /dev/null +++ b/fred-mlambda/src/test/test_fred/test_mlambda/test_interface.py @@ -0,0 +1,54 @@ +import pytest + +from fred.mlambda.interface import Arguments, MLambda + + +# --------------------------------------------------------------------------- +# Arguments +# --------------------------------------------------------------------------- + +def test_arguments_stores_args_and_kwargs(): + a = Arguments(args=[1, 2], kwargs={"key": "val"}) + assert a.args == [1, 2] + assert a.kwargs == {"key": "val"} + + +def test_arguments_is_frozen(): + a = Arguments(args=[], kwargs={}) + with pytest.raises((AttributeError, TypeError)): + a.args = [1] # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# MLambda +# --------------------------------------------------------------------------- + +def test_mlambda_function_resolves_builtin(): + # Use a stdlib module so the test doesn't depend on package internals + ml = MLambda(name="join", import_pattern="os.path") + fn = ml.function + assert callable(fn) + + +def test_mlambda_function_raises_on_missing_name(): + ml = MLambda(name="does_not_exist_ever", import_pattern="os.path") + with pytest.raises(ValueError, match="not found in module"): + _ = ml.function + + +def test_mlambda_run_via_arguments(): + # math.factorial(5) == 120 + ml = MLambda(name="factorial", import_pattern="math") + args = Arguments(args=[5], kwargs={}) + assert ml.run(args) == 120 + + +def test_mlambda_callable_interface(): + ml = MLambda(name="factorial", import_pattern="math") + assert ml(5) == 120 + + +def test_mlambda_is_frozen(): + ml = MLambda(name="factorial", import_pattern="math") + with pytest.raises((AttributeError, TypeError)): + ml.name = "other" # type: ignore[misc] 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 new file mode 100644 index 0000000..5655e84 --- /dev/null +++ b/fred-mlambda/src/test/test_fred/test_mlambda/test_parser.py @@ -0,0 +1,138 @@ +import pytest + +from fred.mlambda.parser import MLambdaParser +from fred.mlambda.interface import Arguments, MLambda + + +# --------------------------------------------------------------------------- +# MLambdaParser.cast() +# --------------------------------------------------------------------------- + +class TestCast: + def test_plain_string(self): + assert MLambdaParser.cast("hello") == "hello" + + def test_autoinfer_integer(self): + assert MLambdaParser.cast("42") == 42 + + def test_autoinfer_float(self): + assert MLambdaParser.cast("3.14") == 3.14 + + def test_autoinfer_bool_true(self): + assert MLambdaParser.cast("true") is True + + def test_autoinfer_bool_false(self): + assert MLambdaParser.cast("false") is False + + def test_autoinfer_null(self): + assert MLambdaParser.cast("null") is None + assert MLambdaParser.cast("none") is None + assert MLambdaParser.cast("") is None + + def test_explicit_int_annotation(self): + assert MLambdaParser.cast("42::int") == 42 + assert isinstance(MLambdaParser.cast("42::int"), int) + + def test_explicit_float_annotation(self): + assert MLambdaParser.cast("1.5::float") == 1.5 + + def test_explicit_bool_annotation_false(self): + assert MLambdaParser.cast("false::bool") is False + assert MLambdaParser.cast("0::bool") is False + + def test_explicit_str_annotation(self): + # Forces string even for digit input + assert MLambdaParser.cast("42::str") == "42" + assert isinstance(MLambdaParser.cast("42::str"), str) + + def test_unknown_type_annotation_raises(self): + with pytest.raises(ValueError, match="Unknown type annotation"): + MLambdaParser.cast("42::complex") + + def test_disable_autoinfer_keeps_raw_string(self): + assert MLambdaParser.cast("42", disable_autoinfer=True) == "42" + assert MLambdaParser.cast("true", disable_autoinfer=True) == "true" + + +# --------------------------------------------------------------------------- +# MLambdaParser.parse_line() +# --------------------------------------------------------------------------- + +class TestParseLine: + def test_empty_string_returns_empty(self): + args, kwargs = MLambdaParser.parse_line("") + assert args == [] + assert kwargs == {} + + def test_positional_args_only(self): + args, kwargs = MLambdaParser.parse_line("a,b,c") + assert args == ["a", "b", "c"] + assert kwargs == {} + + def test_kwargs_only(self): + args, kwargs = MLambdaParser.parse_line("x=1,y=2") + assert args == [] + assert kwargs == {"x": 1, "y": 2} + + def test_mixed_args_and_kwargs(self): + args, kwargs = MLambdaParser.parse_line("hello,42,verbose=true") + assert args == ["hello", 42] + assert kwargs == {"verbose": True} + + def test_typed_args(self): + args, kwargs = MLambdaParser.parse_line("10::int,3.14::float") + assert args == [10, 3.14] + + def test_quoted_value_with_comma(self): + # CSV quoting: "hello, world" is one token + args, kwargs = MLambdaParser.parse_line('"hello, world",42') + assert args[0] == "hello, world" + assert args[1] == 42 + + +# --------------------------------------------------------------------------- +# MLambdaParser.from_string() +# --------------------------------------------------------------------------- + +class TestFromString: + def test_full_dotpath_resolves_mlambda(self): + expr = "${math.factorial: 5}" + parsed = MLambdaParser.from_string(expr) + assert isinstance(parsed.mlambda, MLambda) + assert parsed.mlambda.name == "factorial" + assert parsed.mlambda.import_pattern == "math" + assert parsed.arguments.args == [5] + + def test_alias_resolves_via_catalog(self): + expr = "${STROPS: hello, lower}" + parsed = MLambdaParser.from_string(expr) + assert parsed.mlambda.name == "strops" + + def test_settings_alias_count_resolves(self): + expr = "${count: hello}" + parsed = MLambdaParser.from_string(expr) + assert parsed.mlambda.name == "count" + + def test_kwargs_are_parsed(self): + expr = "${math.factorial: n=5}" + parsed = MLambdaParser.from_string(expr) + assert parsed.arguments.kwargs == {"n": 5} + + def test_no_args_produces_empty_arguments(self): + expr = "${math.factorial: }" + parsed = MLambdaParser.from_string(expr) + assert parsed.arguments.args == [] + assert parsed.arguments.kwargs == {} + + def test_invalid_expression_raises(self): + with pytest.raises(ValueError, match="Invalid MLambda expression"): + MLambdaParser.from_string("not_valid_at_all") + + def test_unknown_alias_raises(self): + with pytest.raises(ValueError): + MLambdaParser.from_string("${totally_unknown: }") + + def test_execute_runs_function(self): + expr = "${math.factorial: 6}" + parsed = MLambdaParser.from_string(expr) + assert parsed.execute() == 720 diff --git a/fred-mlambda/src/test/test_fred/test_mlambda/test_rand.py b/fred-mlambda/src/test/test_fred/test_mlambda/test_rand.py new file mode 100644 index 0000000..de7a34e --- /dev/null +++ b/fred-mlambda/src/test/test_fred/test_mlambda/test_rand.py @@ -0,0 +1,36 @@ +import pytest + +from fred.mlambda._rand import rand + +_POPULATION = ("a", "b", "c", "d") + + +def test_rand_single_returns_scalar(): + # Default k=1 with autoflat → returns a single element, not a list + result = rand(*_POPULATION) + assert result in _POPULATION + + +def test_rand_multiple_returns_list(): + result = rand(*_POPULATION, k=3) + assert isinstance(result, list) + assert len(result) == 3 + assert all(item in _POPULATION for item in result) + + +def test_rand_disable_autoflat_returns_list_of_one(): + result = rand(*_POPULATION, k=1, disable_autoflat=True) + assert isinstance(result, list) + assert len(result) == 1 + assert result[0] in _POPULATION + + +def test_rand_empty_population(): + # random.choices with empty population raises IndexError + with pytest.raises(IndexError): + rand(k=1, disable_autoflat=True) + + +def test_rand_single_element_population(): + result = rand("only", k=2) + assert result == ["only", "only"] diff --git a/fred-mlambda/src/test/test_fred/test_mlambda/test_strops.py b/fred-mlambda/src/test/test_fred/test_mlambda/test_strops.py new file mode 100644 index 0000000..fe5bcb6 --- /dev/null +++ b/fred-mlambda/src/test/test_fred/test_mlambda/test_strops.py @@ -0,0 +1,26 @@ +import pytest + +from fred.mlambda._strops import strops + + +@pytest.mark.parametrize("ops,input_str,expected", [ + ("lower", "Hello World", "hello world"), + ("upper", "Hello World", "HELLO WORLD"), + ("title", "hello world", "Hello World"), + ("capitalize", "hello world", "Hello world"), + ("strip", " hello ", "hello"), + ("lstrip", " hello ", "hello "), + ("rstrip", " hello ", " hello"), +]) +def test_strops_known_operations(ops, input_str, expected): + assert strops(input_str, ops) == expected + + +def test_strops_unknown_op_returns_none(): + result = strops("hello", "nonexistent_op", fail=False) + assert result is None + + +def test_strops_unknown_op_raises_on_fail(): + with pytest.raises(ValueError, match="Unknown operation"): + strops("hello", "nonexistent_op", fail=True) From 665d21864c0bea6d827b712b4fd4c4e386bc3407 Mon Sep 17 00:00:00 2001 From: rhdzmota Date: Sun, 22 Mar 2026 17:02:59 -0600 Subject: [PATCH 7/7] LAM-3 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 6c6aa7c..341cf11 100644 --- a/fred-mlambda/src/main/fred/mlambda/version +++ b/fred-mlambda/src/main/fred/mlambda/version @@ -1 +1 @@ -0.1.0 \ No newline at end of file +0.2.0 \ No newline at end of file