- 
                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.