From 7a8a30024f5c2cf6e34a3fe07b9dfd37e1d07349 Mon Sep 17 00:00:00 2001 From: ghostiee-11 <168410465+ghostiee-11@users.noreply.github.com> Date: Sun, 17 May 2026 16:18:43 +0530 Subject: [PATCH 1/5] feat: add DataFrameLike parameter for cross-backend dataframe inputs `param.DataFrame` is restricted to pandas. `DataFrameLike` accepts any object Narwhals recognises (pandas, Polars, PyArrow, cuDF, Modin) and passes it through unchanged, so existing pandas-only code is unaffected (`param.DataFrame` is not touched). * New `DataFrameLike(ClassSelector)` validating via `narwhals.from_native(eager_only=not allow_lazy, pass_through=False)`. Narwhals is an optional dependency, deferred like pandas is for `DataFrame`. * Same `rows` / `columns` / `ordered` slots as `DataFrame`, driven through the Narwhals wrapper so they work on every backend. Column names read via `collect_schema().names()` so lazy frames are not implicitly collected. * `allow_lazy=True` opts into lazy frames (Polars LazyFrame, Dask, DuckDB); row-count validation is skipped for lazy frames. * Backend-neutral `serialize` (list of records via Narwhals); `deserialize` reuses `DataFrame.deserialize` since JSON carries no backend information. * `_length_bounds_check` extracted to a module-level helper shared by `DataFrame` and `DataFrameLike` (behaviour-preserving; testpandas unchanged). * tests/testdataframelike.py covering pandas / Polars / PyArrow / lazy / serialization; narwhals + polars added to test-only dependencies. --- doc/reference/param/parameters.md | 1 + param/__init__.py | 2 + param/parameters.py | 250 +++++++++++++++++++++++++++-- pixi.toml | 2 + pyproject.toml | 2 + tests/testdataframelike.py | 258 ++++++++++++++++++++++++++++++ 6 files changed, 504 insertions(+), 11 deletions(-) create mode 100644 tests/testdataframelike.py diff --git a/doc/reference/param/parameters.md b/doc/reference/param/parameters.md index cb9f48261..dc49d0ebb 100644 --- a/doc/reference/param/parameters.md +++ b/doc/reference/param/parameters.md @@ -65,6 +65,7 @@ Array Series DataFrame + DataFrameLike Callable Action Composite diff --git a/param/__init__.py b/param/__init__.py index 4aa051eef..989f7659f 100644 --- a/param/__init__.py +++ b/param/__init__.py @@ -84,6 +84,7 @@ Dict, Array, DataFrame, + DataFrameLike, Series, Path, Filename, @@ -166,6 +167,7 @@ 'Composite', 'DEBUG', 'DataFrame', + 'DataFrameLike', 'Date', 'DateRange', 'Dict', diff --git a/param/parameters.py b/param/parameters.py index b44da28f3..052e54101 100644 --- a/param/parameters.py +++ b/param/parameters.py @@ -114,6 +114,12 @@ class _DataFrameInitKwargs(_ParameterKwargs, total=False): columns: int | tuple[int | None, int | None] | list[str] | set[str] | None ordered: bool | None + class _DataFrameLikeInitKwargs(_ParameterKwargs, total=False): + rows: int | tuple[int | None, int | None] | None + columns: int | tuple[int | None, int | None] | list[str] | set[str] | None + ordered: bool | None + allow_lazy: bool | None + class _SeriesInitKwargs(_ParameterKwargs, total=False): rows: int | tuple[int | None, int | None] | None @@ -3311,6 +3317,25 @@ def deserialize(cls, value): return numpy.asarray(value) +def _length_bounds_check(parameter, bounds, length, name): + """Check ``length`` against an int or ``(lower, upper)`` ``bounds``. + + Shared by :class:`DataFrame` and :class:`DataFrameLike`; ``parameter`` is + only used for the error-message prefix. + """ + message = f'{name} length {length} does not match declared bounds of {bounds}' + if not isinstance(bounds, tuple): + if (bounds != length): + raise ValueError(f"{_validate_error_prefix(parameter)}: {message}") + else: + return + (lower, upper) = bounds + failure = ((lower is not None and (length < lower)) + or (upper is not None and length > upper)) + if failure: + raise ValueError(f"{_validate_error_prefix(parameter)}: {message}") + + class DataFrame(ClassSelector["DF"]): """ Parameter whose value is a pandas ``DataFrame``. @@ -3406,17 +3431,7 @@ def __init__( self._validate(self.default) def _length_bounds_check(self, bounds, length, name): - message = f'{name} length {length} does not match declared bounds of {bounds}' - if not isinstance(bounds, tuple): - if (bounds != length): - raise ValueError(f"{_validate_error_prefix(self)}: {message}") - else: - return - (lower, upper) = bounds - failure = ((lower is not None and (length < lower)) - or (upper is not None and length > upper)) - if failure: - raise ValueError(f"{_validate_error_prefix(self)}: {message}") + _length_bounds_check(self, bounds, length, name) def _validate(self, val): super()._validate(val) @@ -3487,6 +3502,219 @@ def deserialize(cls, value): return pandas.DataFrame(value) +def _get_narwhals(): + """Import and return the optional ``narwhals`` dependency. + + Deferred so ``param`` keeps no hard dependency on ``narwhals`` (the same + way pandas is deferred for :class:`DataFrame`). Raises ``ModuleNotFoundError`` + naming ``narwhals`` if it is not installed. + """ + import narwhals + return narwhals + + +class DataFrameLike(ClassSelector[t.Any]): + """ + Parameter whose value is any dataframe-like object that Narwhals recognises. + + Unlike :class:`DataFrame`, which is restricted to ``pandas.DataFrame``, + ``DataFrameLike`` accepts pandas, Polars, PyArrow, cuDF, Modin and any + other backend supported by `Narwhals `_. + The value is passed through unchanged, so reading the parameter returns + the original native object (no Narwhals wrapper). Authors who want a + backend-agnostic API can call ``narwhals.from_native`` on the value + themselves. + + Narwhals is an optional dependency, imported on instantiation. The + structure of the frame can be constrained by the rows and columns + arguments: + + ``rows``: If specified, may be a number or an integer bounds tuple to + constrain the allowable number of rows. Skipped for lazy frames. + + ``columns``: If specified, may be a number, an integer bounds tuple, a + list or a set. If the argument is numeric, constrains the number of + columns using the same semantics as used for rows. If either a list + or set of strings, the column names will be validated. If a set is + used, the supplied frame must contain the specified columns and if a + list is given, the supplied frame must contain exactly the same + columns and in the same order and no other columns. + + ``allow_lazy``: By default only eager frames are accepted. Set + ``allow_lazy=True`` to also accept lazy frames (Polars ``LazyFrame``, + Dask, DuckDB). Row-count validation is skipped for lazy frames so the + frame is never implicitly collected. + """ + + __slots__ = ['rows', 'columns', 'ordered', 'allow_lazy'] + + _slot_defaults = { + **ClassSelector._slot_defaults, + 'rows': None, + 'columns': None, + 'ordered': None, + 'allow_lazy': False, + } + + if t.TYPE_CHECKING: + + @t.overload + def __init__( + self: DataFrameLike, + default: t.Any = None, + *, + allow_None: t.Literal[False] = False, + doc: str | None = None, + label: str | None = None, + precedence: float | None = None, + instantiate: bool = True, + constant: bool = False, + readonly: bool = False, + pickle_default_value: bool = True, + per_instance: bool = True, + allow_refs: bool = False, + nested_refs: bool = False, + default_factory: t.Callable[[], t.Any] | None = None, + metadata: dict[str, t.Any] | None = None, + ) -> None: + ... + + @t.overload + def __init__( + self: DataFrameLike, + default: t.Any | None = None, + *, + allow_None: t.Literal[True] = True, + **kwargs: Unpack[_DataFrameLikeInitKwargs] + ) -> None: + ... + + @t.overload + def __init__( + self: DataFrameLike, + default: None = None, + *, + allow_None: t.Literal[False] = False, + **kwargs: Unpack[_DataFrameLikeInitKwargs] + ) -> None: + ... + + def __init__( + self, + default: t.Any | None = t.cast("t.Any | None", Undefined), # pyrefly: ignore[bad-argument-type] + *, + rows: int | tuple[int | None, int | None] | None = t.cast("int | tuple[int | None, int | None] | None", Undefined), # pyrefly: ignore[bad-argument-type] + columns: int | tuple[int | None, int | None] | list[str] | set[str] | None = t.cast("int | tuple[int | None, int | None] | list[str] | set[str] | None", Undefined), # pyrefly: ignore[bad-argument-type] + ordered: bool | None = t.cast("bool | None", Undefined), # pyrefly: ignore[bad-argument-type] + allow_lazy: bool | None = t.cast("bool | None", Undefined), # pyrefly: ignore[bad-argument-type] + allow_None: bool = t.cast("bool", Undefined), # pyrefly: ignore[bad-argument-type] + **params: Unpack[_ParameterKwargs] + ) -> None: + _get_narwhals() + object.__setattr__(self, 'rows', rows) + object.__setattr__(self, 'columns', columns) + object.__setattr__(self, 'ordered', ordered) + object.__setattr__(self, 'allow_lazy', allow_lazy) + super().__init__( # type: ignore[misc, call-overload] + default=default, # type: ignore[arg-type] + class_=object, # type: ignore[arg-type] + is_instance=True, + allow_None=allow_None, # type: ignore[arg-type] + **params, + ) + self._validate(self.default) + + def _as_narwhals(self, val): + narwhals = _get_narwhals() + try: + return narwhals.from_native( + val, eager_only=not self.allow_lazy, pass_through=False + ) + except TypeError as e: + kind = 'a dataframe-like' if self.allow_lazy else 'an eager dataframe-like' + raise ValueError( + f"{_validate_error_prefix(self)} value must be {kind} object " + f"that Narwhals recognises (pandas, Polars, PyArrow, cuDF, " + f"Modin), not {type(val).__name__!r}." + ) from e + + def _validate(self, val): + super()._validate(val) + + if isinstance(self.columns, set) and self.ordered is True: + raise ValueError( + f'{_validate_error_prefix(self)}: columns cannot be ordered ' + f'when specified as a set' + ) + + if val is None: + # class_=object means ClassSelector accepts None even when + # allow_None is False, so reject it explicitly here. + if self.allow_None: + return + raise ValueError( + f"{_validate_error_prefix(self)} value must be a dataframe-like " + f"object that Narwhals recognises, not None." + ) + + nwframe = self._as_narwhals(val) + narwhals = _get_narwhals() + is_lazy = isinstance(nwframe, narwhals.LazyFrame) + # ``collect_schema().names()`` is the Narwhals-recommended way to read + # column names uniformly across eager and lazy frames; ``.columns`` on + # a lazy frame triggers a backend schema-resolution warning. + cols = list(nwframe.collect_schema().names()) + + if self.columns is None: + pass + elif (isinstance(self.columns, tuple) and len(self.columns)==2 + and all(isinstance(v, (type(None), numbers.Number)) for v in self.columns)): # Numeric bounds tuple + _length_bounds_check(self, self.columns, len(cols), 'columns') + elif isinstance(self.columns, (list, set)): + self.ordered = isinstance(self.columns, list) if self.ordered is None else self.ordered + difference = set(self.columns) - {str(el) for el in cols} + if difference: + raise ValueError( + f"{_validate_error_prefix(self)}: provided columns " + f"{cols} does not contain required " + f"columns {sorted(self.columns)}" + ) + else: + _length_bounds_check(self, self.columns, len(cols), 'column') + + if self.ordered and isinstance(self.columns, Iterable): + if cols != list(self.columns): + raise ValueError( + f"{_validate_error_prefix(self)}: provided columns " + f"{cols} must exactly match {self.columns}" + ) + # Row count requires materialising a lazy frame; skip for lazy so + # the frame is never implicitly collected. + if self.rows is not None and not is_lazy: + _length_bounds_check(self, self.rows, nwframe.shape[0], 'row') + + @classmethod + def serialize(cls, value): + # Backend-neutral list-of-records via Narwhals, so JSON output does + # not depend on the original library. A lazy frame must be collected + # here (unlike validation) because a computation graph cannot be + # serialized. + if value is None: + return None + narwhals = _get_narwhals() + nwframe = narwhals.from_native(value) + if isinstance(nwframe, narwhals.LazyFrame): + nwframe = nwframe.collect() + return nwframe.rows(named=True) + + @classmethod + def deserialize(cls, value): + # JSON carries no backend information, so deserialization lands on + # pandas (the universal default), exactly like DataFrame. Callers + # needing another backend can reconstruct from the records form. + return DataFrame.deserialize(value) + + class Series(ClassSelector["ST"]): """ Parameter whose value is a pandas ``Series``. diff --git a/pixi.toml b/pixi.toml index a62a99629..c21175418 100644 --- a/pixi.toml +++ b/pixi.toml @@ -130,11 +130,13 @@ test-unit = 'pytest tests' cloudpickle = "*" ipython = "*" jsonschema = "*" +narwhals = "*" nest-asyncio = "*" numpy = "*" odfpy = "*" openpyxl = "*" pandas = "*" +polars = "*" pyarrow = "*" pytables = "*" xlrd = "*" diff --git a/pyproject.toml b/pyproject.toml index 6f91c5f24..b7a3ba9f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,8 @@ tests-full = [ "param[tests-deser]", "numpy", "pandas", + "narwhals", + "polars", "ipython", "jsonschema", "gmpy2", diff --git a/tests/testdataframelike.py b/tests/testdataframelike.py new file mode 100644 index 000000000..0661d7a30 --- /dev/null +++ b/tests/testdataframelike.py @@ -0,0 +1,258 @@ +"""Test the DataFrameLike Parameter (cross-backend via Narwhals).""" +import os +import unittest + +import param +import pytest + +from .utils import check_defaults + +try: + import narwhals # noqa: F401 +except ModuleNotFoundError: + if os.getenv('PARAM_TEST_NARWHALS', '0') == '1': + raise ImportError("PARAM_TEST_NARWHALS=1 but narwhals not available.") + else: + raise unittest.SkipTest("narwhals not available") + +try: + import pandas as pd +except ModuleNotFoundError: + pd = None + +try: + import polars as pl +except ModuleNotFoundError: + pl = None + +try: + import pyarrow as pa +except ModuleNotFoundError: + pa = None + +skip_no_pandas = pytest.mark.skipif(pd is None, reason="pandas not available") +skip_no_polars = pytest.mark.skipif(pl is None, reason="polars not available") +skip_no_pyarrow = pytest.mark.skipif(pa is None, reason="pyarrow not available") + + +class TestDataFrameLikeDefaults(unittest.TestCase): + + def test_defaults_class(self): + class P(param.Parameterized): + df = param.DataFrameLike() + + check_defaults(P.param.df, label='Df', skip=['instantiate']) + + def test_defaults_inst(self): + class P(param.Parameterized): + df = param.DataFrameLike() + + check_defaults(P().param.df, label='Df', skip=['instantiate']) + + +@skip_no_pandas +class TestDataFrameLikeAccepts(unittest.TestCase): + + def test_pandas(self): + class P(param.Parameterized): + df = param.DataFrameLike(default=pd.DataFrame({'a': [1, 2]})) + src = pd.DataFrame({'a': [3, 4]}) + p = P(df=src) + # Value is passed through unchanged (no Narwhals wrapper). + self.assertIs(p.df, src) + + @skip_no_polars + def test_polars_eager(self): + class P(param.Parameterized): + df = param.DataFrameLike(default=pd.DataFrame({'a': [1]})) + src = pl.DataFrame({'a': [1, 2]}) + p = P(df=src) + self.assertIs(p.df, src) + self.assertIsInstance(p.df, pl.DataFrame) + + @skip_no_pyarrow + def test_pyarrow(self): + class P(param.Parameterized): + df = param.DataFrameLike(default=pd.DataFrame({'a': [1]})) + src = pa.table({'a': [1, 2]}) + self.assertIs(P(df=src).df, src) + + +@skip_no_pandas +class TestDataFrameLikeRejects(unittest.TestCase): + + def setUp(self): + class P(param.Parameterized): + df = param.DataFrameLike(default=pd.DataFrame({'a': [1]})) + self.P = P + + def test_list(self): + with self.assertRaises(ValueError): + self.P(df=[1, 2, 3]) + + def test_dict(self): + with self.assertRaises(ValueError): + self.P(df={'a': [1]}) + + def test_str(self): + with self.assertRaises(ValueError): + self.P(df='not a frame') + + def test_series(self): + with self.assertRaises(ValueError): + self.P(df=pd.Series([1, 2, 3])) + + def test_none_without_allow_none(self): + with self.assertRaises(ValueError): + self.P(df=None) + + def test_none_with_allow_none(self): + class Q(param.Parameterized): + df = param.DataFrameLike(default=None, allow_None=True) + self.assertIsNone(Q(df=None).df) + + +@skip_no_pandas +class TestDataFrameLikeRows(unittest.TestCase): + + def test_rows_exact_ok(self): + class P(param.Parameterized): + df = param.DataFrameLike(default=pd.DataFrame({'a': [1, 2, 3]}), rows=3) + P(df=pd.DataFrame({'a': [4, 5, 6]})) + + def test_rows_exact_mismatch(self): + class P(param.Parameterized): + df = param.DataFrameLike(default=pd.DataFrame({'a': [1, 2, 3]}), rows=3) + with self.assertRaises(ValueError): + P(df=pd.DataFrame({'a': [1, 2]})) + + def test_rows_bounds(self): + class P(param.Parameterized): + df = param.DataFrameLike(default=pd.DataFrame({'a': [1, 2]}), rows=(1, 4)) + P(df=pd.DataFrame({'a': [1, 2, 3, 4]})) + with self.assertRaises(ValueError): + P(df=pd.DataFrame({'a': list(range(5))})) + + @skip_no_polars + def test_rows_polars(self): + class P(param.Parameterized): + df = param.DataFrameLike(default=pd.DataFrame({'a': [1, 2]}), rows=2) + P(df=pl.DataFrame({'a': [9, 8]})) + with self.assertRaises(ValueError): + P(df=pl.DataFrame({'a': [1]})) + + +@skip_no_pandas +class TestDataFrameLikeColumns(unittest.TestCase): + + def test_set_subset_ok(self): + class P(param.Parameterized): + df = param.DataFrameLike( + default=pd.DataFrame({'a': [1], 'b': [2]}), columns={'a'}) + P(df=pd.DataFrame({'a': [1], 'b': [2], 'c': [3]})) + + def test_set_missing_column(self): + class P(param.Parameterized): + df = param.DataFrameLike( + default=pd.DataFrame({'a': [1]}), columns={'a'}) + with self.assertRaises(ValueError): + P(df=pd.DataFrame({'x': [1]})) + + def test_list_exact_ordered(self): + class P(param.Parameterized): + df = param.DataFrameLike( + default=pd.DataFrame({'a': [1], 'b': [2]}), columns=['a', 'b']) + P(df=pd.DataFrame({'a': [1], 'b': [2]})) + with self.assertRaises(ValueError): + P(df=pd.DataFrame({'b': [2], 'a': [1]})) + + def test_columns_numeric_bounds(self): + class P(param.Parameterized): + df = param.DataFrameLike( + default=pd.DataFrame({'a': [1], 'b': [2]}), columns=(1, 3)) + P(df=pd.DataFrame({'a': [1], 'b': [2], 'c': [3]})) + with self.assertRaises(ValueError): + P(df=pd.DataFrame({c: [1] for c in 'abcd'})) + + def test_set_with_ordered_raises(self): + with self.assertRaises(ValueError): + class P(param.Parameterized): + df = param.DataFrameLike( + default=pd.DataFrame({'a': [1]}), + columns={'a'}, ordered=True) + + +@skip_no_pandas +@skip_no_polars +class TestDataFrameLikeAllowLazy(unittest.TestCase): + + def test_lazy_rejected_by_default(self): + class P(param.Parameterized): + df = param.DataFrameLike(default=pd.DataFrame({'a': [1]})) + with self.assertRaises(ValueError): + P(df=pl.LazyFrame({'a': [1, 2]})) + + def test_lazy_accepted_when_allowed(self): + class P(param.Parameterized): + df = param.DataFrameLike( + default=pd.DataFrame({'a': [1]}), allow_lazy=True) + src = pl.LazyFrame({'a': [1, 2]}) + self.assertIs(P(df=src).df, src) + + def test_lazy_columns_still_validated(self): + class P(param.Parameterized): + df = param.DataFrameLike( + default=pd.DataFrame({'a': [1]}), + columns={'a'}, allow_lazy=True) + P(df=pl.LazyFrame({'a': [1], 'b': [2]})) + with self.assertRaises(ValueError): + P(df=pl.LazyFrame({'x': [1]})) + + def test_lazy_rows_skipped_no_collect(self): + # rows=2 would fail if collected (lazy frame has 3 rows); it must be + # skipped for lazy frames so .collect() is never called implicitly. + class P(param.Parameterized): + df = param.DataFrameLike( + default=pd.DataFrame({'a': [1, 2]}), + rows=2, allow_lazy=True) + lazy = pl.LazyFrame({'a': [1, 2, 3]}) + collected = {'called': False} + orig_collect = pl.LazyFrame.collect + + def spy(self, *a, **k): + collected['called'] = True + return orig_collect(self, *a, **k) + + pl.LazyFrame.collect = spy + try: + P(df=lazy) + finally: + pl.LazyFrame.collect = orig_collect + self.assertFalse(collected['called'], "lazy frame was implicitly collected") + + +@skip_no_pandas +class TestDataFrameLikeSerialize(unittest.TestCase): + + def test_serialize_none(self): + self.assertIsNone(param.DataFrameLike.serialize(None)) + + def test_serialize_records(self): + recs = param.DataFrameLike.serialize(pd.DataFrame({'a': [1, 2], 'b': ['x', 'y']})) + self.assertEqual(recs, [{'a': 1, 'b': 'x'}, {'a': 2, 'b': 'y'}]) + + @skip_no_polars + def test_serialize_backend_neutral(self): + recs_pd = param.DataFrameLike.serialize(pd.DataFrame({'a': [1, 2]})) + recs_pl = param.DataFrameLike.serialize(pl.DataFrame({'a': [1, 2]})) + self.assertEqual(recs_pd, recs_pl) + + @skip_no_polars + def test_serialize_lazy_collected(self): + recs = param.DataFrameLike.serialize(pl.LazyFrame({'a': [1, 2]})) + self.assertEqual(recs, [{'a': 1}, {'a': 2}]) + + def test_deserialize_roundtrip(self): + recs = param.DataFrameLike.serialize(pd.DataFrame({'a': [1, 2]})) + back = param.DataFrameLike.deserialize(recs) + self.assertEqual(back.to_dict('records'), recs) From dad6d8472af2a6b0972cfd12b88aabd5a75b28e9 Mon Sep 17 00:00:00 2001 From: ghostiee-11 <168410465+ghostiee-11@users.noreply.github.com> Date: Sun, 17 May 2026 16:59:07 +0530 Subject: [PATCH 2/5] Harden DataFrameLike for production * Raise a clear ImportError naming the install command when the optional narwhals package is missing, instead of a bare ModuleNotFoundError (declaration-time fail-fast, matching how DataFrame fails on missing pandas). * Document the serialization asymmetry (backend-neutral records out, pandas in) and that cuDF/Modin are Narwhals-supported but not run in CI (cuDF is GPU-only, Modin's pinned deps conflict with the test environment). * Annotate the inherited in-place ordered defaulting as deliberate DataFrame parity. * Add a skip-guarded Modin test and add narwhals + polars to the type-check environment so pyright validates the Narwhals API rather than skipping an unresolved import. --- param/parameters.py | 34 ++++++++++++++++++++++++++++------ pixi.toml | 2 ++ tests/testdataframelike.py | 20 ++++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/param/parameters.py b/param/parameters.py index 052e54101..7d987cfea 100644 --- a/param/parameters.py +++ b/param/parameters.py @@ -3506,10 +3506,17 @@ def _get_narwhals(): """Import and return the optional ``narwhals`` dependency. Deferred so ``param`` keeps no hard dependency on ``narwhals`` (the same - way pandas is deferred for :class:`DataFrame`). Raises ``ModuleNotFoundError`` - naming ``narwhals`` if it is not installed. + way pandas is deferred for :class:`DataFrame`). Raises a clear + ``ImportError`` naming the feature and the install command if ``narwhals`` + is not available. """ - import narwhals + try: + import narwhals + except ModuleNotFoundError as e: + raise ImportError( + "param.DataFrameLike requires the optional 'narwhals' package. " + "Install it with: pip install narwhals" + ) from e return narwhals @@ -3518,14 +3525,18 @@ class DataFrameLike(ClassSelector[t.Any]): Parameter whose value is any dataframe-like object that Narwhals recognises. Unlike :class:`DataFrame`, which is restricted to ``pandas.DataFrame``, - ``DataFrameLike`` accepts pandas, Polars, PyArrow, cuDF, Modin and any - other backend supported by `Narwhals `_. + ``DataFrameLike`` accepts any object supported by + `Narwhals `_. pandas, Polars and PyArrow + are exercised in this project's test suite; Modin and cuDF are supported + through the same Narwhals code path but are not run in CI (Modin's pinned + dependencies conflict with the test environment and cuDF is GPU-only). The value is passed through unchanged, so reading the parameter returns the original native object (no Narwhals wrapper). Authors who want a backend-agnostic API can call ``narwhals.from_native`` on the value themselves. - Narwhals is an optional dependency, imported on instantiation. The + Narwhals is an optional dependency, imported on instantiation; a clear + ``ImportError`` with the install command is raised if it is missing. The structure of the frame can be constrained by the rows and columns arguments: @@ -3544,6 +3555,13 @@ class DataFrameLike(ClassSelector[t.Any]): ``allow_lazy=True`` to also accept lazy frames (Polars ``LazyFrame``, Dask, DuckDB). Row-count validation is skipped for lazy frames so the frame is never implicitly collected. + + Serialization is intentionally backend-neutral: ``serialize`` emits a + list of records via Narwhals (a lazy frame is collected at this point), + and ``deserialize`` reconstructs a ``pandas.DataFrame`` because JSON + carries no backend information. Round-tripping therefore does not + preserve a non-pandas backend; callers needing another backend can + rebuild from the records form. """ __slots__ = ['rows', 'columns', 'ordered', 'allow_lazy'] @@ -3671,6 +3689,10 @@ def _validate(self, val): and all(isinstance(v, (type(None), numbers.Number)) for v in self.columns)): # Numeric bounds tuple _length_bounds_check(self, self.columns, len(cols), 'columns') elif isinstance(self.columns, (list, set)): + # Mirrors DataFrame._validate exactly (including this in-place + # ``ordered`` defaulting) so the two classes behave identically; + # cleaning up the slot mutation is deferred to a cross-cutting + # change that touches both. self.ordered = isinstance(self.columns, list) if self.ordered is None else self.ordered difference = set(self.columns) - {str(el) for el in cols} if difference: diff --git a/pixi.toml b/pixi.toml index c21175418..72c880a1a 100644 --- a/pixi.toml +++ b/pixi.toml @@ -209,8 +209,10 @@ lint-install = 'pre-commit install' [feature.type.dependencies] ty = "==0.0.34" mypy = "*" +narwhals = "*" numpy = "*" pandas = "*" +polars = "*" pyrefly = "*" pyright = "*" IPython = "*" diff --git a/tests/testdataframelike.py b/tests/testdataframelike.py index 0661d7a30..ea4f941e0 100644 --- a/tests/testdataframelike.py +++ b/tests/testdataframelike.py @@ -30,9 +30,17 @@ except ModuleNotFoundError: pa = None +try: + import modin.pandas as mpd +except Exception: + # modin import can fail for reasons beyond ModuleNotFoundError (missing + # execution engine), so guard broadly and skip rather than error. + mpd = None + skip_no_pandas = pytest.mark.skipif(pd is None, reason="pandas not available") skip_no_polars = pytest.mark.skipif(pl is None, reason="polars not available") skip_no_pyarrow = pytest.mark.skipif(pa is None, reason="pyarrow not available") +skip_no_modin = pytest.mark.skipif(mpd is None, reason="modin not available") class TestDataFrameLikeDefaults(unittest.TestCase): @@ -77,6 +85,18 @@ class P(param.Parameterized): src = pa.table({'a': [1, 2]}) self.assertIs(P(df=src).df, src) + @skip_no_modin + def test_modin(self): + class P(param.Parameterized): + df = param.DataFrameLike( + default=pd.DataFrame({'a': [1]}), rows=2, columns={'a'}) + src = mpd.DataFrame({'a': [1, 2]}) + p = P(df=src) + self.assertIs(p.df, src) + self.assertIsInstance(p.df, mpd.DataFrame) + with self.assertRaises(ValueError): + P(df=mpd.DataFrame({'a': [1]})) # rows=2 mismatch + @skip_no_pandas class TestDataFrameLikeRejects(unittest.TestCase): From bdabbc11d3575c15d5fbba292c6c1be279df7a08 Mon Sep 17 00:00:00 2001 From: ghostiee-11 <168410465+ghostiee-11@users.noreply.github.com> Date: Sun, 17 May 2026 17:15:45 +0530 Subject: [PATCH 3/5] Scope DataFrameLike claims to tested backends Remove cuDF/Modin name-drops from the docstring, error message and tests. They are reachable through Narwhals like any other backend but are not exercised here (no GPU; Modin's pinned deps conflict), so naming them as features overclaims. The validation path is described generically as "any Narwhals-supported backend" with pandas, Polars and PyArrow as the tested set. Drops the permanently-skipped Modin test. --- param/parameters.py | 16 +++++++--------- tests/testdataframelike.py | 20 -------------------- 2 files changed, 7 insertions(+), 29 deletions(-) diff --git a/param/parameters.py b/param/parameters.py index 7d987cfea..6608dc8ad 100644 --- a/param/parameters.py +++ b/param/parameters.py @@ -3527,13 +3527,11 @@ class DataFrameLike(ClassSelector[t.Any]): Unlike :class:`DataFrame`, which is restricted to ``pandas.DataFrame``, ``DataFrameLike`` accepts any object supported by `Narwhals `_. pandas, Polars and PyArrow - are exercised in this project's test suite; Modin and cuDF are supported - through the same Narwhals code path but are not run in CI (Modin's pinned - dependencies conflict with the test environment and cuDF is GPU-only). - The value is passed through unchanged, so reading the parameter returns - the original native object (no Narwhals wrapper). Authors who want a - backend-agnostic API can call ``narwhals.from_native`` on the value - themselves. + are exercised in this project's test suite; any other Narwhals-supported + backend uses the identical code path. The value is passed through + unchanged, so reading the parameter returns the original native object + (no Narwhals wrapper). Authors who want a backend-agnostic API can call + ``narwhals.from_native`` on the value themselves. Narwhals is an optional dependency, imported on instantiation; a clear ``ImportError`` with the install command is raised if it is missing. The @@ -3652,8 +3650,8 @@ def _as_narwhals(self, val): kind = 'a dataframe-like' if self.allow_lazy else 'an eager dataframe-like' raise ValueError( f"{_validate_error_prefix(self)} value must be {kind} object " - f"that Narwhals recognises (pandas, Polars, PyArrow, cuDF, " - f"Modin), not {type(val).__name__!r}." + f"that Narwhals recognises (pandas, Polars, PyArrow, ...), " + f"not {type(val).__name__!r}." ) from e def _validate(self, val): diff --git a/tests/testdataframelike.py b/tests/testdataframelike.py index ea4f941e0..0661d7a30 100644 --- a/tests/testdataframelike.py +++ b/tests/testdataframelike.py @@ -30,17 +30,9 @@ except ModuleNotFoundError: pa = None -try: - import modin.pandas as mpd -except Exception: - # modin import can fail for reasons beyond ModuleNotFoundError (missing - # execution engine), so guard broadly and skip rather than error. - mpd = None - skip_no_pandas = pytest.mark.skipif(pd is None, reason="pandas not available") skip_no_polars = pytest.mark.skipif(pl is None, reason="polars not available") skip_no_pyarrow = pytest.mark.skipif(pa is None, reason="pyarrow not available") -skip_no_modin = pytest.mark.skipif(mpd is None, reason="modin not available") class TestDataFrameLikeDefaults(unittest.TestCase): @@ -85,18 +77,6 @@ class P(param.Parameterized): src = pa.table({'a': [1, 2]}) self.assertIs(P(df=src).df, src) - @skip_no_modin - def test_modin(self): - class P(param.Parameterized): - df = param.DataFrameLike( - default=pd.DataFrame({'a': [1]}), rows=2, columns={'a'}) - src = mpd.DataFrame({'a': [1, 2]}) - p = P(df=src) - self.assertIs(p.df, src) - self.assertIsInstance(p.df, mpd.DataFrame) - with self.assertRaises(ValueError): - P(df=mpd.DataFrame({'a': [1]})) # rows=2 mismatch - @skip_no_pandas class TestDataFrameLikeRejects(unittest.TestCase): From b15929af2176fd1568a1e625d219843261021955 Mon Sep 17 00:00:00 2001 From: ghostiee-11 <168410465+ghostiee-11@users.noreply.github.com> Date: Thu, 28 May 2026 12:51:07 +0530 Subject: [PATCH 4/5] Address review feedback on DataFrameLike * Move _get_narwhals to _utils and use narwhals.stable.v2 * Rename allow_lazy to eager_only (default True) * Validate row count on LazyFrame via narwhals .count() instead of skipping * Skip collect_schema() unless columns/ordered or a lazy row check needs it * Compact docstring, fix narwhals URL, drop noisy inline comment * Rewrite tests as plain pytest functions, drop PARAM_TEST_NARWHALS env var, scope lazy tests to polars --- param/_utils.py | 12 +++ param/parameters.py | 100 +++++++------------ tests/testdataframelike.py | 200 +++++++++++++++++-------------------- 3 files changed, 141 insertions(+), 171 deletions(-) diff --git a/param/_utils.py b/param/_utils.py index 11870b935..84fbcfc24 100644 --- a/param/_utils.py +++ b/param/_utils.py @@ -699,3 +699,15 @@ def _find_stack_level() -> int: # https://docs.python.org/3/library/inspect.html#inspect.Traceback del frame return n + + +def _get_narwhals(): + """Import and return the optional ``narwhals`` stable API.""" + try: + import narwhals.stable.v2 as narwhals + except ModuleNotFoundError as e: + raise ImportError( + "param.DataFrameLike requires the optional 'narwhals' package. " + "Install it with: pip install narwhals" + ) from e + return narwhals diff --git a/param/parameters.py b/param/parameters.py index 6608dc8ad..bc9abee20 100644 --- a/param/parameters.py +++ b/param/parameters.py @@ -45,6 +45,7 @@ _find_stack_level, _validate_error_prefix, _deserialize_from_path, + _get_narwhals, _named_objs, _produce_value, _get_min_max_value, @@ -118,7 +119,7 @@ class _DataFrameLikeInitKwargs(_ParameterKwargs, total=False): rows: int | tuple[int | None, int | None] | None columns: int | tuple[int | None, int | None] | list[str] | set[str] | None ordered: bool | None - allow_lazy: bool | None + eager_only: bool | None class _SeriesInitKwargs(_ParameterKwargs, total=False): rows: int | tuple[int | None, int | None] | None @@ -3502,74 +3503,38 @@ def deserialize(cls, value): return pandas.DataFrame(value) -def _get_narwhals(): - """Import and return the optional ``narwhals`` dependency. - - Deferred so ``param`` keeps no hard dependency on ``narwhals`` (the same - way pandas is deferred for :class:`DataFrame`). Raises a clear - ``ImportError`` naming the feature and the install command if ``narwhals`` - is not available. - """ - try: - import narwhals - except ModuleNotFoundError as e: - raise ImportError( - "param.DataFrameLike requires the optional 'narwhals' package. " - "Install it with: pip install narwhals" - ) from e - return narwhals - - class DataFrameLike(ClassSelector[t.Any]): """ Parameter whose value is any dataframe-like object that Narwhals recognises. Unlike :class:`DataFrame`, which is restricted to ``pandas.DataFrame``, ``DataFrameLike`` accepts any object supported by - `Narwhals `_. pandas, Polars and PyArrow - are exercised in this project's test suite; any other Narwhals-supported - backend uses the identical code path. The value is passed through - unchanged, so reading the parameter returns the original native object - (no Narwhals wrapper). Authors who want a backend-agnostic API can call - ``narwhals.from_native`` on the value themselves. - - Narwhals is an optional dependency, imported on instantiation; a clear - ``ImportError`` with the install command is raised if it is missing. The - structure of the frame can be constrained by the rows and columns - arguments: + `Narwhals `_ (pandas, Polars, + PyArrow, ...). The native value is passed through unchanged; authors who + want a backend-agnostic API can call ``narwhals.from_native`` themselves. - ``rows``: If specified, may be a number or an integer bounds tuple to - constrain the allowable number of rows. Skipped for lazy frames. + ``rows``: number or ``(lower, upper)`` bounds on row count. - ``columns``: If specified, may be a number, an integer bounds tuple, a - list or a set. If the argument is numeric, constrains the number of - columns using the same semantics as used for rows. If either a list - or set of strings, the column names will be validated. If a set is - used, the supplied frame must contain the specified columns and if a - list is given, the supplied frame must contain exactly the same - columns and in the same order and no other columns. - - ``allow_lazy``: By default only eager frames are accepted. Set - ``allow_lazy=True`` to also accept lazy frames (Polars ``LazyFrame``, - Dask, DuckDB). Row-count validation is skipped for lazy frames so the - frame is never implicitly collected. - - Serialization is intentionally backend-neutral: ``serialize`` emits a - list of records via Narwhals (a lazy frame is collected at this point), - and ``deserialize`` reconstructs a ``pandas.DataFrame`` because JSON - carries no backend information. Round-tripping therefore does not - preserve a non-pandas backend; callers needing another backend can - rebuild from the records form. + ``columns``: number, ``(lower, upper)`` bounds, a list (exact columns, + same order unless ``ordered=False``), or a set (required subset). + + ``eager_only``: when ``True`` (default), reject lazy frames. Set + ``eager_only=False`` to also accept lazy frames (Polars ``LazyFrame``, + Dask, DuckDB); row counts on lazy frames are validated through a scalar + ``count()`` collect rather than materialising the frame. + + Serialization emits a list of records via Narwhals; ``deserialize`` + reconstructs a ``pandas.DataFrame`` because JSON carries no backend. """ - __slots__ = ['rows', 'columns', 'ordered', 'allow_lazy'] + __slots__ = ['rows', 'columns', 'ordered', 'eager_only'] _slot_defaults = { **ClassSelector._slot_defaults, 'rows': None, 'columns': None, 'ordered': None, - 'allow_lazy': False, + 'eager_only': True, } if t.TYPE_CHECKING: @@ -3622,7 +3587,7 @@ def __init__( rows: int | tuple[int | None, int | None] | None = t.cast("int | tuple[int | None, int | None] | None", Undefined), # pyrefly: ignore[bad-argument-type] columns: int | tuple[int | None, int | None] | list[str] | set[str] | None = t.cast("int | tuple[int | None, int | None] | list[str] | set[str] | None", Undefined), # pyrefly: ignore[bad-argument-type] ordered: bool | None = t.cast("bool | None", Undefined), # pyrefly: ignore[bad-argument-type] - allow_lazy: bool | None = t.cast("bool | None", Undefined), # pyrefly: ignore[bad-argument-type] + eager_only: bool | None = t.cast("bool | None", Undefined), # pyrefly: ignore[bad-argument-type] allow_None: bool = t.cast("bool", Undefined), # pyrefly: ignore[bad-argument-type] **params: Unpack[_ParameterKwargs] ) -> None: @@ -3630,7 +3595,7 @@ def __init__( object.__setattr__(self, 'rows', rows) object.__setattr__(self, 'columns', columns) object.__setattr__(self, 'ordered', ordered) - object.__setattr__(self, 'allow_lazy', allow_lazy) + object.__setattr__(self, 'eager_only', eager_only) super().__init__( # type: ignore[misc, call-overload] default=default, # type: ignore[arg-type] class_=object, # type: ignore[arg-type] @@ -3644,10 +3609,10 @@ def _as_narwhals(self, val): narwhals = _get_narwhals() try: return narwhals.from_native( - val, eager_only=not self.allow_lazy, pass_through=False + val, eager_only=self.eager_only, pass_through=False ) except TypeError as e: - kind = 'a dataframe-like' if self.allow_lazy else 'an eager dataframe-like' + kind = 'an eager dataframe-like' if self.eager_only else 'a dataframe-like' raise ValueError( f"{_validate_error_prefix(self)} value must be {kind} object " f"that Narwhals recognises (pandas, Polars, PyArrow, ...), " @@ -3676,10 +3641,9 @@ def _validate(self, val): nwframe = self._as_narwhals(val) narwhals = _get_narwhals() is_lazy = isinstance(nwframe, narwhals.LazyFrame) - # ``collect_schema().names()`` is the Narwhals-recommended way to read - # column names uniformly across eager and lazy frames; ``.columns`` on - # a lazy frame triggers a backend schema-resolution warning. - cols = list(nwframe.collect_schema().names()) + need_cols = self.columns is not None or self.ordered + schema = nwframe.collect_schema() if (need_cols or (self.rows is not None and is_lazy)) else None + cols = list(schema.names()) if (schema is not None and need_cols) else None if self.columns is None: pass @@ -3708,10 +3672,16 @@ def _validate(self, val): f"{_validate_error_prefix(self)}: provided columns " f"{cols} must exactly match {self.columns}" ) - # Row count requires materialising a lazy frame; skip for lazy so - # the frame is never implicitly collected. - if self.rows is not None and not is_lazy: - _length_bounds_check(self, self.rows, nwframe.shape[0], 'row') + if self.rows is not None: + if is_lazy: + first = next(iter(schema.names()), None) + n = ( + nwframe.select(narwhals.col(first).count()).collect().item() + if first is not None else 0 + ) + else: + n = nwframe.shape[0] + _length_bounds_check(self, self.rows, n, 'row') @classmethod def serialize(cls, value): diff --git a/tests/testdataframelike.py b/tests/testdataframelike.py index 0661d7a30..c21648650 100644 --- a/tests/testdataframelike.py +++ b/tests/testdataframelike.py @@ -1,19 +1,11 @@ """Test the DataFrameLike Parameter (cross-backend via Narwhals).""" -import os -import unittest +import pytest import param -import pytest from .utils import check_defaults -try: - import narwhals # noqa: F401 -except ModuleNotFoundError: - if os.getenv('PARAM_TEST_NARWHALS', '0') == '1': - raise ImportError("PARAM_TEST_NARWHALS=1 but narwhals not available.") - else: - raise unittest.SkipTest("narwhals not available") +pytest.importorskip("narwhals") try: import pandas as pd @@ -35,85 +27,91 @@ skip_no_pyarrow = pytest.mark.skipif(pa is None, reason="pyarrow not available") -class TestDataFrameLikeDefaults(unittest.TestCase): +def test_defaults_class(): + class P(param.Parameterized): + df = param.DataFrameLike() - def test_defaults_class(self): - class P(param.Parameterized): - df = param.DataFrameLike() + check_defaults(P.param.df, label='Df', skip=['instantiate']) - check_defaults(P.param.df, label='Df', skip=['instantiate']) - def test_defaults_inst(self): - class P(param.Parameterized): - df = param.DataFrameLike() +def test_defaults_inst(): + class P(param.Parameterized): + df = param.DataFrameLike() - check_defaults(P().param.df, label='Df', skip=['instantiate']) + check_defaults(P().param.df, label='Df', skip=['instantiate']) @skip_no_pandas -class TestDataFrameLikeAccepts(unittest.TestCase): +def test_accepts_pandas(): + class P(param.Parameterized): + df = param.DataFrameLike(default=pd.DataFrame({'a': [1, 2]})) + src = pd.DataFrame({'a': [3, 4]}) + p = P(df=src) + # Value is passed through unchanged (no Narwhals wrapper). + assert p.df is src - def test_pandas(self): - class P(param.Parameterized): - df = param.DataFrameLike(default=pd.DataFrame({'a': [1, 2]})) - src = pd.DataFrame({'a': [3, 4]}) - p = P(df=src) - # Value is passed through unchanged (no Narwhals wrapper). - self.assertIs(p.df, src) - @skip_no_polars - def test_polars_eager(self): - class P(param.Parameterized): - df = param.DataFrameLike(default=pd.DataFrame({'a': [1]})) - src = pl.DataFrame({'a': [1, 2]}) - p = P(df=src) - self.assertIs(p.df, src) - self.assertIsInstance(p.df, pl.DataFrame) - - @skip_no_pyarrow - def test_pyarrow(self): - class P(param.Parameterized): - df = param.DataFrameLike(default=pd.DataFrame({'a': [1]})) - src = pa.table({'a': [1, 2]}) - self.assertIs(P(df=src).df, src) +@skip_no_pandas +@skip_no_polars +def test_accepts_polars_eager(): + class P(param.Parameterized): + df = param.DataFrameLike(default=pd.DataFrame({'a': [1]})) + src = pl.DataFrame({'a': [1, 2]}) + p = P(df=src) + assert p.df is src + assert isinstance(p.df, pl.DataFrame) @skip_no_pandas -class TestDataFrameLikeRejects(unittest.TestCase): +@skip_no_pyarrow +def test_accepts_pyarrow(): + class P(param.Parameterized): + df = param.DataFrameLike(default=pd.DataFrame({'a': [1]})) + src = pa.table({'a': [1, 2]}) + assert P(df=src).df is src - def setUp(self): - class P(param.Parameterized): - df = param.DataFrameLike(default=pd.DataFrame({'a': [1]})) - self.P = P - def test_list(self): - with self.assertRaises(ValueError): - self.P(df=[1, 2, 3]) +@pytest.fixture +def P_pandas(): + pytest.importorskip("pandas") + + class P(param.Parameterized): + df = param.DataFrameLike(default=pd.DataFrame({'a': [1]})) - def test_dict(self): - with self.assertRaises(ValueError): - self.P(df={'a': [1]}) + return P + + +@skip_no_pandas +class TestDataFrameLikeRejects: - def test_str(self): - with self.assertRaises(ValueError): - self.P(df='not a frame') + def test_list(self, P_pandas): + with pytest.raises(ValueError): + P_pandas(df=[1, 2, 3]) - def test_series(self): - with self.assertRaises(ValueError): - self.P(df=pd.Series([1, 2, 3])) + def test_dict(self, P_pandas): + with pytest.raises(ValueError): + P_pandas(df={'a': [1]}) - def test_none_without_allow_none(self): - with self.assertRaises(ValueError): - self.P(df=None) + def test_str(self, P_pandas): + with pytest.raises(ValueError): + P_pandas(df='not a frame') + + def test_series(self, P_pandas): + with pytest.raises(ValueError): + P_pandas(df=pd.Series([1, 2, 3])) + + def test_none_without_allow_none(self, P_pandas): + with pytest.raises(ValueError): + P_pandas(df=None) def test_none_with_allow_none(self): class Q(param.Parameterized): df = param.DataFrameLike(default=None, allow_None=True) - self.assertIsNone(Q(df=None).df) + assert Q(df=None).df is None @skip_no_pandas -class TestDataFrameLikeRows(unittest.TestCase): +class TestDataFrameLikeRows: def test_rows_exact_ok(self): class P(param.Parameterized): @@ -123,14 +121,14 @@ class P(param.Parameterized): def test_rows_exact_mismatch(self): class P(param.Parameterized): df = param.DataFrameLike(default=pd.DataFrame({'a': [1, 2, 3]}), rows=3) - with self.assertRaises(ValueError): + with pytest.raises(ValueError): P(df=pd.DataFrame({'a': [1, 2]})) def test_rows_bounds(self): class P(param.Parameterized): df = param.DataFrameLike(default=pd.DataFrame({'a': [1, 2]}), rows=(1, 4)) P(df=pd.DataFrame({'a': [1, 2, 3, 4]})) - with self.assertRaises(ValueError): + with pytest.raises(ValueError): P(df=pd.DataFrame({'a': list(range(5))})) @skip_no_polars @@ -138,12 +136,12 @@ def test_rows_polars(self): class P(param.Parameterized): df = param.DataFrameLike(default=pd.DataFrame({'a': [1, 2]}), rows=2) P(df=pl.DataFrame({'a': [9, 8]})) - with self.assertRaises(ValueError): + with pytest.raises(ValueError): P(df=pl.DataFrame({'a': [1]})) @skip_no_pandas -class TestDataFrameLikeColumns(unittest.TestCase): +class TestDataFrameLikeColumns: def test_set_subset_ok(self): class P(param.Parameterized): @@ -155,7 +153,7 @@ def test_set_missing_column(self): class P(param.Parameterized): df = param.DataFrameLike( default=pd.DataFrame({'a': [1]}), columns={'a'}) - with self.assertRaises(ValueError): + with pytest.raises(ValueError): P(df=pd.DataFrame({'x': [1]})) def test_list_exact_ordered(self): @@ -163,7 +161,7 @@ class P(param.Parameterized): df = param.DataFrameLike( default=pd.DataFrame({'a': [1], 'b': [2]}), columns=['a', 'b']) P(df=pd.DataFrame({'a': [1], 'b': [2]})) - with self.assertRaises(ValueError): + with pytest.raises(ValueError): P(df=pd.DataFrame({'b': [2], 'a': [1]})) def test_columns_numeric_bounds(self): @@ -171,88 +169,78 @@ class P(param.Parameterized): df = param.DataFrameLike( default=pd.DataFrame({'a': [1], 'b': [2]}), columns=(1, 3)) P(df=pd.DataFrame({'a': [1], 'b': [2], 'c': [3]})) - with self.assertRaises(ValueError): + with pytest.raises(ValueError): P(df=pd.DataFrame({c: [1] for c in 'abcd'})) def test_set_with_ordered_raises(self): - with self.assertRaises(ValueError): + with pytest.raises(ValueError): class P(param.Parameterized): df = param.DataFrameLike( default=pd.DataFrame({'a': [1]}), columns={'a'}, ordered=True) -@skip_no_pandas @skip_no_polars -class TestDataFrameLikeAllowLazy(unittest.TestCase): +class TestDataFrameLikeLazy: def test_lazy_rejected_by_default(self): class P(param.Parameterized): - df = param.DataFrameLike(default=pd.DataFrame({'a': [1]})) - with self.assertRaises(ValueError): + df = param.DataFrameLike(default=pl.DataFrame({'a': [1]})) + with pytest.raises(ValueError): P(df=pl.LazyFrame({'a': [1, 2]})) def test_lazy_accepted_when_allowed(self): class P(param.Parameterized): df = param.DataFrameLike( - default=pd.DataFrame({'a': [1]}), allow_lazy=True) + default=pl.DataFrame({'a': [1]}), eager_only=False) src = pl.LazyFrame({'a': [1, 2]}) - self.assertIs(P(df=src).df, src) + assert P(df=src).df is src def test_lazy_columns_still_validated(self): class P(param.Parameterized): df = param.DataFrameLike( - default=pd.DataFrame({'a': [1]}), - columns={'a'}, allow_lazy=True) + default=pl.DataFrame({'a': [1]}), + columns={'a'}, eager_only=False) P(df=pl.LazyFrame({'a': [1], 'b': [2]})) - with self.assertRaises(ValueError): + with pytest.raises(ValueError): P(df=pl.LazyFrame({'x': [1]})) - def test_lazy_rows_skipped_no_collect(self): - # rows=2 would fail if collected (lazy frame has 3 rows); it must be - # skipped for lazy frames so .collect() is never called implicitly. + def test_lazy_rows_validated_via_count(self): + # rows=2 must be validated against a LazyFrame without materialising + # the whole frame; narwhals .count() is used to pull only a scalar. class P(param.Parameterized): df = param.DataFrameLike( - default=pd.DataFrame({'a': [1, 2]}), - rows=2, allow_lazy=True) - lazy = pl.LazyFrame({'a': [1, 2, 3]}) - collected = {'called': False} - orig_collect = pl.LazyFrame.collect - - def spy(self, *a, **k): - collected['called'] = True - return orig_collect(self, *a, **k) - - pl.LazyFrame.collect = spy - try: - P(df=lazy) - finally: - pl.LazyFrame.collect = orig_collect - self.assertFalse(collected['called'], "lazy frame was implicitly collected") + default=pl.DataFrame({'a': [1, 2]}), + rows=2, eager_only=False) + # Matching row count passes. + P(df=pl.LazyFrame({'a': [1, 2]})) + # Non-matching row count fails (proves rows are actually checked). + with pytest.raises(ValueError): + P(df=pl.LazyFrame({'a': [1, 2, 3]})) @skip_no_pandas -class TestDataFrameLikeSerialize(unittest.TestCase): +class TestDataFrameLikeSerialize: def test_serialize_none(self): - self.assertIsNone(param.DataFrameLike.serialize(None)) + assert param.DataFrameLike.serialize(None) is None def test_serialize_records(self): recs = param.DataFrameLike.serialize(pd.DataFrame({'a': [1, 2], 'b': ['x', 'y']})) - self.assertEqual(recs, [{'a': 1, 'b': 'x'}, {'a': 2, 'b': 'y'}]) + assert recs == [{'a': 1, 'b': 'x'}, {'a': 2, 'b': 'y'}] @skip_no_polars def test_serialize_backend_neutral(self): recs_pd = param.DataFrameLike.serialize(pd.DataFrame({'a': [1, 2]})) recs_pl = param.DataFrameLike.serialize(pl.DataFrame({'a': [1, 2]})) - self.assertEqual(recs_pd, recs_pl) + assert recs_pd == recs_pl @skip_no_polars def test_serialize_lazy_collected(self): recs = param.DataFrameLike.serialize(pl.LazyFrame({'a': [1, 2]})) - self.assertEqual(recs, [{'a': 1}, {'a': 2}]) + assert recs == [{'a': 1}, {'a': 2}] def test_deserialize_roundtrip(self): recs = param.DataFrameLike.serialize(pd.DataFrame({'a': [1, 2]})) back = param.DataFrameLike.deserialize(recs) - self.assertEqual(back.to_dict('records'), recs) + assert back.to_dict('records') == recs From 252a6f8414a321a84c770bb7c471a43ba3b06218 Mon Sep 17 00:00:00 2001 From: ghostiee-11 <168410465+ghostiee-11@users.noreply.github.com> Date: Thu, 28 May 2026 16:12:08 +0530 Subject: [PATCH 5/5] Fix CI: skip DataFrameLike defaults without narwhals, satisfy type-checkers * tests/testdefaults.py: append DataFrameLike to skip list when narwhals is unavailable, matching the existing pandas/numpy pattern * param/parameters.py: restructure DataFrameLike._validate so cols and schema have non-Optional types in the branches that use them, fixing pyrefly/pyright/ty errors flagged on CI --- param/parameters.py | 58 +++++++++++++++++++++---------------------- tests/testdefaults.py | 4 +++ 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/param/parameters.py b/param/parameters.py index bc9abee20..67874f60c 100644 --- a/param/parameters.py +++ b/param/parameters.py @@ -3641,39 +3641,39 @@ def _validate(self, val): nwframe = self._as_narwhals(val) narwhals = _get_narwhals() is_lazy = isinstance(nwframe, narwhals.LazyFrame) - need_cols = self.columns is not None or self.ordered - schema = nwframe.collect_schema() if (need_cols or (self.rows is not None and is_lazy)) else None - cols = list(schema.names()) if (schema is not None and need_cols) else None - if self.columns is None: - pass - elif (isinstance(self.columns, tuple) and len(self.columns)==2 - and all(isinstance(v, (type(None), numbers.Number)) for v in self.columns)): # Numeric bounds tuple - _length_bounds_check(self, self.columns, len(cols), 'columns') - elif isinstance(self.columns, (list, set)): - # Mirrors DataFrame._validate exactly (including this in-place - # ``ordered`` defaulting) so the two classes behave identically; - # cleaning up the slot mutation is deferred to a cross-cutting - # change that touches both. - self.ordered = isinstance(self.columns, list) if self.ordered is None else self.ordered - difference = set(self.columns) - {str(el) for el in cols} - if difference: - raise ValueError( - f"{_validate_error_prefix(self)}: provided columns " - f"{cols} does not contain required " - f"columns {sorted(self.columns)}" - ) - else: - _length_bounds_check(self, self.columns, len(cols), 'column') + # Resolve schema once if any column check or lazy row check needs it. + need_schema = self.columns is not None or (self.rows is not None and is_lazy) + schema = nwframe.collect_schema() if need_schema else None + + if self.columns is not None: + assert schema is not None + cols = list(schema.names()) + if (isinstance(self.columns, tuple) and len(self.columns)==2 + and all(isinstance(v, (type(None), numbers.Number)) for v in self.columns)): # Numeric bounds tuple + _length_bounds_check(self, self.columns, len(cols), 'columns') + elif isinstance(self.columns, (list, set)): + self.ordered = isinstance(self.columns, list) if self.ordered is None else self.ordered + difference = set(self.columns) - {str(el) for el in cols} + if difference: + raise ValueError( + f"{_validate_error_prefix(self)}: provided columns " + f"{cols} does not contain required " + f"columns {sorted(self.columns)}" + ) + else: + _length_bounds_check(self, self.columns, len(cols), 'column') + + if self.ordered and isinstance(self.columns, Iterable): + if cols != list(self.columns): + raise ValueError( + f"{_validate_error_prefix(self)}: provided columns " + f"{cols} must exactly match {self.columns}" + ) - if self.ordered and isinstance(self.columns, Iterable): - if cols != list(self.columns): - raise ValueError( - f"{_validate_error_prefix(self)}: provided columns " - f"{cols} must exactly match {self.columns}" - ) if self.rows is not None: if is_lazy: + assert schema is not None first = next(iter(schema.names()), None) n = ( nwframe.select(narwhals.col(first).count()).collect().item() diff --git a/tests/testdefaults.py b/tests/testdefaults.py index 7adbb7c7d..63ae13434 100644 --- a/tests/testdefaults.py +++ b/tests/testdefaults.py @@ -28,6 +28,10 @@ except ModuleNotFoundError: skip.append('DataFrame') skip.append('Series') +try: + import narwhals # noqa +except ModuleNotFoundError: + skip.append('DataFrameLike') class DefaultsMetaclassTest(type):