Skip to content
Closed
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
27 changes: 26 additions & 1 deletion marimo/_plugins/ui/_impl/tables/narwhals_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -699,21 +699,46 @@ def get_sample_values(self, column: str) -> list[str | int | float]:

# Sample 3 values from the column
SAMPLE_SIZE = 3
# Cap nested list/dict serialization depth. Unbounded recursion (or
# even json.dumps on pathological nested structs) can stall dataset
# registration for deeply nested Polars Struct columns (#9378).
MAX_NESTING_DEPTH = 5
try:

def _json_default(o: Any) -> str:
if isinstance(o, Enum):
return o.name
return str(o)

def _with_depth_limit(value: Any, depth: int = 0) -> Any:
"""Return a structure safe for json.dumps / display.

At max depth, emit a fixed sentinel rather than str(value):
str() on large list/dict trees still walks the full remaining
container (issue #9378 branching payloads).
"""
if depth >= MAX_NESTING_DEPTH:
if isinstance(value, (list, dict)):
return "..."
return value
if isinstance(value, list):
return [_with_depth_limit(v, depth + 1) for v in value]
if isinstance(value, dict):
return {
k: _with_depth_limit(v, depth + 1)
for k, v in value.items()
}
return value

def to_primitive(value: Any) -> str | int | float:
if isinstance(value, Enum):
return value.name
if isinstance(value, (int, float)):
return value
if isinstance(value, (list, dict)):
try:
return json.dumps(value, default=_json_default)
limited = _with_depth_limit(value)
return json.dumps(limited, default=_json_default)
except (TypeError, ValueError):
return str(value)
return str(value)
Expand Down
57 changes: 57 additions & 0 deletions tests/_plugins/ui/_impl/tables/test_narwhals.py
Original file line number Diff line number Diff line change
Expand Up @@ -1578,6 +1578,37 @@ def build_payload(row_idx: int, depth: int):


@pytest.mark.skipif(not HAS_DEPS, reason="polars not installed")
@pytest.mark.skipif(not HAS_DEPS, reason="polars not installed")
def test_get_sample_values_branching_payload_is_bounded() -> None:
"""Wide branching nests must not explode work via str() at the depth cap."""
import polars as pl

def branch(depth: int, width: int = 8) -> Any:
if depth == 0:
return {"leaf": "x" * 20, "n": 1}
return {f"b{i}": branch(depth - 1, width) for i in range(width)}

# width^depth without a real cap is enormous; with sentinel cap work is O(width^MAX).
payload = branch(6, width=6)
df = pl.DataFrame({"payload": [payload, payload, payload]})
manager = NarwhalsTableManager.from_dataframe(
nw.from_native(df, eager_only=True)
)

t0 = time.perf_counter()
samples = manager.get_sample_values("payload")
elapsed = time.perf_counter() - t0

assert len(samples) == 3
assert elapsed < 1.0, (
f"get_sample_values took {elapsed:.2f}s (expected < 1s)"
)
for s in samples:
assert isinstance(s, str)
assert "..." in s
assert len(s) < 250_000


def test_get_sample_values_nested_struct_is_json() -> None:
import polars as pl

Expand Down Expand Up @@ -1730,6 +1761,32 @@ def test_calculate_top_k_rows_with_all_special_floats(df: Any) -> None:
]


@pytest.mark.skipif(
not DependencyManager.pandas.has(),
reason="pandas required for nested sample depth test",
)
def test_get_sample_values_nested_depth_limit() -> None:
"""Deeply nested list/dict samples must finish quickly (#9378 / #9383)."""
import pandas as pd

def nest(depth: int) -> object:
v: object = "leaf"
for _ in range(depth):
v = {"k": v}
return v

df = pd.DataFrame({"deep": [nest(20)]})
manager = NarwhalsTableManager.from_dataframe(df)
t0 = time.perf_counter()
sample = manager.get_sample_values("deep")
elapsed = time.perf_counter() - t0
assert elapsed < 1.0, f"nested sample too slow: {elapsed:.3f}s"
assert len(sample) == 1
assert isinstance(sample[0], str)
# depth limit should stringify remaining nesting rather than exploding
assert "leaf" in sample[0] or sample[0].startswith("{")


def _normalize_result(result: list[tuple[Any, int]]) -> list[tuple[Any, int]]:
"""Normalize None and NaN values for comparison."""
out: list[tuple[Any, int]] = []
Expand Down
Loading