Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
59548c5
ci: Start measuring coverage
dangotbanned Oct 31, 2025
a35745c
chore: Not really coverage
dangotbanned Oct 31, 2025
f939c9b
chore: Ignore namespaces
dangotbanned Oct 31, 2025
dadfd58
chore: Ignore `DataFrame`, `Series`
dangotbanned Oct 31, 2025
d9c52d4
chore: Ignore `FrozenSchema`
dangotbanned Oct 31, 2025
947d4e6
chore: Ignore `Expr`, `Selector`
dangotbanned Oct 31, 2025
b3ec661
chore: Remove unused stuff
dangotbanned Oct 31, 2025
5e7796d
chore: Ignore `options`
dangotbanned Oct 31, 2025
6d0c363
chore: Ignore `common`
dangotbanned Oct 31, 2025
b86ed3c
ignore rewrites
dangotbanned Oct 31, 2025
c4b2afd
ignore `_parse`
dangotbanned Oct 31, 2025
e6b2108
chore(todo): Ignores that I wanna cover
dangotbanned Oct 31, 2025
1d205cb
test: `when-then` cov
dangotbanned Oct 31, 2025
1abb236
test: repr cov
dangotbanned Oct 31, 2025
4c39f51
chore: Simplify `_is_iterable` and cov
dangotbanned Oct 31, 2025
f954e05
test: A bunch of parsing cov
dangotbanned Oct 31, 2025
1579b32
test: `FrozenSchema` cov
dangotbanned Oct 31, 2025
010ad48
test: More `selectors` cov
dangotbanned Nov 1, 2025
5569a16
fix, simplify, cov: `by_index(require_all=False)`
dangotbanned Nov 1, 2025
26a2b25
refactor: Remove unreachable override
dangotbanned Nov 1, 2025
1dd8291
test: Fully cover `selectors`
dangotbanned Nov 1, 2025
83dd4e5
Merge remote-tracking branch 'upstream/expr-ir/strict-selectors' into…
dangotbanned Nov 1, 2025
2eb9233
chore(typing): Remove ignore I guess?
dangotbanned Nov 1, 2025
25226a1
chore: Explain why ok to ignore
dangotbanned Nov 1, 2025
1e2e36c
test: Cover more error handling
dangotbanned Nov 1, 2025
f8c8b64
refactor: More selectors cov, avoid repeat cov
dangotbanned Nov 1, 2025
fc1b70d
chore: Allow defensive code
dangotbanned Nov 1, 2025
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
22 changes: 8 additions & 14 deletions narwhals/_plan/_expansion.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,11 @@ def expand_selector_irs_names(
ignored: Names of `group_by` columns.
schema: Scope to expand selectors in.
"""
expander = Expander(schema, ignored)
names = expander.iter_expand_selector_names(selectors)
return _ensure_valid_output_names(tuple(names), expander.schema)
names = tuple(Expander(schema, ignored).iter_expand_selector_names(selectors))
if len(names) != len(set(names)):
# NOTE: Can't easily reuse `duplicate_error`, falling back to main for now
check_column_names_are_unique(names)
return names


def remove_alias(origin: ExprIR, /) -> ExprIR:
Expand All @@ -139,14 +141,6 @@ def fn(child: ExprIR, /) -> ExprIR:
return origin.map_ir(fn)


def _ensure_valid_output_names(names: Seq[str], schema: FrozenSchema) -> OutputNames:
check_column_names_are_unique(names)
output_names = names
if not (set(schema.names).issuperset(output_names)):
raise column_not_found_error(output_names, schema)
return output_names


class Expander:
__slots__ = ("ignored", "schema")
schema: FrozenSchema
Expand Down Expand Up @@ -223,7 +217,7 @@ def _expand_recursive(self, origin: ExprIR, /) -> Iterator[ExprIR]:
yield from self._expand_function_expr(origin)
else:
msg = f"Didn't expect to see {type(origin).__name__}"
raise TypeError(msg)
raise NotImplementedError(msg)

def _expand_inner(self, children: Seq[ExprIR], /) -> Iterator[ExprIR]:
"""Use when we want to expand non-root nodes, *without* duplicating the root.
Expand Down Expand Up @@ -265,8 +259,8 @@ def _expand_only(self, child: ExprIR, /) -> ExprIR:
iterable = self._expand_recursive(child)
first = next(iterable)
if second := next(iterable, None):
msg = f"Multi-output expressions are not supported in this context, got: `{second!r}`"
raise MultiOutputExpressionError(msg)
msg = f"Multi-output expressions are not supported in this context, got: `{second!r}`" # pragma: no cover
raise MultiOutputExpressionError(msg) # pragma: no cover
return first

# TODO @dangotbanned: It works, but all this class-specific branching belongs in the classes themselves
Expand Down
11 changes: 5 additions & 6 deletions narwhals/_plan/_expr_ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ def iter_right(self) -> Iterator[ExprIR]:
if isinstance(child, ExprIR):
yield from child.iter_right()
else:
for node in reversed(child):
for node in reversed(child): # pragma: no cover
yield from node.iter_right()

def iter_root_names(self) -> Iterator[ExprIR]:
Expand Down Expand Up @@ -199,9 +199,8 @@ class SelectorIR(ExprIR, config=ExprIROptions.no_dispatch()):
def to_narwhals(self, version: Version = Version.MAIN) -> Selector:
from narwhals._plan.selectors import Selector, SelectorV1

if version is Version.MAIN:
return Selector._from_ir(self)
return SelectorV1._from_ir(self)
tp = Selector if version is Version.MAIN else SelectorV1
return tp._from_ir(self)

def into_columns(
self, schema: FrozenSchema, ignored_columns: Container[str]
Expand Down Expand Up @@ -267,10 +266,10 @@ def map_ir(self, function: MapIR, /) -> Self:
def __repr__(self) -> str:
return f"{self.name}={self.expr!r}"

def _repr_html_(self) -> str:
def _repr_html_(self) -> str: # pragma: no cover
return f"<b>{self.name}</b>={self.expr._repr_html_()}"

def is_elementwise_top_level(self) -> bool:
def is_elementwise_top_level(self) -> bool: # pragma: no cover
"""Return True if the outermost node is elementwise.

Based on [`polars_plan::plans::aexpr::properties::AExpr.is_elementwise_top_level`]
Expand Down
8 changes: 3 additions & 5 deletions narwhals/_plan/_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,7 @@ def is_literal(obj: Any) -> TypeIs[ir.Literal[Any]]:
return isinstance(obj, _ir().Literal)


def is_horizontal_reduction(obj: Any) -> TypeIs[ir.FunctionExpr[Any]]:
return is_function_expr(obj) and obj.options.is_input_wildcard_expansion()


def is_tuple_of(obj: Any, tp: type[T]) -> TypeIs[Seq[T]]:
# TODO @dangotbanned: Coverage
# Used in `ArrowNamespace._vertical`, but only horizontal is covered
def is_tuple_of(obj: Any, tp: type[T]) -> TypeIs[Seq[T]]: # pragma: no cover
return bool(isinstance(obj, tuple) and obj and isinstance(obj[0], tp))
6 changes: 5 additions & 1 deletion narwhals/_plan/_immutable.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ class Immutable(metaclass=ImmutableMeta):
# NOTE: Trying to avoid this being added to synthesized `__init__`
# Seems to be the only difference when decorating the metaclass
__immutable_hash_value__: int
else: # pragma: no cover
...

__immutable_keys__: ClassVar[tuple[str, ...]]

Expand Down Expand Up @@ -108,7 +110,9 @@ def __init__(self, **kwds: Any) -> None:

def _field_str(name: str, value: Any) -> str:
if isinstance(value, tuple):
inner = ", ".join(f"{v}" for v in value)
inner = ", ".join(
(f"{v!s}" if not isinstance(v, str) else f"{v!r}") for v in value
)
return f"{name}=[{inner}]"
if isinstance(value, str):
return f"{name}={value!r}"
Expand Down
39 changes: 15 additions & 24 deletions narwhals/_plan/_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,23 @@
from itertools import chain
from typing import TYPE_CHECKING

from narwhals._native import is_native_pandas
from narwhals._plan._guards import (
is_column_name_or_selector,
is_expr,
is_into_expr_column,
is_iterable_reject,
is_selector,
)
from narwhals._plan.exceptions import (
invalid_into_expr_error,
is_iterable_pandas_error,
is_iterable_polars_error,
)
from narwhals._plan.exceptions import invalid_into_expr_error, is_iterable_error
from narwhals._utils import qualified_type_name
from narwhals.dependencies import get_polars, is_pandas_dataframe, is_pandas_series
from narwhals.dependencies import get_polars
from narwhals.exceptions import InvalidOperationError

if TYPE_CHECKING:
from collections.abc import Iterator
from typing import Any, TypeVar

import polars as pl
from typing_extensions import TypeAlias, TypeIs

from narwhals._plan.expr import Expr
Expand Down Expand Up @@ -126,7 +122,7 @@ def parse_into_expr_ir(
expr = col(input)
elif isinstance(input, list):
if list_as_series is None:
raise TypeError(input)
raise TypeError(input) # pragma: no cover
expr = lit(list_as_series(input))
else:
expr = lit(input, dtype=dtype)
Expand All @@ -140,9 +136,9 @@ def parse_into_selector_ir(input: ColumnNameOrSelector | Expr, /) -> SelectorIR:
from narwhals._plan import selectors as cs

selector = cs.by_name(input)
elif is_expr(input):
elif is_expr(input): # pragma: no cover
selector = input.meta.as_selector()
else:
else: # pragma: no cover
msg = f"cannot turn {qualified_type_name(input)!r} into selector"
raise TypeError(msg)
return selector._ir
Expand Down Expand Up @@ -194,8 +190,8 @@ def _parse_sort_by_into_iter_expr_ir(
) -> Iterator[ExprIR]:
for e in _parse_into_iter_expr_ir(by, *more_by):
if e.is_scalar:
msg = f"All expressions sort keys must preserve length, but got:\n{e!r}"
raise InvalidOperationError(msg)
msg = f"All expressions sort keys must preserve length, but got:\n{e!r}" # pragma: no cover
raise InvalidOperationError(msg) # pragma: no cover
yield e


Expand All @@ -216,14 +212,14 @@ def _parse_into_iter_selector_ir(

if not _is_empty_sequence(first_input):
if _is_iterable(first_input) and not isinstance(first_input, str):
if more_inputs:
if more_inputs: # pragma: no cover
raise invalid_into_expr_error(first_input, more_inputs, {})
else:
for into in first_input: # type: ignore[var-annotated]
yield parse_into_selector_ir(into)
else:
yield parse_into_selector_ir(first_input)
for into in more_inputs:
for into in more_inputs: # pragma: no cover
yield parse_into_selector_ir(into)


Expand Down Expand Up @@ -298,18 +294,13 @@ def _combine_predicates(predicates: Iterator[ExprIR], /) -> ExprIR:


def _is_iterable(obj: Iterable[T] | Any) -> TypeIs[Iterable[T]]:
if is_pandas_dataframe(obj) or is_pandas_series(obj):
raise is_iterable_pandas_error(obj)
if _is_polars(obj):
raise is_iterable_polars_error(obj)
if is_native_pandas(obj) or (
(pl := get_polars())
and isinstance(obj, (pl.Series, pl.Expr, pl.DataFrame, pl.LazyFrame))
):
raise is_iterable_error(obj)
return isinstance(obj, Iterable)


def _is_empty_sequence(obj: Any) -> bool:
return isinstance(obj, Sequence) and not obj


def _is_polars(obj: Any) -> TypeIs[pl.Series | pl.Expr | pl.DataFrame | pl.LazyFrame]:
return (pl := get_polars()) is not None and isinstance(
obj, (pl.Series, pl.Expr, pl.DataFrame, pl.LazyFrame)
)
2 changes: 1 addition & 1 deletion narwhals/_plan/_rewrites.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def map_ir(
origin: NamedOrExprIRT, function: MapIR, *more_functions: MapIR
) -> NamedOrExprIRT:
"""Apply one or more functions, sequentially, to all of `origin`'s children."""
if more_functions:
if more_functions: # pragma: no cover
result = origin
for fn in (function, *more_functions):
result = result.map_ir(fn)
Expand Down
10 changes: 5 additions & 5 deletions narwhals/_plan/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@

if sys.version_info >= (3, 13):
from copy import replace as replace # noqa: PLC0414
else:
else: # pragma: no cover

def replace(obj: T, /, **changes: Any) -> T:
cls = obj.__class__
Expand Down Expand Up @@ -98,20 +98,20 @@ def flatten_hash_safe(iterable: Iterable[OneOrIterable[T]], /) -> Iterator[T]:
yield element # type: ignore[misc]


def _not_one_or_iterable_str_error(obj: Any, /) -> TypeError:
def _not_one_or_iterable_str_error(obj: Any, /) -> TypeError: # pragma: no cover
msg = f"Expected one or an iterable of strings, but got: {qualified_type_name(obj)!r}\n{obj!r}"
return TypeError(msg)


def ensure_seq_str(obj: OneOrIterable[str], /) -> Seq[str]:
if not isinstance(obj, Iterable):
raise _not_one_or_iterable_str_error(obj)
raise _not_one_or_iterable_str_error(obj) # pragma: no cover
return (obj,) if isinstance(obj, str) else tuple(obj)


def ensure_list_str(obj: OneOrIterable[str], /) -> list[str]:
if not isinstance(obj, Iterable):
raise _not_one_or_iterable_str_error(obj)
raise _not_one_or_iterable_str_error(obj) # pragma: no cover
return [obj] if isinstance(obj, str) else list(obj)


Expand Down Expand Up @@ -246,7 +246,7 @@ def _not_enough_room_error(cls, prefix: str, n_chars: int, /) -> NarwhalsError:
available_chars = n_chars - len_prefix
if available_chars < 0:
visualize = ""
else:
else: # pragma: no cover (has coverage, but there's randomness in the test)
okay = "✔" * available_chars
bad = "✖" * (cls._MIN_RANDOM_CHARS - available_chars)
visualize = f"\n Preview: '{prefix}{okay}{bad}'"
Expand Down
22 changes: 12 additions & 10 deletions narwhals/_plan/dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def version(self) -> Version:
return self._version

@property
def implementation(self) -> Implementation:
def implementation(self) -> Implementation: # pragma: no cover
return self._compliant.implementation

@property
Expand All @@ -61,7 +61,7 @@ def schema(self) -> Schema:
def columns(self) -> list[str]:
return self._compliant.columns

def __repr__(self) -> str: # pragma: no cover
def __repr__(self) -> str:
return generate_repr(f"nw.{type(self).__name__}", self.to_native().__repr__())

def __init__(self, compliant: CompliantFrame[Any, NativeFrameT_co], /) -> None:
Expand All @@ -70,12 +70,12 @@ def __init__(self, compliant: CompliantFrame[Any, NativeFrameT_co], /) -> None:
def _with_compliant(self, compliant: CompliantFrame[Any, Incomplete], /) -> Self:
return type(self)(compliant)

def to_native(self) -> NativeFrameT_co:
def to_native(self) -> NativeFrameT_co: # pragma: no cover
return self._compliant.native

def filter(
self, *predicates: OneOrIterable[IntoExprColumn], **constraints: Any
) -> Self:
) -> Self: # pragma: no cover
e = _parse.parse_predicates_constraints_into_expr_ir(*predicates, **constraints)
named_irs, _ = prepare_projection((e,), schema=self)
if len(named_irs) != 1:
Expand Down Expand Up @@ -113,7 +113,9 @@ def sort(
def drop(self, *columns: str, strict: bool = True) -> Self:
return self._with_compliant(self._compliant.drop(columns, strict=strict))

def drop_nulls(self, subset: str | Sequence[str] | None = None) -> Self:
def drop_nulls(
self, subset: str | Sequence[str] | None = None
) -> Self: # pragma: no cover
subset = [subset] if isinstance(subset, str) else subset
return self._with_compliant(self._compliant.drop_nulls(subset))

Expand All @@ -130,7 +132,7 @@ class DataFrame(
def implementation(self) -> _EagerAllowedImpl:
return self._compliant.implementation

def __len__(self) -> int:
def __len__(self) -> int: # pragma: no cover
return len(self._compliant)

@property
Expand Down Expand Up @@ -183,17 +185,17 @@ def to_dict(
def to_dict(
self, *, as_series: bool = True
) -> dict[str, Series[NativeSeriesT]] | dict[str, list[Any]]:
if as_series:
if as_series: # pragma: no cover
return {
key: self._series(value)
for key, value in self._compliant.to_dict(as_series=as_series).items()
}
return self._compliant.to_dict(as_series=as_series)

def to_series(self, index: int = 0) -> Series[NativeSeriesT]:
def to_series(self, index: int = 0) -> Series[NativeSeriesT]: # pragma: no cover
return self._series(self._compliant.to_series(index))

def get_column(self, name: str) -> Series[NativeSeriesT]:
def get_column(self, name: str) -> Series[NativeSeriesT]: # pragma: no cover
return self._series(self._compliant.get_column(name))

@overload
Expand Down Expand Up @@ -253,7 +255,7 @@ def filter(
**constraints,
)
named_irs, _ = prepare_projection((e,), schema=self)
if len(named_irs) != 1:
if len(named_irs) != 1: # pragma: no cover
# Should be unreachable, but I guess we will see
msg = f"Expected a single predicate after expansion, but got {len(named_irs)!r}\n\n{named_irs!r}"
raise ValueError(msg)
Expand Down
18 changes: 3 additions & 15 deletions narwhals/_plan/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from itertools import groupby
from typing import TYPE_CHECKING

from narwhals._utils import qualified_type_name
from narwhals.exceptions import (
ColumnNotFoundError,
ComputeError,
Expand All @@ -21,9 +22,6 @@
from collections.abc import Collection, Iterable
from typing import Any

import pandas as pd
import polars as pl

from narwhals._plan import expressions as ir
from narwhals._plan._function import Function
from narwhals._plan.expressions.operators import Operator
Expand Down Expand Up @@ -156,19 +154,9 @@ def invalid_into_expr_error(
return InvalidIntoExprError(msg)


def is_iterable_pandas_error(obj: pd.DataFrame | pd.Series[Any], /) -> TypeError:
msg = (
f"Expected Narwhals class or scalar, got: {type(obj)}. "
"Perhaps you forgot a `nw.from_native` somewhere?"
)
return TypeError(msg)


def is_iterable_polars_error(
obj: pl.Series | pl.Expr | pl.DataFrame | pl.LazyFrame, /
) -> TypeError:
def is_iterable_error(obj: object, /) -> TypeError:
msg = (
f"Expected Narwhals class or scalar, got: {type(obj)}.\n\n"
f"Expected Narwhals class or scalar, got: {qualified_type_name(obj)!r}.\n\n"
"Hint: Perhaps you\n"
"- forgot a `nw.from_native` somewhere?\n"
"- used `pl.col` instead of `nw.col`?"
Expand Down
Loading
Loading