Skip to content
Open
Show file tree
Hide file tree
Changes from 15 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
1 change: 1 addition & 0 deletions docs/api-reference/exceptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- InvalidOperationError
- MultiOutputExpressionError
- NarwhalsUnstableWarning
- PluginError
- ShapeError
- UnsupportedDTypeError
show_source: false
Expand Down
1 change: 1 addition & 0 deletions docs/api-reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@
- [narwhals.selectors](selectors.md)
- [narwhals.typing](typing.md)
- [narwhals.utils](utils.md)
- [narwhals.plugins](plugins.md)
13 changes: 13 additions & 0 deletions docs/api-reference/plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# `narwhals.plugins`

For an overview of how to write a plugin, see [extensions and plugins](../extending.md).

::: narwhals.plugins
handler: python
options:
members:
- Plugin
- PluginName
- from_native
show_source: false
show_bases: false
2 changes: 2 additions & 0 deletions docs/api-reference/typing.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Narwhals comes fully statically typed. In addition to `nw.DataFrame`, `nw.Expr`,
- Backend
- EagerAllowed
- LazyAllowed
- FileSource
- NormalizedPath
- IntoDType
- IntoSchema
- SizeUnit
Expand Down
45 changes: 43 additions & 2 deletions docs/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,52 @@ 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.

## IO functions: the namespace contract
## 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`).

In all cases, 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
[namespace contract](#io-functions-the-namespace-contract) below. If the plugin's
namespace does not implement the required method, an informative
[`PluginError`](api-reference/exceptions.md) is raised.

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. If the plugin's compliant namespace implements the
`EagerNamespace` protocol (in particular the `_dataframe` and `_series` properties,
Comment thread
FBruzzesi marked this conversation as resolved.
Outdated
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
[`PluginError`](api-reference/exceptions.md) 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.

!!! tip "Type checking"

The `backend` parameters of these functions are typed with
[`PluginName`](api-reference/plugins.md), a `str`
[`NewType`](https://docs.python.org/3/library/typing.html#newtype): plugin names
are only known at runtime, so an opaque string does not type check, but an
explicitly wrapped one does, e.g.
`nw.from_dict(data, backend=PluginName("narwhals-grizzlies"))`.
Passing the plain string works at runtime all the same.

### IO functions: the namespace contract

The Narwhals IO functions (`read_csv`, `scan_csv`, `read_parquet`, `scan_parquet`)
dispatch to same-named methods on the compliant namespace. This is a single mechanism,
shared by built-in backends and extensions alike: to support these functions, a
shared by built-in backends and plugins alike: to support these functions, a
compliant namespace implements (a subset of):

```py
Expand Down
82 changes: 80 additions & 2 deletions 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
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 All @@ -33,6 +109,9 @@ def __init__(self, native_dataframe: DictFrame, *, version: Version) -> None:
def __narwhals_lazyframe__(self) -> Self:
return self

def to_narwhals(self) -> LazyFrame[Any]:
return self._version.lazyframe(self, level="lazy")

@property
def columns(self) -> list[str]: # pragma: no cover
return list(self._native_frame.keys())
Expand Down Expand Up @@ -74,7 +153,6 @@ def _with_version(self, version: Version) -> Self:
sink_parquet = not_implemented()
sort = not_implemented()
tail = not_implemented()
to_narwhals = not_implemented()
unique = not_implemented()
unpivot = not_implemented()
with_columns = not_implemented()
Expand Down
45 changes: 40 additions & 5 deletions packages/test-plugin/src/test_plugin/namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
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.typing import NormalizedPath
from narwhals.utils import Version
from test_plugin.series import DictSeries


class DictNamespace(CompliantNamespace[DictLazyFrame, Any]):
Expand All @@ -17,9 +19,44 @@ 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

# IO methods below follow the namespace contract used by `narwhals.functions`
# (see "IO functions: the namespace contract" in `docs/extending.md`).
# `read_*` are deliberately left unimplemented: `test_plugin` wraps dicts
# lazily, so it only supports `scan_*`.

def scan_csv(

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'm a bit confused by this, since the test suite implements them in a subclass?

In my head it seems like the test suite should aim to avoid importing from test_plugin and access things through the Plugin interface.
If we can't do that - then it would point to gaps in the definition of Plugin or Compliant* that we need to fill

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.

Things to consider

  1. Is test_plugin enough, or do we require multiple packages?
  2. If we need to import from test_plugin directly for a test, are there any alternative ways we could write it within the current definitions we have?

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.

FYI, I started trying to adapt some tests for improving typing coverage in (feat/extend-from-backend...tests/3753-typing-cov).
But now I'm thinking that parts of that 1 are solving the wrong problem and that addressing 1 would make the tests simpler

Footnotes

  1. Adding typing to the dynamic creations of modules/plugins/namespaces

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.

DictNamespace deliberately did not implement read_*, so that test_read_plugin_scan_only could assert the PluginError. But test_read_plugin_eager_namespace needed those same methods to exist, to cover the eager-read success path. So the test added a subclass to get a second namespace shape, and wrapping it in a types.ModuleType turned it into a second plugin. The subclass wasn't the goal, it was the workaround for a contradiction I'd created by keeping DictNamespace scan-only.

Addressed that in d7e5350

self, source: NormalizedPath, *, separator: str = ",", **kwds: Any
) -> DictLazyFrame:
import csv
from pathlib import Path

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

def scan_parquet(self, source: NormalizedPath, **kwds: Any) -> DictLazyFrame:
import pyarrow.parquet as pq

data: DictFrame = pq.read_table(source, **kwds).to_pydict()
return DictLazyFrame(data, version=self._version)

# 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 All @@ -37,5 +74,3 @@ def from_native(self, native_object: DictFrame) -> DictLazyFrame:
selectors: Any = not_implemented()
coalesce: Any = not_implemented()
struct: Any = not_implemented()
scan_csv: Any = not_implemented()
scan_parquet: Any = not_implemented()
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")
Loading
Loading