-
Notifications
You must be signed in to change notification settings - Fork 170
feat: introduce (experimental) plugin system #2978
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6d36b4e
1062d99
8d4cda6
cfd156f
e3a2f3b
f65d7bf
a133796
eee9068
c3a8c4d
ca01346
c4611fe
90ad973
76232df
ebc1a8f
15d9c8c
16832f9
9e11a8f
d8663d8
34e7fb6
8a2b019
f92cdbf
54509d2
941edc4
72a5df4
e783af7
4cf2f96
a52a6bb
a4e539a
aca8cc4
02d544a
0952cd9
de0e5ce
9dfcd09
b999e9a
3460f4e
1c246dc
ab8303d
a8501f5
19dc900
42f2df8
d6a384a
9cf290d
bdd71e7
bde288f
7035533
4868aab
afd8620
b09d3ad
1a73ab8
1481d48
59589c1
6888f78
8f22fdb
b3f2b60
422ec70
b0b524b
0960d69
3207de1
d951220
ea28de2
b563ace
9847793
1f38e31
5dee0bb
c508ab0
f2d6e57
6c65fd4
b26d7d4
22362ad
d22f046
028e473
87e67bf
5c16c7f
e00c7c6
0c69762
f7765e8
e3e5ec7
0cd5f2f
d511fc0
6dd4d1c
8a96d84
1944e47
ce712e0
7bcec61
9bbc0a6
0f65066
8ad25ea
21aab4a
0138803
fa09ba6
213cc31
da2d115
3a10b68
2d3f034
547db41
afd4d83
910a31e
f227c13
3ef415d
793a1cd
c0d9d00
725ce3c
bd95e6d
150dc52
dc82860
41a64c0
242d8dd
62ea8c9
29d7b8e
1b877d4
f7e5ae9
ee37848
28ca7fd
19d08e1
840c65f
4550ed3
79ce894
a49e214
b980628
0cdb198
4a3f43b
3f06f3a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,12 +6,14 @@ | |
|
|
||
| If you want your own library to be recognised too, you're welcome open a PR (with tests)!. | ||
| Alternatively, if you can't do that (for example, if you library is closed-source), see | ||
| the next section for what else you can do. | ||
| the next sections for what else you can do. | ||
|
|
||
| ## Creating an Extension | ||
|
|
||
| We love open source, but we're not "open source absolutists". If you're unable to open | ||
| source you library, then this is how you can make your library compatible with Narwhals. | ||
| source your library, then this is how you can make your library compatible with Narwhals. | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure about the 'also' on the line below. I read this as telling the reader some stuff X needs to be defined, and then we're reminding them to along with that stuff X also define the list that follows. In which case I'm left to look for a description for stuff X. Maybe I'm misreading? If we're wanting to say 'along with whatever you want your extension to do, you'll have to implement these', maybe something like the following could be an alternative to the line: "Along with your use-case, make sure that you also define:" There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you can remove "also", i probably wrote it by accident |
||
| Make sure that you also define: | ||
| Make sure that you define: | ||
|
|
||
| - `DataFrame.__narwhals_dataframe__`: return an object which implements methods from the | ||
| `CompliantDataFrame` protocol in `narwhals/typing.py`. | ||
|
|
@@ -33,3 +35,36 @@ Make sure that you also define: | |
|
|
||
| Note that this "extension" mechanism is still experimental. If anything is not clear, or | ||
| doesn't work, please do raise an issue or contact us on Discord (see the link on the README). | ||
|
|
||
| ## Creating a Plugin | ||
|
|
||
| If it's not possible to add extra functions like `__narwhals_namespace__` and others to a dataframe object | ||
| itself, then another option is to write a plugin. Narwhals itself has the necessary utilities to detect and | ||
| handle plugins. For this integration to work, any plugin architecture must contain the following: | ||
|
|
||
| 1. an entrypoint defined in a `pyproject.toml` file: | ||
|
|
||
| ``` | ||
| [project.entry-points.'narwhals.plugins'] | ||
| narwhals-<library name> = 'narwhals_<library name>' | ||
| ``` | ||
| The section name needs to be the same for all plugins; inside it, plugin creators can replace their | ||
| own library name, for example `narwhals-grizzlies = 'narwhals_grizzlies'` | ||
|
|
||
|
|
||
| 2. a top-level `__init__.py`Β file containing the following: | ||
|
|
||
| - `is_native` and `__narwhals_namespace__` functions | ||
| - a string constant `NATIVE_PACKAGE` which holds the name of the library for which the plugin is made | ||
|
|
||
| `is_native` accepts a native object and returns a boolean indicating whether the native object is | ||
| a dataframe of the library the plugin was written for. | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. for the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it's fine to include it, a bit of repetition in docs is OK IMO |
||
| `__narwhals_namespace__` takes the Narwhals version and returns a compliant namespace for the library, | ||
| i.e. one that complies with theΒ CompliantNamespaceΒ protocol. This protocol specifies a `from_native` | ||
| function, whose input parameter is the Narwhals version and which returns a compliant Narwhals LazyFrame | ||
| which wraps the native dataframe. | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe 'bare-bones' will be legacy soon, so maybe we should say "in progress"? |
||
| If you want to see an example of a plugin, we have implemented a bare-bones version for the `daft` library | ||
| that allows users to pass daft dataframes to Narwhals: | ||
| [narwhals-daft](https://github.com/MarcoGorelli/narwhals-daft). | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import sys | ||
| from functools import cache | ||
| from typing import TYPE_CHECKING, Any, Protocol, cast | ||
|
|
||
| from narwhals._compliant import CompliantNamespace | ||
| from narwhals._typing_compat import TypeVar | ||
|
|
||
| if TYPE_CHECKING: | ||
| from collections.abc import Iterator | ||
| from importlib.metadata import EntryPoints | ||
|
|
||
| from typing_extensions import LiteralString, TypeAlias | ||
|
|
||
| from narwhals._compliant.typing import ( | ||
| CompliantDataFrameAny, | ||
| CompliantFrameAny, | ||
| CompliantLazyFrameAny, | ||
| CompliantSeriesAny, | ||
| ) | ||
| from narwhals.utils import Version | ||
|
|
||
|
|
||
| __all__ = ["Plugin", "from_native"] | ||
|
|
||
| CompliantAny: TypeAlias = ( | ||
| "CompliantDataFrameAny | CompliantLazyFrameAny | CompliantSeriesAny" | ||
| ) | ||
| """A statically-unknown, Compliant object originating from a plugin.""" | ||
|
|
||
| FrameT = TypeVar( | ||
| "FrameT", | ||
| bound="CompliantFrameAny", | ||
| default="CompliantDataFrameAny | CompliantLazyFrameAny", | ||
| ) | ||
| FromNativeR_co = TypeVar( | ||
| "FromNativeR_co", bound=CompliantAny, covariant=True, default=CompliantAny | ||
| ) | ||
|
|
||
|
|
||
| @cache | ||
| def _discover_entrypoints() -> EntryPoints: | ||
| from importlib.metadata import entry_points as eps | ||
|
|
||
| group = "narwhals.plugins" | ||
| if sys.version_info < (3, 10): | ||
| return cast("EntryPoints", eps().get(group, ())) | ||
| return eps(group=group) | ||
|
|
||
|
|
||
| class PluginNamespace(CompliantNamespace[FrameT, Any], Protocol[FrameT, FromNativeR_co]): | ||
| def from_native(self, data: Any, /) -> FromNativeR_co: ... | ||
|
|
||
|
|
||
| class Plugin(Protocol[FrameT, FromNativeR_co]): | ||
| NATIVE_PACKAGE: LiteralString | ||
|
|
||
| def __narwhals_namespace__( | ||
| self, version: Version | ||
| ) -> PluginNamespace[FrameT, FromNativeR_co]: ... | ||
| def is_native(self, native_object: object, /) -> bool: ... | ||
|
|
||
|
|
||
| @cache | ||
| def _might_be(cls: type, type_: str) -> bool: # pragma: no cover | ||
| try: | ||
| return any(type_ in o.__module__.split(".") for o in cls.mro()) | ||
| except TypeError: | ||
| return False | ||
|
|
||
|
|
||
| def _is_native_plugin(native_object: Any, plugin: Plugin) -> bool: | ||
| pkg = plugin.NATIVE_PACKAGE | ||
| return ( | ||
| sys.modules.get(pkg) is not None | ||
| and _might_be(type(native_object), pkg) # type: ignore[arg-type] | ||
| and plugin.is_native(native_object) | ||
| ) | ||
|
|
||
|
|
||
| def _iter_from_native(native_object: Any, version: Version) -> Iterator[CompliantAny]: | ||
| for entry_point in _discover_entrypoints(): | ||
| plugin: Plugin = entry_point.load() | ||
| if _is_native_plugin(native_object, plugin): | ||
| compliant_namespace = plugin.__narwhals_namespace__(version=version) | ||
| yield compliant_namespace.from_native(native_object) | ||
|
|
||
|
|
||
| def from_native(native_object: Any, version: Version) -> CompliantAny | None: | ||
| """Attempt to convert `native_object` to a Compliant object, using any available plugin(s). | ||
|
Comment on lines
+90
to
+91
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just linking some stuff from the other PR, @ym-pett feel free to add to this π
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
thanks @dangotbanned I couldn't find that merged branch anymore, glad you've located it! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've added #3130 in the linked issues as I think it'll fix the type checking failure in the tests. |
||
|
|
||
| Arguments: | ||
| native_object: Raw object from user. | ||
| version: Narwhals API version. | ||
|
|
||
| Returns: | ||
| If the following conditions are met | ||
| - at least 1 plugin is installed | ||
| - at least 1 installed plugin supports `type(native_object)` | ||
|
|
||
| Then for the **first matching plugin**, the result of the call below. | ||
| This *should* be an object accepted by a Narwhals Dataframe, Lazyframe, or Series: | ||
|
|
||
| plugin: Plugin | ||
| plugin.__narwhals_namespace__(version).from_native(native_object) | ||
|
|
||
| In all other cases, `None` is returned instead. | ||
| """ | ||
| return next(_iter_from_native(native_object, version), None) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import pytest | ||
|
|
||
| import narwhals as nw | ||
|
|
||
|
|
||
| def test_plugin() -> None: | ||
| pytest.importorskip("test_plugin") | ||
| df_native = {"a": [1, 1, 2], "b": [4, 5, 6]} | ||
| lf = nw.from_native(df_native) # type: ignore[call-overload] | ||
| assert isinstance(lf, nw.LazyFrame) | ||
| assert lf.columns == ["a", "b"] |
Uh oh!
There was an error while loading. Please reload this page.