Skip to content

Commit b45c1ae

Browse files
ym-pettpre-commit-ci[bot]MarcoGorellidangotbanned
authored
feat: introduce (experimental) plugin system (#2978)
--------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Marco Gorelli <[email protected]> Co-authored-by: dangotbanned <[email protected]>
1 parent e028741 commit b45c1ae

File tree

13 files changed

+401
-44
lines changed

13 files changed

+401
-44
lines changed

.github/workflows/pytest.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ jobs:
3333
run: uv pip freeze
3434
- name: Run pytest
3535
run: pytest tests --cov=narwhals --cov=tests --cov-fail-under=75 --constructors=pandas,pyarrow,polars[eager],polars[lazy]
36+
- name: install-test-plugin
37+
run: uv pip install -e tests/test_plugin --system
3638

3739
pytest-windows:
3840
strategy:
@@ -54,6 +56,8 @@ jobs:
5456
- name: install-reqs
5557
# we are not testing pyspark, modin, or dask on Windows here because nobody got time for that
5658
run: uv pip install -e ".[ibis]" --group core-tests --group extra --system
59+
- name: install-test-plugin
60+
run: uv pip install -e tests/test_plugin --system
5761
- name: show-deps
5862
run: uv pip freeze
5963
- name: Run pytest
@@ -84,6 +88,8 @@ jobs:
8488
cache-dependency-glob: "pyproject.toml"
8589
- name: install-reqs
8690
run: uv pip install -e ".[dask, modin, ibis]" --group core-tests --group extra --system
91+
- name: install-test-plugin
92+
run: uv pip install -e tests/test_plugin --system
8793
- name: show-deps
8894
run: uv pip freeze
8995
- name: Run pytest

.pre-commit-config.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,10 @@ repos:
115115
rev: v6.0.0
116116
hooks:
117117
- id: name-tests-test
118-
exclude: ^tests/utils\.py
118+
exclude: |
119+
(?x)
120+
^(tests/utils\.py)
121+
|^(tests/test_plugin/)
119122
- id: no-commit-to-branch
120123
- id: end-of-file-fixer
121124
exclude: .svg$

docs/extending.md

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66

77
If you want your own library to be recognised too, you're welcome open a PR (with tests)!.
88
Alternatively, if you can't do that (for example, if you library is closed-source), see
9-
the next section for what else you can do.
9+
the next sections for what else you can do.
10+
11+
## Creating an Extension
1012

1113
We love open source, but we're not "open source absolutists". If you're unable to open
12-
source you library, then this is how you can make your library compatible with Narwhals.
14+
source your library, then this is how you can make your library compatible with Narwhals.
1315

14-
Make sure that you also define:
16+
Make sure that you define:
1517

1618
- `DataFrame.__narwhals_dataframe__`: return an object which implements methods from the
1719
`CompliantDataFrame` protocol in `narwhals/typing.py`.
@@ -33,3 +35,36 @@ Make sure that you also define:
3335

3436
Note that this "extension" mechanism is still experimental. If anything is not clear, or
3537
doesn't work, please do raise an issue or contact us on Discord (see the link on the README).
38+
39+
## Creating a Plugin
40+
41+
If it's not possible to add extra functions like `__narwhals_namespace__` and others to a dataframe object
42+
itself, then another option is to write a plugin. Narwhals itself has the necessary utilities to detect and
43+
handle plugins. For this integration to work, any plugin architecture must contain the following:
44+
45+
1. an entrypoint defined in a `pyproject.toml` file:
46+
47+
```
48+
[project.entry-points.'narwhals.plugins']
49+
narwhals-<library name> = 'narwhals_<library name>'
50+
```
51+
The section name needs to be the same for all plugins; inside it, plugin creators can replace their
52+
own library name, for example `narwhals-grizzlies = 'narwhals_grizzlies'`
53+
54+
55+
2. a top-level `__init__.py` file containing the following:
56+
57+
- `is_native` and `__narwhals_namespace__` functions
58+
- a string constant `NATIVE_PACKAGE` which holds the name of the library for which the plugin is made
59+
60+
`is_native` accepts a native object and returns a boolean indicating whether the native object is
61+
a dataframe of the library the plugin was written for.
62+
63+
`__narwhals_namespace__` takes the Narwhals version and returns a compliant namespace for the library,
64+
i.e. one that complies with the CompliantNamespace protocol. This protocol specifies a `from_native`
65+
function, whose input parameter is the Narwhals version and which returns a compliant Narwhals LazyFrame
66+
which wraps the native dataframe.
67+
68+
If you want to see an example of a plugin, we have implemented a bare-bones version for the `daft` library
69+
that allows users to pass daft dataframes to Narwhals:
70+
[narwhals-daft](https://github.com/MarcoGorelli/narwhals-daft).

narwhals/plugins.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from functools import cache
5+
from typing import TYPE_CHECKING, Any, Protocol, cast
6+
7+
from narwhals._compliant import CompliantNamespace
8+
from narwhals._typing_compat import TypeVar
9+
10+
if TYPE_CHECKING:
11+
from collections.abc import Iterator
12+
from importlib.metadata import EntryPoints
13+
14+
from typing_extensions import LiteralString, TypeAlias
15+
16+
from narwhals._compliant.typing import (
17+
CompliantDataFrameAny,
18+
CompliantFrameAny,
19+
CompliantLazyFrameAny,
20+
CompliantSeriesAny,
21+
)
22+
from narwhals.utils import Version
23+
24+
25+
__all__ = ["Plugin", "from_native"]
26+
27+
CompliantAny: TypeAlias = (
28+
"CompliantDataFrameAny | CompliantLazyFrameAny | CompliantSeriesAny"
29+
)
30+
"""A statically-unknown, Compliant object originating from a plugin."""
31+
32+
FrameT = TypeVar(
33+
"FrameT",
34+
bound="CompliantFrameAny",
35+
default="CompliantDataFrameAny | CompliantLazyFrameAny",
36+
)
37+
FromNativeR_co = TypeVar(
38+
"FromNativeR_co", bound=CompliantAny, covariant=True, default=CompliantAny
39+
)
40+
41+
42+
@cache
43+
def _discover_entrypoints() -> EntryPoints:
44+
from importlib.metadata import entry_points as eps
45+
46+
group = "narwhals.plugins"
47+
if sys.version_info < (3, 10):
48+
return cast("EntryPoints", eps().get(group, ()))
49+
return eps(group=group)
50+
51+
52+
class PluginNamespace(CompliantNamespace[FrameT, Any], Protocol[FrameT, FromNativeR_co]):
53+
def from_native(self, data: Any, /) -> FromNativeR_co: ...
54+
55+
56+
class Plugin(Protocol[FrameT, FromNativeR_co]):
57+
NATIVE_PACKAGE: LiteralString
58+
59+
def __narwhals_namespace__(
60+
self, version: Version
61+
) -> PluginNamespace[FrameT, FromNativeR_co]: ...
62+
def is_native(self, native_object: object, /) -> bool: ...
63+
64+
65+
@cache
66+
def _might_be(cls: type, type_: str) -> bool: # pragma: no cover
67+
try:
68+
return any(type_ in o.__module__.split(".") for o in cls.mro())
69+
except TypeError:
70+
return False
71+
72+
73+
def _is_native_plugin(native_object: Any, plugin: Plugin) -> bool:
74+
pkg = plugin.NATIVE_PACKAGE
75+
return (
76+
sys.modules.get(pkg) is not None
77+
and _might_be(type(native_object), pkg) # type: ignore[arg-type]
78+
and plugin.is_native(native_object)
79+
)
80+
81+
82+
def _iter_from_native(native_object: Any, version: Version) -> Iterator[CompliantAny]:
83+
for entry_point in _discover_entrypoints():
84+
plugin: Plugin = entry_point.load()
85+
if _is_native_plugin(native_object, plugin):
86+
compliant_namespace = plugin.__narwhals_namespace__(version=version)
87+
yield compliant_namespace.from_native(native_object)
88+
89+
90+
def from_native(native_object: Any, version: Version) -> CompliantAny | None:
91+
"""Attempt to convert `native_object` to a Compliant object, using any available plugin(s).
92+
93+
Arguments:
94+
native_object: Raw object from user.
95+
version: Narwhals API version.
96+
97+
Returns:
98+
If the following conditions are met
99+
- at least 1 plugin is installed
100+
- at least 1 installed plugin supports `type(native_object)`
101+
102+
Then for the **first matching plugin**, the result of the call below.
103+
This *should* be an object accepted by a Narwhals Dataframe, Lazyframe, or Series:
104+
105+
plugin: Plugin
106+
plugin.__narwhals_namespace__(version).from_native(native_object)
107+
108+
In all other cases, `None` is returned instead.
109+
"""
110+
return next(_iter_from_native(native_object, version), None)

narwhals/stable/v2/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,7 @@ def from_native( # noqa: D417
566566
eager_only=eager_only,
567567
series_only=series_only,
568568
allow_series=allow_series,
569+
eager_or_interchange_only=False,
569570
version=Version.V2,
570571
)
571572

narwhals/translate.py

Lines changed: 82 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,22 @@
55
from functools import wraps
66
from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar, overload
77

8+
from narwhals import plugins
89
from narwhals._constants import EPOCH, MS_PER_SECOND
910
from narwhals._native import (
1011
is_native_arrow,
1112
is_native_pandas_like,
1213
is_native_polars,
1314
is_native_spark_like,
1415
)
15-
from narwhals._utils import Implementation, Version, has_native_namespace
16+
from narwhals._utils import (
17+
Implementation,
18+
Version,
19+
has_native_namespace,
20+
is_compliant_dataframe,
21+
is_compliant_lazyframe,
22+
is_compliant_series,
23+
)
1624
from narwhals.dependencies import (
1725
get_dask_expr,
1826
get_numpy,
@@ -314,23 +322,65 @@ def from_native( # noqa: D417
314322
)
315323

316324

325+
def _translate_if_compliant( # noqa: C901,PLR0911
326+
compliant_object: Any,
327+
*,
328+
pass_through: bool = False,
329+
eager_only: bool = False,
330+
# Interchange-level was removed after v1
331+
eager_or_interchange_only: bool,
332+
series_only: bool,
333+
allow_series: bool | None,
334+
version: Version,
335+
) -> Any:
336+
if is_compliant_dataframe(compliant_object):
337+
if series_only:
338+
if not pass_through:
339+
msg = "Cannot only use `series_only` with dataframe"
340+
raise TypeError(msg)
341+
return compliant_object
342+
return version.dataframe(
343+
compliant_object.__narwhals_dataframe__()._with_version(version), level="full"
344+
)
345+
if is_compliant_lazyframe(compliant_object):
346+
if series_only:
347+
if not pass_through:
348+
msg = "Cannot only use `series_only` with lazyframe"
349+
raise TypeError(msg)
350+
return compliant_object
351+
if eager_only or eager_or_interchange_only:
352+
if not pass_through:
353+
msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with lazyframe"
354+
raise TypeError(msg)
355+
return compliant_object
356+
return version.lazyframe(
357+
compliant_object.__narwhals_lazyframe__()._with_version(version), level="full"
358+
)
359+
if is_compliant_series(compliant_object):
360+
if not allow_series:
361+
if not pass_through:
362+
msg = "Please set `allow_series=True` or `series_only=True`"
363+
raise TypeError(msg)
364+
return compliant_object
365+
return version.series(
366+
compliant_object.__narwhals_series__()._with_version(version), level="full"
367+
)
368+
# Object wasn't compliant, can't translate here.
369+
return None
370+
371+
317372
def _from_native_impl( # noqa: C901, PLR0911, PLR0912, PLR0915
318373
native_object: Any,
319374
*,
320375
pass_through: bool = False,
321376
eager_only: bool = False,
322377
# Interchange-level was removed after v1
323-
eager_or_interchange_only: bool = False,
324-
series_only: bool = False,
325-
allow_series: bool | None = None,
378+
eager_or_interchange_only: bool,
379+
series_only: bool,
380+
allow_series: bool | None,
326381
version: Version,
327382
) -> Any:
328383
from narwhals._interchange.dataframe import supports_dataframe_interchange
329-
from narwhals._utils import (
330-
is_compliant_dataframe,
331-
is_compliant_lazyframe,
332-
is_compliant_series,
333-
)
334384
from narwhals.dataframe import DataFrame, LazyFrame
335385
from narwhals.series import Series
336386

@@ -350,38 +400,18 @@ def _from_native_impl( # noqa: C901, PLR0911, PLR0912, PLR0915
350400
raise ValueError(msg)
351401

352402
# Extensions
353-
if is_compliant_dataframe(native_object):
354-
if series_only:
355-
if not pass_through:
356-
msg = "Cannot only use `series_only` with dataframe"
357-
raise TypeError(msg)
358-
return native_object
359-
return version.dataframe(
360-
native_object.__narwhals_dataframe__()._with_version(version), level="full"
361-
)
362-
if is_compliant_lazyframe(native_object):
363-
if series_only:
364-
if not pass_through:
365-
msg = "Cannot only use `series_only` with lazyframe"
366-
raise TypeError(msg)
367-
return native_object
368-
if eager_only or eager_or_interchange_only:
369-
if not pass_through:
370-
msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with lazyframe"
371-
raise TypeError(msg)
372-
return native_object
373-
return version.lazyframe(
374-
native_object.__narwhals_lazyframe__()._with_version(version), level="full"
375-
)
376-
if is_compliant_series(native_object):
377-
if not allow_series:
378-
if not pass_through:
379-
msg = "Please set `allow_series=True` or `series_only=True`"
380-
raise TypeError(msg)
381-
return native_object
382-
return version.series(
383-
native_object.__narwhals_series__()._with_version(version), level="full"
403+
if (
404+
translated := _translate_if_compliant(
405+
native_object,
406+
pass_through=pass_through,
407+
eager_only=eager_only,
408+
eager_or_interchange_only=eager_or_interchange_only,
409+
series_only=series_only,
410+
allow_series=allow_series,
411+
version=version,
384412
)
413+
) is not None:
414+
return translated
385415

386416
# Polars
387417
if is_native_polars(native_object):
@@ -534,6 +564,18 @@ def _from_native_impl( # noqa: C901, PLR0911, PLR0912, PLR0915
534564
raise TypeError(msg)
535565
return Version.V1.dataframe(InterchangeFrame(native_object), level="interchange")
536566

567+
compliant_object = plugins.from_native(native_object, version)
568+
if compliant_object is not None:
569+
return _translate_if_compliant(
570+
compliant_object,
571+
pass_through=pass_through,
572+
eager_only=eager_only,
573+
eager_or_interchange_only=eager_or_interchange_only,
574+
series_only=series_only,
575+
allow_series=allow_series,
576+
version=version,
577+
)
578+
537579
if not pass_through:
538580
msg = f"Expected pandas-like dataframe, Polars dataframe, or Polars lazyframe, got: {type(native_object)}"
539581
raise TypeError(msg)

tests/plugins_test.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
import narwhals as nw
6+
7+
8+
def test_plugin() -> None:
9+
pytest.importorskip("test_plugin")
10+
df_native = {"a": [1, 1, 2], "b": [4, 5, 6]}
11+
lf = nw.from_native(df_native) # type: ignore[call-overload]
12+
assert isinstance(lf, nw.LazyFrame)
13+
assert lf.columns == ["a", "b"]

tests/test_plugin/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)