From aa1b2aafa741be9c85c5528c8261d131bae0f2a1 Mon Sep 17 00:00:00 2001 From: Pedro Sampaio Date: Wed, 25 Mar 2026 02:19:03 -0300 Subject: [PATCH 1/7] improve request manager --- retrack/engine/request_manager.py | 60 ++++++++++++++++------- tests/test_engine/test_request_manager.py | 3 +- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/retrack/engine/request_manager.py b/retrack/engine/request_manager.py index 77cf581..6a3e0e4 100644 --- a/retrack/engine/request_manager.py +++ b/retrack/engine/request_manager.py @@ -1,7 +1,6 @@ import typing import pandas as pd -import pandera import pydantic from retrack.nodes.base import BaseNode, NodeKind @@ -75,7 +74,7 @@ def model(self) -> typing.Type[pydantic.BaseModel]: return self._model @property - def dataframe_model(self) -> pandera.DataFrameSchema: + def dataframe_model(self): return self._dataframe_model def __create_model( @@ -117,23 +116,19 @@ def __create_model( ), ) - def __create_dataframe_model(self) -> pandera.DataFrameSchema: - """Create a pydantic model from the RequestManager's inputs""" - fields = {} - for input_field in self.inputs: - fields[input_field.data.name] = pandera.Column( - str, - nullable=input_field.data.default is not None, - coerce=True, - default=input_field.data.default, - ) + def __create_dataframe_model(self) -> dict: + """Create a lightweight validation schema from the RequestManager's inputs. - return pandera.DataFrameSchema( - fields, - index=pandera.Index(int), - # strict=True, - coerce=True, - ) + Returns: + dict: mapping column_name -> {"nullable": bool, "default": value} + """ + schema = {} + for input_field in self.inputs: + schema[input_field.data.name] = { + "nullable": input_field.data.default is not None, + "default": input_field.data.default, + } + return schema def validate( self, @@ -156,4 +151,31 @@ def validate( if not isinstance(payload, pd.DataFrame): raise TypeError(f"payload must be a pandas.DataFrame, not {type(payload)}") - return self.dataframe_model.validate(payload) + schema = self.dataframe_model + + # Check required columns exist + missing = set(schema.keys()) - set(payload.columns) + if missing: + raise ValueError( + f"Missing columns: {missing}" + ) + + result = payload.copy() + + for col_name, col_schema in schema.items(): + series = result[col_name] + + # Handle nulls + null_mask = series.isna() | series.isin([None, "None", "", "null"]) + if null_mask.any(): + if not col_schema["nullable"]: + raise ValueError( + f"Column '{col_name}' contains null values but is not nullable" + ) + if col_schema["default"] is not None: + series = series.where(~null_mask, col_schema["default"]) + + # Coerce to str + result[col_name] = series.astype(str) + + return result diff --git a/tests/test_engine/test_request_manager.py b/tests/test_engine/test_request_manager.py index 71211e5..b4fcd00 100644 --- a/tests/test_engine/test_request_manager.py +++ b/tests/test_engine/test_request_manager.py @@ -1,5 +1,4 @@ import pandas as pd -import pandera import pydantic import pytest @@ -41,7 +40,7 @@ def test_validate_payload_with_valid_payload(valid_input_dict_before_validation) assert issubclass(rm.model, pydantic.BaseModel) - assert isinstance(rm.dataframe_model, pandera.api.pandas.container.DataFrameSchema) + assert isinstance(rm.dataframe_model, dict) payload = rm.model(example="test") From 50d1876b0ef18d95120d77f1067f3a86ec2c9d27 Mon Sep 17 00:00:00 2001 From: Pedro Sampaio Date: Wed, 25 Mar 2026 02:30:26 -0300 Subject: [PATCH 2/7] improve has ended --- retrack/engine/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/retrack/engine/base.py b/retrack/engine/base.py index 75569dc..ede9f16 100644 --- a/retrack/engine/base.py +++ b/retrack/engine/base.py @@ -95,7 +95,7 @@ def result(self) -> pd.DataFrame: ] def has_ended(self) -> bool: - return self.states[constants.OUTPUT_REFERENCE_COLUMN].isna().sum() == 0 + return not self.states[constants.OUTPUT_REFERENCE_COLUMN].isna().any() def to_dict(self) -> dict: return { From 5e04b073670c86224bb830fa97c007e9ca7cfc69 Mon Sep 17 00:00:00 2001 From: Pedro Sampaio Date: Fri, 22 May 2026 18:03:25 -0300 Subject: [PATCH 3/7] update pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 067a940..bb02ffc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "retrack" -version = "3.5.0" +version = "3.6.0a1" description = "A business rules engine" authors = ["Gabriel Guarisa "] license = "MIT" From c5c85c2c273cf5a2bd0ca3388974cd794cd1cfd8 Mon Sep 17 00:00:00 2001 From: Pedro Sampaio Date: Fri, 22 May 2026 18:10:18 -0300 Subject: [PATCH 4/7] Bump package version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bb02ffc..b429506 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "retrack" -version = "3.6.0a1" +version = "3.6.0a2" description = "A business rules engine" authors = ["Gabriel Guarisa "] license = "MIT" From aa4c1d2ddce4092cb22e592bde4a50991c635a9a Mon Sep 17 00:00:00 2001 From: Pedro Sampaio Date: Tue, 7 Jul 2026 11:01:08 -0300 Subject: [PATCH 5/7] Bump version from 3.6.0a3 to 3.6.0a4 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8566765..68f954d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "retrack" -version = "3.6.0a3" +version = "3.6.0a4" description = "A business rules engine" authors = ["Gabriel Guarisa "] license = "MIT" From 867b81695d287fb6d28921821d93a94c595730dd Mon Sep 17 00:00:00 2001 From: Pedro Sampaio Date: Wed, 12 Aug 2026 11:48:25 -0300 Subject: [PATCH 6/7] Update version from 3.6.0a4 to 3.7.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 68f954d..d951cba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "retrack" -version = "3.6.0a4" +version = "3.7.0" description = "A business rules engine" authors = ["Gabriel Guarisa "] license = "MIT" From 9e36ec175b3253589580f3f5e56765a18ecde6fb Mon Sep 17 00:00:00 2001 From: Pedro Sampaio Date: Wed, 12 Aug 2026 12:09:27 -0300 Subject: [PATCH 7/7] Add sentinel string tests and apply ruff format Cover null handling of "None", "", "null" and None in the DataFrame validation path, matching StrFieldValidator used by the pydantic path. Formatting changes are AST-identical, no behavior change. --- retrack/engine/base.py | 2 +- retrack/engine/request_manager.py | 4 +--- tests/test_engine/test_request_manager.py | 26 +++++++++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/retrack/engine/base.py b/retrack/engine/base.py index 7a09cff..714ba4d 100644 --- a/retrack/engine/base.py +++ b/retrack/engine/base.py @@ -21,7 +21,7 @@ def __init__( states: pd.DataFrame, filters: dict = None, context: registry.Registry = None, - child_executions = None, + child_executions=None, nodes: dict = None, constants: dict = None, ): diff --git a/retrack/engine/request_manager.py b/retrack/engine/request_manager.py index 6a3e0e4..4efa253 100644 --- a/retrack/engine/request_manager.py +++ b/retrack/engine/request_manager.py @@ -156,9 +156,7 @@ def validate( # Check required columns exist missing = set(schema.keys()) - set(payload.columns) if missing: - raise ValueError( - f"Missing columns: {missing}" - ) + raise ValueError(f"Missing columns: {missing}") result = payload.copy() diff --git a/tests/test_engine/test_request_manager.py b/tests/test_engine/test_request_manager.py index b4fcd00..3595524 100644 --- a/tests/test_engine/test_request_manager.py +++ b/tests/test_engine/test_request_manager.py @@ -62,3 +62,29 @@ def test_validate_dict_with_none_value(valid_input_dict_before_validation): assert issubclass(rm.model, pydantic.BaseModel) assert rm.model(example=None) == rm.model(example="Hello World") assert rm.model() == rm.model(example="Hello World") + + +def test_validate_dataframe_replaces_sentinel_strings_with_default( + valid_input_dict_before_validation, +): + """Sentinel strings count as null in the DataFrame path, matching StrFieldValidator.""" + rm = RequestManager([Input(**valid_input_dict_before_validation)]) + + result = rm.validate( + pd.DataFrame([{"example": v} for v in ["None", "", "null", None]]) + ) + + assert result["example"].tolist() == ["Hello World"] * 4 + + +def test_validate_dataframe_rejects_sentinel_strings_when_not_nullable( + valid_input_dict_before_validation, +): + """Without a default the input is not nullable, so sentinel strings must raise.""" + rm = RequestManager( + [Input(**{**valid_input_dict_before_validation, "data": {"name": "example"}})] + ) + + for value in ["None", "", "null", None]: + with pytest.raises(ValueError, match="not nullable"): + rm.validate(pd.DataFrame([{"example": value}]))