Skip to content
Merged
Show file tree
Hide file tree
Changes from 87 commits
Commits
Show all changes
122 commits
Select commit Hold shift + click to select a range
6d36b4e
attempted outline of how things could look
ym-pett Aug 13, 2025
1062d99
attempting to read in plugins
ym-pett Aug 13, 2025
8d4cda6
linting
ym-pett Aug 13, 2025
cfd156f
trying to see an effect of plugin
ym-pett Aug 13, 2025
e3a2f3b
cleanup after pair session
ym-pett Aug 14, 2025
f65d7bf
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 14, 2025
a133796
Merge branch 'narwhals-dev:main' into create_fromnative_daft
ym-pett Aug 14, 2025
eee9068
think we're managing to import the class
ym-pett Aug 14, 2025
c3a8c4d
changes mean we can read daft df, joy
ym-pett Aug 14, 2025
ca01346
Merge branch 'main' into create_fromnative_daft
ym-pett Aug 15, 2025
c4611fe
nicer error handling, not there yet
ym-pett Aug 16, 2025
90ad973
nicer error handling, not there yet
ym-pett Aug 16, 2025
76232df
added some thoughts, currently going in circles
ym-pett Aug 16, 2025
ebc1a8f
added some thoughts, currently going in circles
ym-pett Aug 16, 2025
15d9c8c
work from pair session
ym-pett Aug 18, 2025
16832f9
more explicit error handling
ym-pett Aug 18, 2025
9e11a8f
error handling now passes ruff check
ym-pett Aug 18, 2025
d8663d8
moved plugins back to end, raising general Exception
ym-pett Aug 19, 2025
34e7fb6
silencing ruff errors, deleted t.py
ym-pett Aug 19, 2025
8a2b019
Merge branch 'main' into create_fromnative_daft
ym-pett Aug 19, 2025
f92cdbf
Merge branch 'main' into create_fromnative_daft
ym-pett Aug 19, 2025
54509d2
Merge branch 'main' into create_fromnative_daft
ym-pett Aug 19, 2025
941edc4
Merge branch 'main' into create_fromnative_daft
ym-pett Aug 19, 2025
72a5df4
checking for version and preventing tests on plugin codeblock
ym-pett Aug 19, 2025
e783af7
wip implementing marco's proposal
ym-pett Aug 20, 2025
4cf2f96
new version passes tests
ym-pett Aug 20, 2025
a52a6bb
discover_plugins function and fixed pragma no cover
ym-pett Aug 20, 2025
a4e539a
added pragma, removed group argument
ym-pett Aug 22, 2025
aca8cc4
Merge branch 'main' into create_fromnative_daft
ym-pett Aug 22, 2025
02d544a
wip: add test-plugin
MarcoGorelli Aug 22, 2025
0952cd9
wip
MarcoGorelli Aug 22, 2025
de0e5ce
wip
MarcoGorelli Aug 22, 2025
9dfcd09
fixup
MarcoGorelli Aug 22, 2025
b999e9a
remove unused function, fixup type ignore
MarcoGorelli Aug 22, 2025
3460f4e
install test-plugin in CI
MarcoGorelli Aug 22, 2025
1c246dc
pass Version down
MarcoGorelli Aug 22, 2025
ab8303d
fixup, remove more defaults
MarcoGorelli Aug 22, 2025
a8501f5
coverage
MarcoGorelli Aug 22, 2025
19dc900
actually stage test file
MarcoGorelli Aug 22, 2025
42f2df8
remove daft traces
MarcoGorelli Aug 22, 2025
d6a384a
rename
MarcoGorelli Aug 22, 2025
9cf290d
install test_plugin in makefile
MarcoGorelli Aug 22, 2025
bdd71e7
coverage
MarcoGorelli Aug 22, 2025
bde288f
fix typing (for real this time)
MarcoGorelli Aug 22, 2025
7035533
aah ruff check was removing the not-really-unused import
MarcoGorelli Aug 22, 2025
4868aab
lru_cache -> cache
MarcoGorelli Aug 22, 2025
afd8620
Merge branch 'main' into create_fromnative_daft
ym-pett Sep 1, 2025
b09d3ad
merged main into branch to resolve conflicts
ym-pett Sep 4, 2025
1a73ab8
feat(suggestion): `<3.10` support?
dangotbanned Sep 4, 2025
1481d48
fix: don't expect plugins?
dangotbanned Sep 4, 2025
59589c1
chore(typing): Ignore unimplemented
dangotbanned Sep 4, 2025
6888f78
Merge branch 'main' into create_fromnative_daft
ym-pett Sep 8, 2025
8f22fdb
wip: hybrid approach to importing
ym-pett Sep 8, 2025
b3f2b60
wip: checkpoint
ym-pett Sep 8, 2025
422ec70
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 8, 2025
b0b524b
damn I broke the plugin tests
ym-pett Sep 8, 2025
0960d69
wip: fixed local mess in test_plugin
ym-pett Sep 8, 2025
3207de1
wip: fixed local mess in test_plugin
ym-pett Sep 8, 2025
d951220
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 8, 2025
ea28de2
Merge branch 'main' into create_fromnative_daft
ym-pett Sep 9, 2025
b563ace
added not_implemented functions to namespace
ym-pett Sep 9, 2025
9847793
removed comment
ym-pett Sep 9, 2025
1f38e31
changed naming for plugin entrypoins
ym-pett Sep 9, 2025
5dee0bb
added not_implemented functions to dictnamespace
ym-pett Sep 9, 2025
c508ab0
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 9, 2025
f2d6e57
Merge branch 'main' into create_fromnative_daft
ym-pett Sep 9, 2025
6c65fd4
fix(typing): Add `py.typed` marker
dangotbanned Sep 9, 2025
b26d7d4
fix: Fully qualify imports (from `tests`)
dangotbanned Sep 9, 2025
22362ad
added protocol & plugin detection function
ym-pett Sep 11, 2025
d22f046
changed plugin contract
ym-pett Sep 11, 2025
028e473
Merge branch 'main' into create_fromnative_daft
ym-pett Sep 11, 2025
87e67bf
Merge branch 'main' into create_fromnative_daft
ym-pett Sep 12, 2025
5c16c7f
added is_native back in, adapted plugin tests
ym-pett Sep 12, 2025
e00c7c6
Merge branch 'main' into create_fromnative_daft
ym-pett Sep 15, 2025
0c69762
wip: moving plugins
ym-pett Sep 15, 2025
f7765e8
wip: fixed imports in plugins utils
ym-pett Sep 15, 2025
e3e5ec7
refactored plugin-related utils into their own file
ym-pett Sep 15, 2025
0cd5f2f
refactor: Make `plugins` a module instead of a package
dangotbanned Sep 16, 2025
d511fc0
chore(typing): ignore `@cache` warning
dangotbanned Sep 16, 2025
6dd4d1c
refactor: Expose as `plugins.from_native`
dangotbanned Sep 16, 2025
8a96d84
fix(typing): Add the `Plugin` annotation I forgot
dangotbanned Sep 16, 2025
1944e47
docs(DRAFT): Start working on `plugins.from_native`
dangotbanned Sep 16, 2025
ce712e0
feat(typing): Add some slightly narrower typing
dangotbanned Sep 16, 2025
7bcec61
docs: Update `plugins.from_native`
dangotbanned Sep 16, 2025
9bbc0a6
Merge pull request #2 from narwhals-dev/plugin/dgb
ym-pett Sep 25, 2025
0f65066
Merge pull request #1 from ym-pett/tidy_plugin_utils
ym-pett Sep 25, 2025
8ad25ea
Merge branch 'main' into create_fromnative_daft
ym-pett Sep 25, 2025
21aab4a
Merge branch 'main' into create_fromnative_daft
ym-pett Sep 30, 2025
0138803
fix mess in pytest.yml
ym-pett Sep 30, 2025
fa09ba6
modified pytest.yml
ym-pett Sep 30, 2025
213cc31
Update pytest.yml
ym-pett Sep 30, 2025
da2d115
Update pytest.yml
ym-pett Sep 30, 2025
3a10b68
added _with_native to plugintest
ym-pett Sep 30, 2025
2d3f034
Merge branch 'main' into create_fromnative_daft
ym-pett Oct 8, 2025
547db41
Merge branch 'main' into create_fromnative_daft
ym-pett Oct 8, 2025
afd4d83
added is_native not implemented to plugin tests
ym-pett Oct 8, 2025
910a31e
defined is_native properly
ym-pett Oct 8, 2025
f227c13
using not_implemented and nocover to silence plugin test failures
ym-pett Oct 8, 2025
3ef415d
removed duplicate definition
ym-pett Oct 8, 2025
793a1cd
trying to silence colums coverage error
ym-pett Oct 8, 2025
c0d9d00
seeing if no cover works in this file
ym-pett Oct 8, 2025
725ce3c
implementing _with_version to stop test failing, cleaned up unnecessa…
ym-pett Oct 10, 2025
bd95e6d
Merge branch 'main' into create_fromnative_daft
ym-pett Oct 10, 2025
150dc52
Merge branch 'main' into create_fromnative_daft
ym-pett Oct 12, 2025
dc82860
Merge branch 'main' into create_fromnative_daft
ym-pett Oct 13, 2025
41a64c0
Merge branch 'main' into create_fromnative_daft
ym-pett Oct 13, 2025
242d8dd
removing skip for lower versions as it now runs for all
ym-pett Oct 13, 2025
62ea8c9
added pytest to 39 and windows
ym-pett Oct 13, 2025
29d7b8e
fixing TYP001 guard import
ym-pett Oct 14, 2025
1b877d4
Merge branch 'main' into create_fromnative_daft
ym-pett Oct 15, 2025
f7e5ae9
Merge branch 'main' into create_fromnative_daft
ym-pett Oct 16, 2025
ee37848
fixing import order
ym-pett Oct 16, 2025
28ca7fd
fixing typos
ym-pett Oct 16, 2025
19d08e1
Merge branch 'main' into create_fromnative_daft
ym-pett Oct 18, 2025
840c65f
Update docs/extending.md
ym-pett Oct 18, 2025
4550ed3
Update docs/extending.md
ym-pett Oct 18, 2025
79ce894
made suggested changes to docs
ym-pett Oct 18, 2025
a49e214
Merge branch 'main' into create_fromnative_daft
ym-pett Oct 20, 2025
b980628
Merge branch 'main' into create_fromnative_daft
ym-pett Oct 20, 2025
0cdb198
Merge branch 'main' into create_fromnative_daft
ym-pett Oct 21, 2025
4a3f43b
Merge branch 'main' into create_fromnative_daft
ym-pett Oct 24, 2025
3f06f3a
Merge branch 'main' into create_fromnative_daft
ym-pett Oct 27, 2025
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
3 changes: 3 additions & 0 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ jobs:
cache-suffix: pytest-full-coverage-${{ matrix.python-version }}
cache-dependency-glob: "pyproject.toml"
- name: install-reqs
run: uv pip install -e ".[dask, modin, ibis]" --group core-tests --group extra --system
- name: install-test-plugin
run: uv pip install -e tests/test_plugin --system
# TODO(FBruzzesi): Unpin duckdb version once ibis makes a new release
run: uv pip install -e ".[dask, modin, ibis]" --group core-tests --group extra "duckdb<1.4" --system
- name: show-deps
Expand Down
5 changes: 4 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,10 @@ repos:
rev: v6.0.0
hooks:
- id: name-tests-test
exclude: ^tests/utils\.py
exclude: |
(?x)
^(tests/utils\.py)
|^(tests/test_plugin/)
- id: no-commit-to-branch
- id: end-of-file-fixer
exclude: .svg$
1 change: 1 addition & 0 deletions narwhals/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
import re
import sys
from collections.abc import Collection, Container, Iterable, Iterator, Mapping, Sequence
from datetime import timezone
from enum import Enum, auto
Expand Down
110 changes: 110 additions & 0 deletions narwhals/plugins.py
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:
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

@ym-pett ym-pett Sep 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)
1 change: 1 addition & 0 deletions narwhals/stable/v2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@ def from_native( # noqa: D417
eager_only=eager_only,
series_only=series_only,
allow_series=allow_series,
eager_or_interchange_only=False,
version=Version.V2,
)

Expand Down
122 changes: 82 additions & 40 deletions narwhals/translate.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,22 @@
from functools import wraps
from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar, overload

from narwhals import plugins
from narwhals._constants import EPOCH, MS_PER_SECOND
from narwhals._namespace import (
is_native_arrow,
is_native_pandas_like,
is_native_polars,
is_native_spark_like,
)
from narwhals._utils import Implementation, Version, has_native_namespace
from narwhals._utils import (
Implementation,
Version,
has_native_namespace,
is_compliant_dataframe,
is_compliant_lazyframe,
is_compliant_series,
)
from narwhals.dependencies import (
get_dask_expr,
get_numpy,
Expand Down Expand Up @@ -314,23 +322,65 @@ def from_native( # noqa: D417
)


def _translate_if_compliant( # noqa: C901,PLR0911
compliant_object: Any,
*,
pass_through: bool = False,
eager_only: bool = False,
# Interchange-level was removed after v1
eager_or_interchange_only: bool,
series_only: bool,
allow_series: bool | None,
version: Version,
) -> Any:
if is_compliant_dataframe(compliant_object):
if series_only:
if not pass_through:
msg = "Cannot only use `series_only` with dataframe"
raise TypeError(msg)
return compliant_object
return version.dataframe(
compliant_object.__narwhals_dataframe__()._with_version(version), level="full"
)
if is_compliant_lazyframe(compliant_object):
if series_only:
if not pass_through:
msg = "Cannot only use `series_only` with lazyframe"
raise TypeError(msg)
return compliant_object
if eager_only or eager_or_interchange_only:
if not pass_through:
msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with lazyframe"
raise TypeError(msg)
return compliant_object
return version.lazyframe(
compliant_object.__narwhals_lazyframe__()._with_version(version), level="full"
)
if is_compliant_series(compliant_object):
if not allow_series:
if not pass_through:
msg = "Please set `allow_series=True` or `series_only=True`"
raise TypeError(msg)
return compliant_object
return version.series(
compliant_object.__narwhals_series__()._with_version(version), level="full"
)
# Object wasn't compliant, can't translate here.
return None


def _from_native_impl( # noqa: C901, PLR0911, PLR0912, PLR0915
native_object: Any,
*,
pass_through: bool = False,
eager_only: bool = False,
# Interchange-level was removed after v1
eager_or_interchange_only: bool = False,
series_only: bool = False,
allow_series: bool | None = None,
eager_or_interchange_only: bool,
series_only: bool,
allow_series: bool | None,
version: Version,
) -> Any:
from narwhals._interchange.dataframe import supports_dataframe_interchange
from narwhals._utils import (
is_compliant_dataframe,
is_compliant_lazyframe,
is_compliant_series,
)
from narwhals.dataframe import DataFrame, LazyFrame
from narwhals.series import Series

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

# Extensions
if is_compliant_dataframe(native_object):
if series_only:
if not pass_through:
msg = "Cannot only use `series_only` with dataframe"
raise TypeError(msg)
return native_object
return version.dataframe(
native_object.__narwhals_dataframe__()._with_version(version), level="full"
)
if is_compliant_lazyframe(native_object):
if series_only:
if not pass_through:
msg = "Cannot only use `series_only` with lazyframe"
raise TypeError(msg)
return native_object
if eager_only or eager_or_interchange_only:
if not pass_through:
msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with lazyframe"
raise TypeError(msg)
return native_object
return version.lazyframe(
native_object.__narwhals_lazyframe__()._with_version(version), level="full"
)
if is_compliant_series(native_object):
if not allow_series:
if not pass_through:
msg = "Please set `allow_series=True` or `series_only=True`"
raise TypeError(msg)
return native_object
return version.series(
native_object.__narwhals_series__()._with_version(version), level="full"
if (
translated := _translate_if_compliant(
native_object,
pass_through=pass_through,
eager_only=eager_only,
eager_or_interchange_only=eager_or_interchange_only,
series_only=series_only,
allow_series=allow_series,
version=version,
)
) is not None:
return translated

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

compliant_object = plugins.from_native(native_object, version)
if compliant_object is not None:
return _translate_if_compliant(
compliant_object,
pass_through=pass_through,
eager_only=eager_only,
eager_or_interchange_only=eager_or_interchange_only,
series_only=series_only,
allow_series=allow_series,
version=version,
)

if not pass_through:
msg = f"Expected pandas-like dataframe, Polars dataframe, or Polars lazyframe, got: {type(native_object)}"
raise TypeError(msg)
Expand Down
16 changes: 16 additions & 0 deletions tests/plugins_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from __future__ import annotations

import sys

import pytest

import narwhals as nw


@pytest.mark.skipif(sys.version_info < (3, 10), reason="3.10+ required for entrypoints")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this still true? I think the changes suggested by Dan should make it runnable on Python 3.9 too?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're right, taking out the skip.. line & tests still run fine

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"]
Empty file added tests/test_plugin/__init__.py
Empty file.
10 changes: 10 additions & 0 deletions tests/test_plugin/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "test_plugin"
version = "0.1.0"

[project.entry-points.'narwhals.plugins']
test-plugin = 'test_plugin'
23 changes: 23 additions & 0 deletions tests/test_plugin/test_plugin/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from typing_extensions import TypeIs

from narwhals.utils import Version
from tests.test_plugin.test_plugin.dataframe import DictFrame
from tests.test_plugin.test_plugin.namespace import DictNamespace


def __narwhals_namespace__(version: Version) -> DictNamespace: # noqa: N807
from tests.test_plugin.test_plugin.namespace import DictNamespace

return DictNamespace(version=version)


def is_native(native_object: object) -> TypeIs[DictFrame]:
return isinstance(native_object, dict)


NATIVE_PACKAGE = "builtins"
Loading
Loading