Skip to content
Open
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
4 changes: 4 additions & 0 deletions fern/versions/latest/pages/concepts/columns.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,10 @@ Template capabilities:
- **Conditional logic**: if/elif/else support
- **Arithmetic**: Add, subtract, multiply, divide

Values from other columns keep their type in templates, so a text cell such as `"1.10"` or `"true"` renders exactly as written. Cast numbers stored as text before doing math with them, for example `{{ quantity | int + 1 }}` or `{{ price | float * 2 }}`. Columns holding a JSON object or array still support nested access such as `{{ result.score }}`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we also cover text flags in skip.when here? With when="{{ flag }}", text "false" and "null" now cause the cell to be skipped, and {{ not flag }} flips the other way. That follows the intended string semantics, but is easy to miss from the numeric-cast guidance. A short note recommending explicit comparisons like {{ flag == 'true' }}, plus regression cases, would help.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a note right after the expression paragraph: text "false" and "null" are non-empty strings, so when="{{ flag }}" skips and {{ not flag }} doesn't, and the recommended form is {{ flag == 'true' }}. Regression cases for both forms plus the explicit comparison are in test_skip_evaluator.py (04cb220). They fail on main, as expected.


The same applies to `skip.when`. A text cell holding `"false"` or `"null"` is a non-empty string, so `when="{{ flag }}"` skips the cell and `when="{{ not flag }}"` does not. For flags stored as text, compare explicitly, for example `when="{{ flag == 'true' }}"`.

### πŸ” Validation Columns

Validation columns check generated content against rules and return structured pass/fail results.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from data_designer.config.column_configs import EmbeddingColumnConfig
from data_designer.engine.column_generators.generators.base import ColumnGeneratorWithModel, GenerationStrategy
from data_designer.engine.processing.utils import deserialize_json_values, parse_list_string
from data_designer.engine.processing.utils import parse_list_string


class EmbeddingGenerationResult(BaseModel):
Expand All @@ -29,18 +29,15 @@ def get_generation_strategy() -> GenerationStrategy:

def _prepare_embedding_inputs(self, data: dict) -> list[str]:
cell_value = data[self.config.target_column]
deserialized_value = deserialize_json_values(data)[self.config.target_column]
if isinstance(deserialized_value, list):
return [str(text).strip() for text in deserialized_value]
if isinstance(deserialized_value, str):
return parse_list_string(deserialized_value)
if not isinstance(cell_value, str):
raise ValueError(
f"Embedding target column {self.config.target_column!r} must contain text or a list of texts, "
f"got {type(cell_value).__name__}"
)
# A JSON scalar such as "123" or "true" is embedded as the user wrote it.
return [cell_value.strip()]
if isinstance(cell_value, list):
return [str(text).strip() for text in cell_value]
if isinstance(cell_value, str):
# A JSON scalar such as "123" or "true" is embedded as the user wrote it.
return parse_list_string(cell_value)
raise ValueError(
f"Embedding target column {self.config.target_column!r} must contain text or a list of texts, "
f"got {type(cell_value).__name__}"
)

def generate(self, data: dict) -> dict:
input_texts = self._prepare_embedding_inputs(data)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from data_designer.engine.context import format_row_group_tag
from data_designer.engine.processing.ginja.environment import WithJinja2UserTemplateRendering
from data_designer.engine.processing.ginja.exceptions import EmptyTemplateRenderError, UserTemplateError
from data_designer.engine.processing.utils import deserialize_json_values
from data_designer.engine.processing.utils import prepare_template_context

if TYPE_CHECKING:
import pandas as pd
Expand Down Expand Up @@ -59,7 +59,7 @@ def generate(self, data: pd.DataFrame) -> pd.DataFrame:
row_records = [{} for _ in row_indexes]

for row_index, record in zip(row_indexes, row_records, strict=True):
prepared_record = deserialize_json_values(record)
prepared_record = prepare_template_context(record)
try:
rendered_value = self.render_template(prepared_record)
except EmptyTemplateRenderError:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from data_designer.config.column_configs import ImageColumnConfig
from data_designer.engine.column_generators.generators.base import ColumnGeneratorWithModel, GenerationStrategy
from data_designer.engine.processing.ginja.environment import WithJinja2UserTemplateRendering
from data_designer.engine.processing.utils import deserialize_json_values
from data_designer.engine.processing.utils import prepare_template_context

if TYPE_CHECKING:
from data_designer.engine.storage.media_storage import MediaStorage
Expand All @@ -34,7 +34,7 @@ def get_generation_strategy() -> GenerationStrategy:

def _prepare_image_inputs(self, data: dict) -> tuple[str, list[dict] | None]:
"""Validate inputs and render prompt for image generation."""
deserialized_record = deserialize_json_values(data)
deserialized_record = prepare_template_context(data)
missing_columns = list(set(self.config.required_columns) - set(data.keys()))
if len(missing_columns) > 0:
raise ValueError(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
)
from data_designer.engine.configurable_task import TaskConfigT
from data_designer.engine.models.recipes.base import ResponseRecipe
from data_designer.engine.processing.utils import deserialize_json_values
from data_designer.engine.processing.utils import deserialize_json_string, prepare_template_context

if TYPE_CHECKING:
from data_designer.engine.models.utils import ChatMessage
Expand Down Expand Up @@ -78,7 +78,7 @@ def _prepare_generation_kwargs(self, data: dict) -> dict[str, Any]:
# Deserialize input data from previous columns so Jinja2 templates can access nested fields
# Example: If prev column stored '{"key": "value"}', templates can use {{ prev_column.key }}
# Note: This creates a new dict and doesn't mutate the original `data` argument
deserialized_record = deserialize_json_values(data)
deserialized_record = prepare_template_context(data)

multi_modal_context = self._build_multi_modal_context(deserialized_record)

Expand Down Expand Up @@ -155,9 +155,9 @@ class LLMCodeCellGenerator(ColumnGeneratorWithModelChatCompletion[LLMCodeColumnC

class LLMStructuredCellGenerator(ColumnGeneratorWithModelChatCompletion[LLMStructuredColumnConfig]):
def _process_serialized_output(self, serialized_output: str) -> dict | list:
return deserialize_json_values(serialized_output)
return deserialize_json_string(serialized_output)


class LLMJudgeCellGenerator(ColumnGeneratorWithModelChatCompletion[LLMJudgeColumnConfig]):
def _process_serialized_output(self, serialized_output: str) -> dict | list:
return deserialize_json_values(serialized_output)
return deserialize_json_string(serialized_output)
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from jinja2.sandbox import SandboxedEnvironment

from data_designer.engine.dataset_builders.utils.skip_tracker import SKIPPED_COLUMNS_RECORD_KEY
from data_designer.engine.processing.utils import deserialize_json_values
from data_designer.engine.processing.utils import prepare_template_context

if TYPE_CHECKING:
from jinja2 import Template
Expand Down Expand Up @@ -47,7 +47,7 @@ def evaluate_skip_when(expression: str, record: dict) -> bool:
"""
try:
template = _compile_skip_template(expression)
deserialized = deserialize_json_values(record)
deserialized = prepare_template_context(record)
result = template.render(deserialized)
return bool(result)
except (UndefinedError, SecurityError, TemplateSyntaxError, TypeError, ValueError):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from data_designer.config.utils.storage_schema import JsonStoragePlan
from data_designer.engine.processing.ginja.environment import WithJinja2UserTemplateRendering
from data_designer.engine.processing.processors.base import Processor
from data_designer.engine.processing.utils import deserialize_json_values
from data_designer.engine.processing.utils import prepare_template_context
from data_designer.engine.storage.artifact_storage import BatchStage

if TYPE_CHECKING:
Expand Down Expand Up @@ -83,7 +83,7 @@ def _transform(self, data: pd.DataFrame) -> pd.DataFrame:
)
formatted_records = []
for record in data.to_dict(orient="records"):
deserialized = deserialize_json_values(record)
deserialized = prepare_template_context(record)
rendered = self.render_template(deserialized)
formatted_records.append(json.loads(rendered))
return create_logical_dataframe(formatted_records, self.config.json_storage_columns)
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import json
import logging
import re
from typing import TYPE_CHECKING, Any, TypeVar, overload
from typing import TYPE_CHECKING, Any, TypeVar

import data_designer.lazy_heavy_imports as lazy

Expand All @@ -18,8 +18,6 @@
logger = logging.getLogger(__name__)

T = TypeVar("T")
K = TypeVar("K")
V = TypeVar("V")


def concat_datasets(datasets: list[pd.DataFrame]) -> pd.DataFrame:
Expand All @@ -30,87 +28,38 @@ def concat_datasets(datasets: list[pd.DataFrame]) -> pd.DataFrame:
return lazy.pd.concat([df for df in datasets], axis=1)


# Overloads to help static type checker better understand
# the input/output types of the deserialize_json_values function.
@overload
def deserialize_json_values(data: str) -> dict[str, Any] | list[Any] | Any: ...


@overload
def deserialize_json_values(data: list[T]) -> list[Any]: ...


@overload
def deserialize_json_values(data: dict[K, V]) -> dict[K, Any]: ...


@overload
def deserialize_json_values(data: T) -> T: ...
def deserialize_json_string(value: str) -> Any:
"""Decode a JSON document, returning the original string if it is not valid JSON."""
try:
return json.loads(value)
except json.JSONDecodeError:
return value


def deserialize_json_values(data):
"""De-serialize JSON strings in various input formats.
def prepare_template_context(data: T) -> Any:
"""Decode JSON objects and arrays stored as strings so templates can reach their fields.

This function creates a deep copy of the input data and does not mutate the original.
Native dictionaries and lists are processed recursively. A string is replaced only when it holds a JSON
object or array, so `{{ column.key }}` works on a column that stored a JSON object, while scalar strings
like "1.10", "true" or "null" stay text. The input is not mutated.

Args:
data: Input data in one of four formats:
- Single string (JSON string to deserialize)
- List of strings (list of JSON strings to deserialize)
- Dictionary (potentially with nested JSON strings to deserialize)
- Some other object that can't be deserialized.
data: A record, a list, a string, or any other value.

Returns:
Deserialized data in the corresponding format:
- Dictionary (when input is a single string)
- List of dictionaries (when input is a list of strings)
- Dictionary (when input is a dictionary, with nested JSON strings deserialized)
- The original object (if there is no deserialization to perform)

A copy of `data` with embedded JSON objects and arrays decoded.
"""
# Create a deep copy to avoid mutating the original data
data_copy = copy.deepcopy(data)

# Case 1: Single string input
if isinstance(data_copy, str):
try:
return json.loads(data_copy)
except json.JSONDecodeError:
return data_copy

# Case 2: List of strings input
elif isinstance(data_copy, list):
result = []
for item in data_copy:
if isinstance(item, str):
try:
result.append(json.loads(item))
except json.JSONDecodeError:
result.append(item)
else:
# If list contains non-string items, recursively process them
result.append(deserialize_json_values(item))
return result

# Case 3: Dictionary input with potential nested JSON strings
elif isinstance(data_copy, dict):
result = {}
for key, value in data_copy.items():
if isinstance(value, str):
try:
result[key] = json.loads(value)
except json.JSONDecodeError:
result[key] = value
elif isinstance(value, (dict, list)):
# Recursively process nested dictionaries and lists
result[key] = deserialize_json_values(value)
else:
result[key] = value
return result

# Fallback for other data types
else:
return data_copy
if isinstance(data, str):
loaded = deserialize_json_string(data)
return loaded if isinstance(loaded, (dict, list)) else data

if isinstance(data, list):
return [prepare_template_context(item) for item in data]

if isinstance(data, dict):
return {key: prepare_template_context(value) for key, value in data.items()}

return copy.deepcopy(data)


def parse_list_string(text: str) -> list[str]:
Expand All @@ -131,22 +80,26 @@ def parse_list_string(text: str) -> list[str]:

# Try JSON again with cleaned text
try:
return _clean_whitespace(json.loads(text_cleaned))
list_obj = json.loads(text_cleaned)
if isinstance(list_obj, list):
return _clean_whitespace(list_obj)
except json.JSONDecodeError:
pass

# Try Python literal eval (handles single quotes)
try:
return _clean_whitespace(ast.literal_eval(text_cleaned))
list_obj = ast.literal_eval(text_cleaned)
if isinstance(list_obj, list):
return _clean_whitespace(list_obj)
except (ValueError, SyntaxError):
pass

# If all else fails, return the original text
return [text.strip()]


def _clean_whitespace(texts: list[str]) -> list[str]:
return [text.strip() for text in texts]
def _clean_whitespace(texts: list[Any]) -> list[str]:
return [str(text).strip() for text in texts]


def _verify_columns_are_unique(datasets: list[pd.DataFrame]) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ def test_embedding_cell_generator_generate(stub_embedding_column_config, stub_re
("123", ["123"]),
("true", ["true"]),
("null", ["null"]),
('"quoted text"', ['"quoted text"']),
(["true", "1.10"], ["true", "1.10"]),
('["true", "1.10"]', ["true", "1.10"]),
("['true', '1.10']", ["true", "1.10"]),
([" padded ", '{"a": 1}'], ["padded", '{"a": 1}']),
]


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def test_generator_creation():
"data_designer.engine.column_generators.generators.expression.WithJinja2UserTemplateRendering.render_template",
autospec=True,
)
@patch("data_designer.engine.column_generators.generators.expression.deserialize_json_values", autospec=True)
@patch("data_designer.engine.column_generators.generators.expression.prepare_template_context", autospec=True)
def test_generate_with_dataframe(mock_deserialize, mock_render, mock_prepare):
config = _create_test_config("sum_column", "{{ col1 + col2 }}", "int")
generator = _create_test_generator(config)
Expand Down Expand Up @@ -75,7 +75,7 @@ def test_generate_with_dataframe(mock_deserialize, mock_render, mock_prepare):
"data_designer.engine.column_generators.generators.expression.WithJinja2UserTemplateRendering.render_template",
autospec=True,
)
@patch("data_designer.engine.column_generators.generators.expression.deserialize_json_values", autospec=True)
@patch("data_designer.engine.column_generators.generators.expression.prepare_template_context", autospec=True)
def test_generate_with_different_dtypes(mock_deserialize, mock_render, mock_prepare):
config = _create_test_config("result_column", "{{ col1 }}", "float")
generator = _create_test_generator(config)
Expand All @@ -100,7 +100,7 @@ def test_generate_with_different_dtypes(mock_deserialize, mock_render, mock_prep
"data_designer.engine.column_generators.generators.expression.WithJinja2UserTemplateRendering.render_template",
autospec=True,
)
@patch("data_designer.engine.column_generators.generators.expression.deserialize_json_values", autospec=True)
@patch("data_designer.engine.column_generators.generators.expression.prepare_template_context", autospec=True)
def test_generate_with_bool_dtype_numeric(mock_deserialize, mock_render, mock_prepare):
config = _create_test_config("bool_column", "{{ col1 }}", "bool")
generator = _create_test_generator(config)
Expand All @@ -125,7 +125,7 @@ def test_generate_with_bool_dtype_numeric(mock_deserialize, mock_render, mock_pr
"data_designer.engine.column_generators.generators.expression.WithJinja2UserTemplateRendering.render_template",
autospec=True,
)
@patch("data_designer.engine.column_generators.generators.expression.deserialize_json_values", autospec=True)
@patch("data_designer.engine.column_generators.generators.expression.prepare_template_context", autospec=True)
def test_generate_with_bool_dtype_string(mock_deserialize, mock_render, mock_prepare):
config = _create_test_config("bool_column", "{{ col1 }}", "bool")
generator = _create_test_generator(config)
Expand Down Expand Up @@ -221,6 +221,28 @@ def test_generate_int_dtype_drops_only_the_out_of_range_row() -> None:
assert result["number"].tolist() == [1, 3]


def test_generate_keeps_scalar_strings_from_previous_columns() -> None:
config = _create_test_config("label", "v{{ version }} {{ flag }} {{ flag == 'true' }} {{ meta.score }}", "str")
generator = _create_test_generator(config)
df = lazy.pd.DataFrame(
{"version": ["1.10", "1e3"], "flag": ["true", "null"], "meta": ['{"score": 95}', '{"score": 42}']}
)

result = generator.generate(df)

assert result["label"].tolist() == ["v1.10 true True 95", "v1e3 null False 42"]


def test_generate_casts_numeric_strings_explicitly() -> None:
config = _create_test_config("next", "{{ n | int + 1 }}", "int")
generator = _create_test_generator(config)
df = lazy.pd.DataFrame({"n": ["5", "6"]})

result = generator.generate(df)

assert result["next"].tolist() == [6, 7]


def test_generate_with_missing_columns():
config = _create_test_config("test_column", "{{ col1 }}", "str")
generator = _create_test_generator(config)
Expand Down
Loading
Loading