Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c19b368
feat: Extend plugin support for IO and `from_*` methods/functions
FBruzzesi Jul 5, 2026
d24b570
simplify test plugin
FBruzzesi Jul 5, 2026
19f4b56
skip old arrow
FBruzzesi Jul 5, 2026
2a18910
Avoid using implementation in testing.assert_series_equal
FBruzzesi Jul 6, 2026
9f14d08
add test for coverage
FBruzzesi Jul 6, 2026
0195eb5
merge main, simplify tests
FBruzzesi Jul 7, 2026
47e432b
shorter lines
FBruzzesi Jul 7, 2026
e446236
merge main, solve conflicts
FBruzzesi Jul 12, 2026
314d185
address error type, terminology and static attribute access
FBruzzesi Jul 12, 2026
3b1143d
merge main
FBruzzesi Jul 16, 2026
bcb2e2c
merge main
FBruzzesi Jul 22, 2026
56d9f42
Add plugin API reference page, combine extending.md sections
FBruzzesi Jul 22, 2026
d6ba539
simplify test-plugin
FBruzzesi Jul 22, 2026
bae0a80
Add docstrings to `PluginNamespace` and `Plugin` classes
FBruzzesi Jul 22, 2026
c5fe618
avoid usage of `lambda ...:` in plugins_test
FBruzzesi Jul 22, 2026
2f681e6
fix(typing): Avoid `eager_namespace` cast
dangotbanned Jul 26, 2026
a4d397c
fix(tying): Avoid `_ensure_eager_allowed` cast
dangotbanned Jul 26, 2026
b807ef0
chore(typing): Reveal `EagerNamespaceAny` hole
dangotbanned Jul 26, 2026
e65a05d
merge main
FBruzzesi Jul 26, 2026
b658306
Export EagerDataFrame, EagerExpr, EagerNamespace, EagerSeries in narw…
FBruzzesi Jul 26, 2026
fbf2c35
mention narwhals.compliant in extending.md
FBruzzesi Jul 26, 2026
b8aa459
factor out _is_eager_namespace from _ensure_eager_allowed
FBruzzesi Jul 26, 2026
055a03e
refactor: Do not require dedicated namespace for IO
FBruzzesi Jul 26, 2026
3da5081
move eager_namespace hint examples to mapping from function name
FBruzzesi Jul 26, 2026
d7e5350
adjust test-plugin and plugins_test.py
FBruzzesi Jul 26, 2026
f5ab38e
Merge branch 'main' into feat/extend-from-backend
FBruzzesi Jul 27, 2026
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
35 changes: 34 additions & 1 deletion docs/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,40 @@ 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.


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

Functions and constructors which accept a `backend` argument can also dispatch to a
plugin. Users can pass:

- the plugin's entry point name (e.g. `backend="narwhals-grizzlies"`),

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.

Note

My main issue is I would like there to be a single valid string per-plugin

I guess this is my bad as I missed what we have documented for how to name a plugin:

  1. an entrypoint defined in a pyproject.toml file:
[project.entry-points.'narwhals.plugins']
narwhals-<library name> = 'narwhals_<library name>'

If you compare that to what I have here, the namespacing is provided by the entry-points group:

narwhals/pyproject.toml

Lines 63 to 65 in c57e72c

[project.entry-points.'narwhals.plugins.plan']
polars = "narwhals._plan.polars:plugin"
pyarrow = "narwhals._plan.arrow:plugin"

Which is the same pattern given as an example for https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/#using-package-metadata

Suggestion

Could we just have 1 option for plugins, which is:

- - the plugin's entry point name (e.g. `backend="narwhals-grizzlies"`),
- - the plugin's module name (e.g. `backend="narwhals_grizzlies"`),
- - or the plugin's module itself (e.g. `backend=narwhals_grizzlies`).
+ the plugin's entry point name (e.g. `backend="grizzlies"`)

I understand we started with passing modules around, but it doesn't fit into the type system like Literal["grizzlies"] or even LiteralString could.

If we need to support modules (e.g. there is high usage of nw.get_native_namespace) then sure, have that option too

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 don't think this is a good strategy to be honest. Yesterday we were discussing the "plain dict" plugin name, and it might not come with a narwhals prefix. All we need is to register the entrypoint within the "narwhals.plugins" section: [project.entry-points.'narwhals.plugins'].

Our test-plugin is also not following the narwhals-prefix pattern

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.

The dash-underscore string duality (e.g. "narwhals-daft" vs "narwhals_daft") mirrors the pypi name vs import-name reality that many other modules ended up having. Matching both in _find_plugin costs three lines and is "forgiving", I don't think it's too ambiguous (as we document it).

I am fine to support only one as string, and I would lean more toward the pypi name

- the plugin's module name (e.g. `backend="narwhals_grizzlies"`),
- or the plugin's module itself (e.g. `backend=narwhals_grizzlies`).

There are two mechanisms, depending on the function:
Comment thread
FBruzzesi marked this conversation as resolved.
Outdated

1. **IO functions** (`read_csv`, `scan_csv`, `read_parquet`, `scan_parquet`): the plugin
should expose a same-named function at the top level of its namespace which returns a
**native** object (which is then passed through `narwhals.from_native`):

- `read_csv(source, separator=",", **kwargs)`, `scan_csv(source, separator=",", **kwargs)`
- `read_parquet(source, **kwargs)`, `scan_parquet(source, **kwargs)`

If the required hook is missing, an informative `AttributeError` is raised.
Comment thread
FBruzzesi marked this conversation as resolved.
Outdated

2. **Eager constructors** (`from_dict`, `from_dicts`, `from_numpy`, `from_arrow`,
`new_series`, as well as the `DataFrame.from_*` and `Series.from_*` classmethods):
these are eager-only, and go through the compliant namespace returned by the plugin's
`__narwhals_namespace__`. If that namespace implements the `EagerNamespace` protocol
(in particular the `_dataframe` and `_series` properties, and the `from_dict`,
`from_dicts`, `from_numpy`, `from_arrow` and `from_iterable` constructors on the
respective compliant classes), these functions work with no extra plugin code.
Lazy-only plugins get an informative `ValueError` instead.

Methods which internally construct Series (for example `Series.scatter`, or
`DataFrame.filter` with a list of booleans) use the compliant namespace of the object
they are called on, so they also work for eager plugins.

## Can I see an example?

Yes! For a reference plugin, please check out [narwhals-daft](https://github.com/narwhals-dev/narwhals-daft).
29 changes: 28 additions & 1 deletion packages/test-plugin/src/test_plugin/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from typing_extensions import TypeIs
Expand All @@ -21,3 +21,30 @@ def is_native(native_object: object) -> TypeIs[DictFrame]:


NATIVE_PACKAGE = "builtins"


# The functions below are the IO extension hooks used by `narwhals.functions`
# when `backend` resolves to this plugin (https://github.com/narwhals-dev/narwhals/issues/3713).
# Eager constructors (`from_dict`, `new_series`, `Series.from_iterable`, ...) instead go
# through `DictNamespace._dataframe` / `DictNamespace._series`.


def scan_csv(source: str, separator: str = ",", **kwargs: Any) -> DictFrame: # noqa: ARG001
import csv
from pathlib import Path

with Path(source).open(newline="", encoding="utf-8") as file:
header, *rows = list(csv.reader(file, delimiter=separator))
return {name: [row[index] for row in rows] for index, name in enumerate(header)}


def scan_parquet(source: str, **kwargs: Any) -> DictFrame:
import pyarrow.parquet as pq

result: DictFrame = pq.read_table(source, **kwargs).to_pydict()
return result


# `read_*` returns the same native object; narwhals then enforces eagerness downstream.
read_csv = scan_csv
read_parquet = scan_parquet
78 changes: 77 additions & 1 deletion packages/test-plugin/src/test_plugin/dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,91 @@
from narwhals.typing import CompliantLazyFrame

if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from typing import TypeAlias

from typing_extensions import Self

from narwhals import LazyFrame # noqa: F401
from narwhals import DataFrame, LazyFrame # noqa: F401
from narwhals._utils import _LimitedContext

DictFrame: TypeAlias = dict[str, list[Any]]


class DictDataFrame:
"""Minimal eager frame, kept to the smallest surface exercised in narwhals' tests."""

_implementation = Implementation.UNKNOWN

def __init__(self, native_dataframe: DictFrame, *, version: Version) -> None:
self._native_frame: DictFrame = native_dataframe
self._version = version

@classmethod
def from_dict(
cls,
data: Mapping[str, Any],
/,
*,
context: _LimitedContext,
schema: Any = None, # noqa: ARG003
) -> Self:
return cls(
{name: list(values) for name, values in data.items()},
version=context._version,
)

@classmethod
def from_dicts(
cls,
data: Sequence[Mapping[str, Any]],
/,
*,
context: _LimitedContext,
schema: Any = None, # noqa: ARG003
) -> Self:
columns: list[str] = list(data[0]) if data else []
return cls(
{name: [row[name] for row in data] for name in columns},
version=context._version,
)

@classmethod
def from_numpy(
cls, data: Any, /, *, context: _LimitedContext, schema: Any = None
) -> Self:
names = (
list(schema)
if schema is not None
else [str(index) for index in range(data.shape[1])]
)
return cls(
{name: data[:, index].tolist() for index, name in enumerate(names)},
version=context._version,
)

@classmethod
def from_arrow(cls, data: Any, /, *, context: _LimitedContext) -> Self:
import pyarrow as pa

return cls(pa.table(data).to_pydict(), version=context._version)

def __narwhals_dataframe__(self) -> Self:
return self

def __narwhals_namespace__(self) -> Any:
from test_plugin.namespace import DictNamespace

return DictNamespace(version=self._version)

@property
def native(self) -> DictFrame:
return self._native_frame

def to_narwhals(self) -> DataFrame[Any]:
return self._version.dataframe(self, level="full")


class DictLazyFrame(
CompliantLazyFrame[Any, "DictFrame", "LazyFrame[DictFrame]"], # type: ignore[type-var]
ValidateBackendVersion,
Expand Down
20 changes: 17 additions & 3 deletions packages/test-plugin/src/test_plugin/namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
from typing import TYPE_CHECKING, Any

from narwhals._compliant import CompliantNamespace
from narwhals._utils import not_implemented
from test_plugin.dataframe import DictFrame, DictLazyFrame
from narwhals._utils import Implementation, not_implemented
from test_plugin.dataframe import DictDataFrame, DictFrame, DictLazyFrame

if TYPE_CHECKING:
from narwhals.utils import Version
from test_plugin.series import DictSeries


class DictNamespace(CompliantNamespace[DictLazyFrame, Any]):
Expand All @@ -17,9 +18,22 @@ def __init__(self, *, version: Version) -> None:
def from_native(self, native_object: DictFrame) -> DictLazyFrame:
return DictLazyFrame(native_object, version=self._version)

@property
def _dataframe(self) -> type[DictDataFrame]:
return DictDataFrame

@property
def _series(self) -> type[DictSeries]:
from test_plugin.series import DictSeries

return DictSeries

# NOTE: `not_implemented.__get__` reads `instance._implementation` to build its
# error message, so `_implementation` itself must be a real value.
_implementation = Implementation.UNKNOWN

is_native: Any = not_implemented()
_expr: Any = not_implemented()
_implementation: Any = not_implemented()
corr: Any = not_implemented()
cov: Any = not_implemented()
len: Any = not_implemented()
Expand Down
73 changes: 73 additions & 0 deletions packages/test-plugin/src/test_plugin/series.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Any

from narwhals._utils import Implementation

if TYPE_CHECKING:
from collections.abc import Iterable

from typing_extensions import Self

from narwhals._utils import Version, _LimitedContext
from narwhals.series import Series


class DictSeries:
"""Minimal eager series, kept to the smallest surface exercised in narwhals' tests."""

_implementation = Implementation.UNKNOWN

def __init__(
self, values: Iterable[Any], *, name: str = "", version: Version
) -> None:
self._values: list[Any] = list(values)
self._name = name
self._version = version

@classmethod
def from_iterable(
cls,
data: Iterable[Any],
/,
*,
context: _LimitedContext,
name: str = "",
dtype: Any = None, # noqa: ARG003
) -> Self:
return cls(data, name=name, version=context._version)

@classmethod
def from_numpy(cls, data: Any, /, *, context: _LimitedContext) -> Self:
return cls(data.tolist(), version=context._version)

def __narwhals_series__(self) -> Self:
return self

def __narwhals_namespace__(self) -> Any:
from test_plugin.namespace import DictNamespace

return DictNamespace(version=self._version)

@property
def native(self) -> list[Any]:
return self._values

@property
def name(self) -> str:
return self._name

def alias(self, name: str) -> Self:
return self.__class__(self._values, name=name, version=self._version)

def is_empty(self) -> bool:
return not self._values

def scatter(self, indices: Self, values: Self) -> Self:
data = list(self._values)
for index, value in zip(indices.native, values.native, strict=True):
data[index] = value
return self.__class__(data, name=self._name, version=self._version)

def to_narwhals(self) -> Series[Any]:
return self._version.series(self, level="full")
77 changes: 77 additions & 0 deletions src/narwhals/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
from narwhals._compliant.any_namespace import NamespaceAccessor
from narwhals._compliant.typing import (
Accessor,
EagerNamespaceAny,
EvalNames,
NativeDataFrameT,
NativeLazyFrameT,
Expand Down Expand Up @@ -1625,6 +1626,82 @@ def is_eager_allowed(impl: Implementation, /) -> TypeIs[_EagerAllowedImpl]:
}


def _ensure_eager_capable(
namespace: Any, /, *, source: str, function_name: str
) -> EagerNamespaceAny:
"""Duck-check that `namespace` implements the `EagerNamespace` protocol.

`_series` and `_dataframe` may be regular properties, missing entirely, or
`not_implemented` descriptors (which raise on instance access).
"""
try:
Comment thread
dangotbanned marked this conversation as resolved.
Outdated
eager_capable = namespace._series is not None and namespace._dataframe is not None
except (AttributeError, NotImplementedError):
eager_capable = False
Comment thread
FBruzzesi marked this conversation as resolved.
Outdated
if not eager_capable:
msg = (
f"Plugin backend {source!r} does not provide eager support (its "
"compliant namespace does not implement the `EagerNamespace` protocol), "
f"but `{function_name}` is an eager-only function."
)
raise ValueError(msg)
return cast("EagerNamespaceAny", namespace)


def eager_namespace(
backend: IntoBackend[Backend],
/,
*,
version: Version,
function_name: str,
hint_example: str,
) -> EagerNamespaceAny:
"""Resolve `backend` to an eager-capable 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 lazy-only backends raise an informative `ValueError`, suggesting
`hint_example` followed by a `.lazy(...)` call.
"""
implementation = Implementation.from_backend(backend)
if is_eager_allowed(implementation):
# NOTE: `cast` is required as the overload returns a union of concrete
# namespaces, whose invariant type parameters don't unify with `*Any` aliases.
return cast(
"EagerNamespaceAny", 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" {hint_example}.lazy('{implementation}')"
)
raise ValueError(msg)
from narwhals.plugins import _backend_namespace, _plugin_hook

module = _backend_namespace(backend)
namespace = _plugin_hook(module, "__narwhals_namespace__")(version=version)
return _ensure_eager_capable(
namespace, source=module.__name__, function_name=function_name
)


def eager_namespace_from_compliant(
compliant_object: Any, /, *, function_name: str
) -> EagerNamespaceAny:
"""Resolve the eager namespace of a compliant object originating from a plugin.

`Implementation.UNKNOWN` cannot be resolved back to a plugin, so methods which
internally construct series use the namespace of the compliant object itself.
"""
namespace = compliant_object.__narwhals_namespace__()
return _ensure_eager_capable(
namespace, source=type(namespace).__name__, function_name=function_name
)


def can_lazyframe_collect(impl: Implementation, /) -> TypeIs[_LazyFrameCollectImpl]:
"""Return True if `LazyFrame.collect(impl)` is allowed."""
return impl in {Implementation.PANDAS, Implementation.POLARS, Implementation.PYARROW}
Expand Down
Loading
Loading