Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
25 changes: 23 additions & 2 deletions docs/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,27 @@ handle plugins. For this integration to work, any plugin architecture must conta
Take a look at the `Plugin` protocol in `narwhals/plugins.py` for the
signatures.

3. an `_implementation` attribute set to `narwhals.Implementation.UNKNOWN`, on every
compliant class the plugin exposes (namespace, dataframe, lazyframe, series):

```py
from narwhals import Implementation


class GrizzliesNamespace:
_implementation = Implementation.UNKNOWN
...


class GrizzliesDataFrame:
_implementation = Implementation.UNKNOWN
...
```

A plugin's backend is, by definition, not one of Narwhals' own `Implementation`
members, and this attribute is what says so, telling plugin objects apart from
built-in ones.

## Supporting `backend=...` in Narwhals functions

Functions and constructors which accept a `backend` argument can also dispatch to a
Expand All @@ -69,8 +90,8 @@ plugin. Users can pass:
- the plugin's module name (e.g. `backend="narwhals_grizzlies"`),
- or the plugin's module itself (e.g. `backend=narwhals_grizzlies`).

In all cases, dispatch goes through the compliant namespace returned by the plugin's
`__narwhals_namespace__`:
All three spellings are resolved the same way built-in backends are, and dispatch goes
through the compliant namespace returned by the plugin's `__narwhals_namespace__`:

1. **IO functions** (`read_csv`, `scan_csv`, `read_parquet`, `scan_parquet`): these
call same-named methods on the compliant namespace, following the
Expand Down
21 changes: 16 additions & 5 deletions src/narwhals/_namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
is_native_spark_like,
is_native_sqlframe,
)
from narwhals._utils import Implementation, Version
from narwhals._utils import Implementation, Version, is_into_plugin

if TYPE_CHECKING:
from typing import TypeAlias
Expand All @@ -57,6 +57,7 @@
Ibis,
IntoBackend,
PandasLike,
PluginName,
Polars,
SparkLike,
)
Expand Down Expand Up @@ -135,12 +136,12 @@ def from_backend(cls, backend: EagerAllowed, /) -> EagerAllowedNamespace: ...
@overload
@classmethod
def from_backend(
cls, backend: IntoBackend[Backend], /
cls, backend: IntoBackend[Backend | PluginName], /
) -> Namespace[CompliantNamespaceAny]: ...

@classmethod
def from_backend(
cls: type[Namespace[Any]], backend: IntoBackend[Backend], /
cls: type[Namespace[Any]], backend: IntoBackend[Backend | PluginName], /
) -> Namespace[Any]:
"""Instantiate from native namespace module, string, or Implementation.

Expand Down Expand Up @@ -184,8 +185,18 @@ def from_backend(
from narwhals._ibis.namespace import IbisNamespace

ns = IbisNamespace(version=version)
else:
msg = "Not supported Implementation" # pragma: no cover
elif is_into_plugin(backend, impl):
# NOTE: Anything unknown to `Implementation` is resolved as a plugin,
# either by entry point name, by module name, or as the plugin module itself.
from narwhals.plugins import _plugin_namespace, _resolve_plugin

plugin = _resolve_plugin(backend)
ns = _plugin_namespace(plugin, version=version)
else: # pragma: no cover
# NOTE: Unreachable and defensive only check; `UNKNOWN` is handled
# above and every other member of `Implementation` is matched by one
# of the branches.
msg = "Not supported Implementation"
raise AssertionError(msg)
return cls(ns)

Expand Down
51 changes: 34 additions & 17 deletions src/narwhals/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
if TYPE_CHECKING:
from collections.abc import Set # noqa: PYI025
from types import ModuleType
from typing import Concatenate, TypeAlias
from typing import Concatenate, TypeAlias, TypeGuard

import pandas as pd
import polars as pl
Expand Down Expand Up @@ -1654,6 +1654,24 @@ def is_eager_allowed(impl: Implementation, /) -> TypeIs[_EagerAllowedImpl]:
}


# NOTE: Keep `TypeGuard`, not `TypeIs`, as the two narrow by different rules.
def is_into_plugin(
backend: IntoBackend[Backend | PluginName], # noqa: ARG001
impl: Implementation,
Comment on lines +1659 to +1660

@FBruzzesi FBruzzesi Jul 27, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

impl is a parameter rather than re-derived here since in eager_namespace it's already computed (it's not in _io_method)

/,
) -> TypeGuard[IntoBackend[PluginName]]:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I spent an unhealthy amount of time on this. @dangotbanned I am sure this is obvious to you, but it's still a bit mysterious for me

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I will try to work through this with you soon πŸ™

"""Return True if `backend` names a plugin, rather than a built-in backend.

`Implementation.UNKNOWN` means exactly "not one of Narwhals' own backends", so a
`backend` which resolves to it can only be a plugin's name or a plugin module.

Arguments:
backend: Backend spelling, as given by the user.
impl: Result of `Implementation.from_backend(backend)`.
"""
return impl is Implementation.UNKNOWN


# TODO(Unassigned): Generalize _hasattr_static?
# See https://github.com/narwhals-dev/narwhals/pull/3753#discussion_r3653098839
def _is_eager_namespace(obj: object, /) -> TypeIs[EagerNamespaceAny]:
Expand Down Expand Up @@ -1725,30 +1743,29 @@ def eager_namespace(
) -> EagerNamespaceAny | EagerNamespaceKnown:
"""Resolve `backend` to an eager-allowed compliant namespace.

Built-in eager backends resolve directly. Anything unknown to `Implementation` is
resolved via the plugin entry-point registry, in which case the plugin's
`__narwhals_namespace__` must return a namespace implementing the `EagerNamespace`
protocol (in particular, the `_series` and `_dataframe` properties).
Built-in eager backends and plugins resolve through the same
`Namespace.from_backend`; for a plugin, the namespace returned by
`__narwhals_namespace__` must implement the `EagerNamespace` protocol (in
particular, the `_series` and `_dataframe` properties).
Built-in lazy-only backends raise an informative `ValueError`, suggesting the
`EAGER_HINT_EXAMPLES` entry for `function_name` followed by a `.lazy(...)` call.
"""
implementation = Implementation.from_backend(backend)
if is_eager_allowed(implementation):
return version.namespace.from_backend(implementation).compliant
if implementation is not Implementation.UNKNOWN:
msg = (
f"{implementation} support in Narwhals is lazy-only, but `{function_name}` is an eager-only function.\n\n"
"Hint: you may want to use an eager backend and then call `.lazy`, e.g.:\n\n"
f" {EAGER_HINT_EXAMPLES[function_name]}.lazy('{implementation}')"
)
raise ValueError(msg)
from narwhals.plugins import _backend_namespace, _plugin_namespace
if is_into_plugin(backend, implementation):
from narwhals.plugins import _backend_name

plugin = _backend_namespace(backend)
namespace = _plugin_namespace(plugin, version=version)
return _ensure_eager_allowed(
namespace, source=plugin.__name__, function_name=function_name
namespace = version.namespace.from_backend(backend).compliant
return _ensure_eager_allowed(
namespace, source=_backend_name(backend), function_name=function_name
)
msg = (
f"{implementation} support in Narwhals is lazy-only, but `{function_name}` is an eager-only function.\n\n"
"Hint: you may want to use an eager backend and then call `.lazy`, e.g.:\n\n"
f" {EAGER_HINT_EXAMPLES[function_name]}.lazy('{implementation}')"
)
raise ValueError(msg)


def eager_namespace_from_compliant(
Expand Down
112 changes: 69 additions & 43 deletions src/narwhals/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import platform
import sys
from collections.abc import Iterable, Mapping, Sequence
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Concatenate, Literal, overload

from narwhals._expression_parsing import (
ExprKind,
Expand All @@ -19,6 +19,7 @@
eager_namespace,
flatten,
is_eager_allowed,
is_into_plugin,
is_nested_literal,
is_sequence_of,
normalize_path,
Expand All @@ -33,7 +34,7 @@
)
from narwhals.exceptions import InvalidOperationError
from narwhals.expr import Expr
from narwhals.plugins import plugin_io_method
from narwhals.plugins import _backend_name, _ensure_io_method
from narwhals.schema import Schema
from narwhals.translate import to_native

Expand All @@ -44,7 +45,7 @@

from typing_extensions import Self, TypeIs

from narwhals._compliant.typing import CompliantFrameAny
from narwhals._compliant.typing import CompliantDataFrameAny, CompliantFrameAny
from narwhals._translate import IntoArrowTable
from narwhals._typing import Backend, EagerAllowed, IntoBackend, PluginName
from narwhals.dataframe import DataFrame, LazyFrame
Expand All @@ -57,7 +58,9 @@
IntoDType,
IntoExpr,
IntoSchema,
IOMethodName,
NonNestedLiteral,
NormalizedPath,
PythonLiteral,
_2DArray,
)
Expand Down Expand Up @@ -549,6 +552,47 @@ def show_versions() -> None:
print(f"{k:>13}: {stat}") # noqa: T201


@overload
def _io_method(
backend: IntoBackend[Backend | PluginName],
method_name: Literal["read_csv", "read_parquet"],
/,
*,
version: Version,
) -> Callable[Concatenate[NormalizedPath, ...], CompliantDataFrameAny]: ...


@overload
def _io_method(
backend: IntoBackend[Backend | PluginName],
method_name: Literal["scan_csv", "scan_parquet"],
/,
*,
version: Version,
) -> Callable[Concatenate[NormalizedPath, ...], CompliantFrameAny]: ...


def _io_method(
backend: IntoBackend[Backend | PluginName],
method_name: IOMethodName,
/,
*,
version: Version,
) -> Callable[Concatenate[NormalizedPath, ...], CompliantFrameAny]:
"""Bind the `method_name` method of `backend`'s compliant namespace.

Built-in backends and plugins share one resolution path (`Namespace.from_backend`)
and one dispatch mechanism (a same-named method on the compliant namespace), but only
a plugin may be missing the method, so only a plugin is checked for it.
"""
impl = Implementation.from_backend(backend)
namespace = version.namespace.from_backend(backend).compliant
if is_into_plugin(backend, impl):
_ensure_io_method(namespace, method_name, source=_backend_name(backend))
method: Callable[..., CompliantFrameAny] = getattr(namespace, method_name)
return method


def read_csv(
source: FileSource,
*,
Expand Down Expand Up @@ -584,20 +628,16 @@ def read_csv(
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
"""
impl = Implementation.from_backend(backend)
if is_eager_allowed(impl):
ns = Version.MAIN.namespace.from_backend(impl).compliant
frame = ns.read_csv(normalize_path(source), separator=separator, **kwargs)
return frame.to_narwhals()
if impl is Implementation.UNKNOWN:
read = plugin_io_method(backend, "read_csv", version=Version.MAIN)
frame = read(normalize_path(source), separator=separator, **kwargs)
result: DataFrame[Any] = frame.to_narwhals()
return result
msg = (
f"Expected eager backend, found {impl}.\n\n"
f"Hint: use nw.scan_csv(source={source}, backend={backend})"
)
raise ValueError(msg)
if not is_eager_allowed(impl) and impl is not Implementation.UNKNOWN:
msg = (
f"Expected eager backend, found {impl}.\n\n"
f"Hint: use nw.scan_csv(source={source}, backend={backend})"
)
raise ValueError(msg)
read = _io_method(backend, "read_csv", version=Version.MAIN)
frame = read(normalize_path(source), separator=separator, **kwargs)
result: DataFrame[Any] = frame.to_narwhals()
return result


def scan_csv(
Expand Down Expand Up @@ -640,12 +680,7 @@ def scan_csv(
β”‚ z β”‚ 3 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜
"""
impl = Implementation.from_backend(backend)
scan: Callable[..., CompliantFrameAny]
if impl is Implementation.UNKNOWN:
scan = plugin_io_method(backend, "scan_csv", version=Version.MAIN)
else:
scan = Version.MAIN.namespace.from_backend(impl).compliant.scan_csv
scan = _io_method(backend, "scan_csv", version=Version.MAIN)
frame = scan(normalize_path(source), separator=separator, **kwargs)
result: LazyFrame[Any] = frame.to_narwhals().lazy()
return result
Expand Down Expand Up @@ -686,20 +721,16 @@ def read_parquet(
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
"""
impl = Implementation.from_backend(backend)
if is_eager_allowed(impl):
ns = Version.MAIN.namespace.from_backend(impl).compliant
frame = ns.read_parquet(normalize_path(source), **kwargs)
return frame.to_narwhals()
if impl is Implementation.UNKNOWN:
read = plugin_io_method(backend, "read_parquet", version=Version.MAIN)
frame = read(normalize_path(source), **kwargs)
result: DataFrame[Any] = frame.to_narwhals()
return result
msg = (
f"Expected eager backend, found {impl}.\n\n"
f"Hint: use nw.scan_parquet(source={source}, backend={backend})"
)
raise ValueError(msg)
if not is_eager_allowed(impl) and impl is not Implementation.UNKNOWN:
msg = (
f"Expected eager backend, found {impl}.\n\n"
f"Hint: use nw.scan_parquet(source={source}, backend={backend})"
)
raise ValueError(msg)
read = _io_method(backend, "read_parquet", version=Version.MAIN)
frame = read(normalize_path(source), **kwargs)
result: DataFrame[Any] = frame.to_narwhals()
return result


def scan_parquet(
Expand Down Expand Up @@ -764,12 +795,7 @@ def scan_parquet(
| b: [[4,5]] |
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
"""
impl = Implementation.from_backend(backend)
scan: Callable[..., CompliantFrameAny]
if impl is Implementation.UNKNOWN:
scan = plugin_io_method(backend, "scan_parquet", version=Version.MAIN)
else:
scan = Version.MAIN.namespace.from_backend(impl).compliant.scan_parquet
scan = _io_method(backend, "scan_parquet", version=Version.MAIN)
frame = scan(normalize_path(source), **kwargs)
result: LazyFrame[Any] = frame.to_narwhals().lazy()
return result
Expand Down
Loading
Loading