From cc139e7876c80ff87148a38847c4b9c8363e1fc5 Mon Sep 17 00:00:00 2001 From: FBruzzesi Date: Tue, 14 Jul 2026 14:33:04 +0200 Subject: [PATCH 1/3] feat(typing): introduce `PluginName` NewType so plugin backends can type-check --- src/narwhals/_typing.py | 21 ++++++++++++-- src/narwhals/_utils.py | 9 ++---- src/narwhals/dataframe.py | 10 +++---- src/narwhals/functions.py | 38 +++++++----------------- src/narwhals/series.py | 6 ++-- src/narwhals/stable/v1/__init__.py | 46 +++++++++++++++--------------- src/narwhals/stable/v2/__init__.py | 40 +++++++++++--------------- tests/plugins_test.py | 39 +++++++++++++++++++++++++ 8 files changed, 117 insertions(+), 92 deletions(-) diff --git a/src/narwhals/_typing.py b/src/narwhals/_typing.py index 8361d96303..fb485bfa9a 100644 --- a/src/narwhals/_typing.py +++ b/src/narwhals/_typing.py @@ -1,7 +1,7 @@ from __future__ import annotations from types import ModuleType -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Literal, NewType from narwhals._typing_compat import TypeVar from narwhals._utils import Implementation, _NoDefault @@ -151,8 +151,23 @@ └──────────────────┘ """ -IntoBackendAny: TypeAlias = IntoBackend[Backend] -IntoBackendEager: TypeAlias = IntoBackend[EagerAllowed] +PluginName = NewType("PluginName", str) +"""Name of a plugin backend's [entry point](https://packaging.python.org/en/latest/specifications/entry-points/). + +Plugin backends are discovered at runtime, so their names cannot join the +`Literal` unions that describe the built-in backends. `PluginName` is a +[`NewType`](https://typing.python.org/en/latest/spec/aliases.html#newtype): +type checkers treat it as a distinct subtype of `str`, meaning + +- an arbitrary `str` is still rejected where a `backend` is expected, and +- a value explicitly wrapped as `PluginName("...")` is accepted. + +The contract is that a wrapped string **must** name an installed plugin's +entry point in the `narwhals.plugins` group. +""" + +IntoBackendAny: TypeAlias = "IntoBackend[Backend] | PluginName" +IntoBackendEager: TypeAlias = "IntoBackend[EagerAllowed] | PluginName" IntoBackendLazy: TypeAlias = IntoBackend[LazyAllowed] NoDefault: TypeAlias = Literal[_NoDefault.no_default] diff --git a/src/narwhals/_utils.py b/src/narwhals/_utils.py index 4bb942f91a..d53d552978 100644 --- a/src/narwhals/_utils.py +++ b/src/narwhals/_utils.py @@ -98,8 +98,7 @@ ) from narwhals._translate import ArrowStreamExportable, IntoArrowTable, ToNarwhalsT_co from narwhals._typing import ( - Backend, - IntoBackend, + IntoBackendAny, _ArrowImpl, _CuDFImpl, _DaskImpl, @@ -140,8 +139,6 @@ _SliceNone, ) - UnknownBackendName: TypeAlias = str - FrameOrSeriesT = TypeVar( "FrameOrSeriesT", bound=LazyFrame[Any] | DataFrame[Any] | Series[Any] ) @@ -394,9 +391,7 @@ def from_string(cls: type[Self], backend_name: str) -> Implementation: return Implementation.UNKNOWN @classmethod - def from_backend( - cls: type[Self], backend: IntoBackend[Backend] | UnknownBackendName - ) -> Implementation: + def from_backend(cls: type[Self], backend: IntoBackendAny) -> Implementation: """Instantiate from native namespace module, string, or Implementation. Arguments: diff --git a/src/narwhals/dataframe.py b/src/narwhals/dataframe.py index 3c7fb9ea15..20c3cda90f 100644 --- a/src/narwhals/dataframe.py +++ b/src/narwhals/dataframe.py @@ -72,7 +72,7 @@ from narwhals._compliant.typing import CompliantExprAny from narwhals._expression_parsing import ExprMetadata from narwhals._translate import IntoArrowTable - from narwhals._typing import EagerAllowed, IntoBackend, LazyAllowed, Polars + from narwhals._typing import IntoBackend, IntoBackendEager, LazyAllowed, Polars from narwhals.group_by import GroupBy, LazyGroupBy from narwhals.typing import ( AsofJoinStrategy, @@ -502,7 +502,7 @@ def __init__(self, df: Any, *, level: Literal["full", "lazy", "interchange"]) -> @classmethod def from_arrow( - cls, native_frame: IntoArrowTable, *, backend: IntoBackend[EagerAllowed] + cls, native_frame: IntoArrowTable, *, backend: IntoBackendEager ) -> DataFrame[Any]: """Construct a DataFrame from an object which supports the PyCapsule Interface. @@ -559,7 +559,7 @@ def from_dict( data: Mapping[str, Any], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackend[EagerAllowed] | None = None, + backend: IntoBackendEager | None = None, ) -> DataFrame[Any]: """Instantiate DataFrame from dictionary. @@ -622,7 +622,7 @@ def from_dicts( data: Sequence[Mapping[str, Any]], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackend[EagerAllowed], + backend: IntoBackendEager, ) -> DataFrame[Any]: """Instantiate DataFrame from a sequence of dictionaries representing rows. @@ -696,7 +696,7 @@ def from_numpy( data: _2DArray, schema: IntoSchema | Sequence[str] | None = None, *, - backend: IntoBackend[EagerAllowed], + backend: IntoBackendEager, ) -> DataFrame[Any]: """Construct a DataFrame from a NumPy ndarray. diff --git a/src/narwhals/functions.py b/src/narwhals/functions.py index c528ebf60c..c05233f50a 100644 --- a/src/narwhals/functions.py +++ b/src/narwhals/functions.py @@ -43,7 +43,7 @@ from narwhals._native import NativeDataFrame, NativeLazyFrame, NativeSeries from narwhals._translate import IntoArrowTable - from narwhals._typing import Backend, EagerAllowed, IntoBackend + from narwhals._typing import IntoBackendAny, IntoBackendEager from narwhals.dataframe import DataFrame, LazyFrame from narwhals.series import Series from narwhals.typing import ( @@ -165,11 +165,7 @@ def concat(items: Iterable[FrameT], *, how: ConcatMethod = "vertical") -> FrameT def new_series( - name: str, - values: Any, - dtype: IntoDType | None = None, - *, - backend: IntoBackend[EagerAllowed], + name: str, values: Any, dtype: IntoDType | None = None, *, backend: IntoBackendEager ) -> Series[Any]: """Instantiate Narwhals Series from iterable (e.g. list or array). @@ -207,11 +203,7 @@ def new_series( def _new_series_impl( - name: str, - values: Any, - dtype: IntoDType | None = None, - *, - backend: IntoBackend[EagerAllowed], + name: str, values: Any, dtype: IntoDType | None = None, *, backend: IntoBackendEager ) -> Series[Any]: implementation = Implementation.from_backend(backend) if is_eager_allowed(implementation): @@ -241,7 +233,7 @@ def from_dict( data: Mapping[str, Any], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackend[EagerAllowed] | None = None, + backend: IntoBackendEager | None = None, native_namespace: ModuleType | None = None, # noqa: ARG001 ) -> DataFrame[Any]: """Instantiate DataFrame from dictionary. @@ -332,7 +324,7 @@ def from_dicts( data: Sequence[Mapping[str, Any]], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackend[EagerAllowed], + backend: IntoBackendEager, ) -> DataFrame[Any]: """Instantiate DataFrame from a sequence of dictionaries representing rows. @@ -393,7 +385,7 @@ def from_numpy( data: _2DArray, schema: IntoSchema | Sequence[str] | None = None, *, - backend: IntoBackend[EagerAllowed], + backend: IntoBackendEager, ) -> DataFrame[Any]: """Construct a DataFrame from a NumPy ndarray. @@ -482,7 +474,7 @@ def _is_into_schema(obj: Any) -> TypeIs[_IntoSchema]: def from_arrow( - native_frame: IntoArrowTable, *, backend: IntoBackend[EagerAllowed] + native_frame: IntoArrowTable, *, backend: IntoBackendEager ) -> DataFrame[Any]: # pragma: no cover """Construct a DataFrame from an object which supports the PyCapsule Interface. @@ -650,11 +642,7 @@ def _validate_separator_pyarrow(separator: str, **kwargs: Any) -> Any: def read_csv( - source: FileSource, - *, - backend: IntoBackend[EagerAllowed], - separator: str = ",", - **kwargs: Any, + source: FileSource, *, backend: IntoBackendEager, separator: str = ",", **kwargs: Any ) -> DataFrame[Any]: """Read a CSV file into a DataFrame. @@ -725,11 +713,7 @@ def read_csv( def scan_csv( - source: FileSource, - *, - backend: IntoBackend[Backend], - separator: str = ",", - **kwargs: Any, + source: FileSource, *, backend: IntoBackendAny, separator: str = ",", **kwargs: Any ) -> LazyFrame[Any]: """Lazily read from a CSV file. @@ -813,7 +797,7 @@ def scan_csv( def read_parquet( - source: FileSource, *, backend: IntoBackend[EagerAllowed], **kwargs: Any + source: FileSource, *, backend: IntoBackendEager, **kwargs: Any ) -> DataFrame[Any]: """Read into a DataFrame from a parquet file. @@ -886,7 +870,7 @@ def read_parquet( def scan_parquet( - source: FileSource, *, backend: IntoBackend[Backend], **kwargs: Any + source: FileSource, *, backend: IntoBackendAny, **kwargs: Any ) -> LazyFrame[Any]: """Lazily read from a parquet file. diff --git a/src/narwhals/series.py b/src/narwhals/series.py index c6f9d9ba5b..220328d16e 100644 --- a/src/narwhals/series.py +++ b/src/narwhals/series.py @@ -45,7 +45,7 @@ from typing_extensions import Self from narwhals._compliant import CompliantSeries - from narwhals._typing import EagerAllowed, IntoBackend, NoDefault + from narwhals._typing import IntoBackendEager, NoDefault from narwhals.dataframe import DataFrame, MultiIndexSelector from narwhals.dtypes import DType from narwhals.typing import ( @@ -121,7 +121,7 @@ def from_numpy( values: _1DArray, dtype: IntoDType | None = None, *, - backend: IntoBackend[EagerAllowed], + backend: IntoBackendEager, ) -> Series[Any]: """Construct a Series from a NumPy ndarray. @@ -186,7 +186,7 @@ def from_iterable( values: Iterable[Any], dtype: IntoDType | None = None, *, - backend: IntoBackend[EagerAllowed], + backend: IntoBackendEager, ) -> Series[Any]: """Construct a Series from an iterable. diff --git a/src/narwhals/stable/v1/__init__.py b/src/narwhals/stable/v1/__init__.py index a730b927c7..675ceae8da 100644 --- a/src/narwhals/stable/v1/__init__.py +++ b/src/narwhals/stable/v1/__init__.py @@ -98,9 +98,9 @@ ) from narwhals._typing import ( Arrow, - Backend, - EagerAllowed, IntoBackend, + IntoBackendAny, + IntoBackendEager, LazyAllowed, Pandas, Polars, @@ -138,7 +138,7 @@ def __init__(self, df: Any, *, level: Literal["full", "lazy", "interchange"]) -> @classmethod def from_arrow( - cls, native_frame: IntoArrowTable, *, backend: IntoBackend[EagerAllowed] + cls, native_frame: IntoArrowTable, *, backend: IntoBackendEager ) -> DataFrame[Any]: result = super().from_arrow(native_frame, backend=backend) return cast("DataFrame[Any]", result) @@ -149,7 +149,7 @@ def from_dict( data: Mapping[str, Any], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackend[EagerAllowed] | None = None, + backend: IntoBackendEager | None = None, ) -> DataFrame[Any]: result = super().from_dict(data, schema, backend=backend) return cast("DataFrame[Any]", result) @@ -160,7 +160,7 @@ def from_dicts( data: Sequence[Any], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackend[EagerAllowed], + backend: IntoBackendEager, ) -> DataFrame[Any]: result = super().from_dicts(data, schema, backend=backend) return cast("DataFrame[Any]", result) @@ -171,7 +171,7 @@ def from_numpy( data: _2DArray, schema: IntoSchema | Sequence[str] | None = None, *, - backend: IntoBackend[EagerAllowed], + backend: IntoBackendEager, ) -> DataFrame[Any]: result = super().from_numpy(data, schema, backend=backend) return cast("DataFrame[Any]", result) @@ -329,7 +329,7 @@ def from_numpy( values: _1DArray, dtype: IntoDType | None = None, *, - backend: IntoBackend[EagerAllowed], + backend: IntoBackendEager, ) -> Series[Any]: result = super().from_numpy(name, values, dtype, backend=backend) return cast("Series[Any]", result) @@ -341,7 +341,7 @@ def from_iterable( values: Iterable[Any], dtype: IntoDType | None = None, *, - backend: IntoBackend[EagerAllowed], + backend: IntoBackendEager, ) -> Series[Any]: result = super().from_iterable(name, values, dtype, backend=backend) return cast("Series[Any]", result) @@ -950,7 +950,7 @@ def new_series( values: Any, dtype: IntoDType | None = None, *, - backend: IntoBackend[EagerAllowed] | None = None, + backend: IntoBackendEager | None = None, native_namespace: ModuleType | None = None, # noqa: ARG001 ) -> Series[Any]: """Instantiate Narwhals Series from iterable (e.g. list or array). @@ -959,7 +959,7 @@ def new_series( an is the same as `backend` but only accepts module types - for new code, we recommend using `backend`, as that's available beyond just `narwhals.stable.v1`. """ - backend = cast("IntoBackend[EagerAllowed]", backend) + backend = cast("IntoBackendEager", backend) return _stableify(_new_series_impl(name, values, dtype, backend=backend)) @@ -967,7 +967,7 @@ def new_series( def from_arrow( native_frame: IntoArrowTable, *, - backend: IntoBackend[EagerAllowed] | None = None, + backend: IntoBackendEager | None = None, native_namespace: ModuleType | None = None, # noqa: ARG001 ) -> DataFrame[Any]: """Construct a DataFrame from an object which supports the PyCapsule Interface. @@ -976,7 +976,7 @@ def from_arrow( an is the same as `backend` but only accepts module types - for new code, we recommend using `backend`, as that's available beyond just `narwhals.stable.v1`. """ - backend = cast("IntoBackend[EagerAllowed]", backend) + backend = cast("IntoBackendEager", backend) return _stableify(nw_f.from_arrow(native_frame, backend=backend)) @@ -985,7 +985,7 @@ def from_dict( data: Mapping[str, Any], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackend[EagerAllowed] | None = None, + backend: IntoBackendEager | None = None, native_namespace: ModuleType | None = None, # noqa: ARG001 ) -> DataFrame[Any]: """Instantiate DataFrame from dictionary. @@ -1005,7 +1005,7 @@ def from_numpy( data: _2DArray, schema: IntoSchema | Sequence[str] | None = None, *, - backend: IntoBackend[EagerAllowed] | None = None, + backend: IntoBackendEager | None = None, native_namespace: ModuleType | None = None, # noqa: ARG001 ) -> DataFrame[Any]: """Construct a DataFrame from a NumPy ndarray. @@ -1014,7 +1014,7 @@ def from_numpy( an is the same as `backend` but only accepts module types - for new code, we recommend using `backend`, as that's available beyond just `narwhals.stable.v1`. """ - backend = cast("IntoBackend[EagerAllowed]", backend) + backend = cast("IntoBackendEager", backend) return _stableify(nw_f.from_numpy(data, schema, backend=backend)) @@ -1022,7 +1022,7 @@ def from_numpy( def read_csv( source: FileSource, *, - backend: IntoBackend[EagerAllowed] | None = None, + backend: IntoBackendEager | None = None, native_namespace: ModuleType | None = None, # noqa: ARG001 **kwargs: Any, ) -> DataFrame[Any]: @@ -1032,7 +1032,7 @@ def read_csv( an is the same as `backend` but only accepts module types - for new code, we recommend using `backend`, as that's available beyond just `narwhals.stable.v1`. """ - backend = cast("IntoBackend[EagerAllowed]", backend) + backend = cast("IntoBackendEager", backend) return _stableify(nw_f.read_csv(source, backend=backend, **kwargs)) @@ -1040,7 +1040,7 @@ def read_csv( def scan_csv( source: FileSource, *, - backend: IntoBackend[Backend] | None = None, + backend: IntoBackendAny | None = None, native_namespace: ModuleType | None = None, # noqa: ARG001 **kwargs: Any, ) -> LazyFrame[Any]: @@ -1050,7 +1050,7 @@ def scan_csv( an is the same as `backend` but only accepts module types - for new code, we recommend using `backend`, as that's available beyond just `narwhals.stable.v1`. """ - backend = cast("IntoBackend[Backend]", backend) + backend = cast("IntoBackendAny", backend) return _stableify(nw_f.scan_csv(source, backend=backend, **kwargs)) @@ -1058,7 +1058,7 @@ def scan_csv( def read_parquet( source: FileSource, *, - backend: IntoBackend[EagerAllowed] | None = None, + backend: IntoBackendEager | None = None, native_namespace: ModuleType | None = None, # noqa: ARG001 **kwargs: Any, ) -> DataFrame[Any]: @@ -1068,7 +1068,7 @@ def read_parquet( an is the same as `backend` but only accepts module types - for new code, we recommend using `backend`, as that's available beyond just `narwhals.stable.v1`. """ - backend = cast("IntoBackend[EagerAllowed]", backend) + backend = cast("IntoBackendEager", backend) return _stableify(nw_f.read_parquet(source, backend=backend, **kwargs)) @@ -1076,7 +1076,7 @@ def read_parquet( def scan_parquet( source: FileSource, *, - backend: IntoBackend[Backend] | None = None, + backend: IntoBackendAny | None = None, native_namespace: ModuleType | None = None, # noqa: ARG001 **kwargs: Any, ) -> LazyFrame[Any]: @@ -1086,7 +1086,7 @@ def scan_parquet( an is the same as `backend` but only accepts module types - for new code, we recommend using `backend`, as that's available beyond just `narwhals.stable.v1`. """ - backend = cast("IntoBackend[Backend]", backend) + backend = cast("IntoBackendAny", backend) return _stableify(nw_f.scan_parquet(source, backend=backend, **kwargs)) diff --git a/src/narwhals/stable/v2/__init__.py b/src/narwhals/stable/v2/__init__.py index f45c1e2a1b..b59290dcf2 100644 --- a/src/narwhals/stable/v2/__init__.py +++ b/src/narwhals/stable/v2/__init__.py @@ -87,9 +87,9 @@ ) from narwhals._typing import ( Arrow, - Backend, - EagerAllowed, IntoBackend, + IntoBackendAny, + IntoBackendEager, LazyAllowed, Pandas, Polars, @@ -125,7 +125,7 @@ def __init__(self, df: Any, *, level: Literal["full", "lazy", "interchange"]) -> @classmethod def from_arrow( - cls, native_frame: IntoArrowTable, *, backend: IntoBackend[EagerAllowed] + cls, native_frame: IntoArrowTable, *, backend: IntoBackendEager ) -> DataFrame[Any]: result = super().from_arrow(native_frame, backend=backend) return cast("DataFrame[Any]", result) @@ -136,7 +136,7 @@ def from_dict( data: Mapping[str, Any], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackend[EagerAllowed] | None = None, + backend: IntoBackendEager | None = None, ) -> DataFrame[Any]: result = super().from_dict(data, schema, backend=backend) return cast("DataFrame[Any]", result) @@ -147,7 +147,7 @@ def from_dicts( data: Sequence[Mapping[str, Any]], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackend[EagerAllowed], + backend: IntoBackendEager, ) -> DataFrame[Any]: result = super().from_dicts(data, schema, backend=backend) return cast("DataFrame[Any]", result) @@ -158,7 +158,7 @@ def from_numpy( data: _2DArray, schema: IntoSchema | Sequence[str] | None = None, *, - backend: IntoBackend[EagerAllowed], + backend: IntoBackendEager, ) -> DataFrame[Any]: result = super().from_numpy(data, schema, backend=backend) return cast("DataFrame[Any]", result) @@ -282,7 +282,7 @@ def from_numpy( values: _1DArray, dtype: IntoDType | None = None, *, - backend: IntoBackend[EagerAllowed], + backend: IntoBackendEager, ) -> Series[Any]: result = super().from_numpy(name, values, dtype, backend=backend) return cast("Series[Any]", result) @@ -294,7 +294,7 @@ def from_iterable( values: Iterable[Any], dtype: IntoDType | None = None, *, - backend: IntoBackend[EagerAllowed], + backend: IntoBackendEager, ) -> Series[Any]: result = super().from_iterable(name, values, dtype, backend=backend) return cast("Series[Any]", result) @@ -923,11 +923,7 @@ def when(*predicates: IntoExpr | Iterable[IntoExpr]) -> When: def new_series( - name: str, - values: Any, - dtype: IntoDType | None = None, - *, - backend: IntoBackend[EagerAllowed], + name: str, values: Any, dtype: IntoDType | None = None, *, backend: IntoBackendEager ) -> Series[Any]: """Instantiate Narwhals Series from iterable (e.g. list or array). @@ -949,7 +945,7 @@ def new_series( def from_arrow( - native_frame: IntoArrowTable, *, backend: IntoBackend[EagerAllowed] + native_frame: IntoArrowTable, *, backend: IntoBackendEager ) -> DataFrame[Any]: """Construct a DataFrame from an object which supports the PyCapsule Interface. @@ -971,7 +967,7 @@ def from_dict( data: Mapping[str, Any], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackend[EagerAllowed] | None = None, + backend: IntoBackendEager | None = None, ) -> DataFrame[Any]: """Instantiate DataFrame from dictionary. @@ -1009,7 +1005,7 @@ def from_numpy( data: _2DArray, schema: IntoSchema | Sequence[str] | None = None, *, - backend: IntoBackend[EagerAllowed], + backend: IntoBackendEager, ) -> DataFrame[Any]: """Construct a DataFrame from a NumPy ndarray. @@ -1036,11 +1032,7 @@ def from_numpy( def read_csv( - source: str, - *, - backend: IntoBackend[EagerAllowed], - separator: str = ",", - **kwargs: Any, + source: str, *, backend: IntoBackendEager, separator: str = ",", **kwargs: Any ) -> DataFrame[Any]: """Read a CSV file into a DataFrame. @@ -1064,7 +1056,7 @@ def read_csv( def scan_csv( - source: str, *, backend: IntoBackend[Backend], separator: str = ",", **kwargs: Any + source: str, *, backend: IntoBackendAny, separator: str = ",", **kwargs: Any ) -> LazyFrame[Any]: """Lazily read from a CSV file. @@ -1091,7 +1083,7 @@ def scan_csv( def read_parquet( - source: str, *, backend: IntoBackend[EagerAllowed], **kwargs: Any + source: str, *, backend: IntoBackendEager, **kwargs: Any ) -> DataFrame[Any]: """Read into a DataFrame from a parquet file. @@ -1112,7 +1104,7 @@ def read_parquet( def scan_parquet( - source: str, *, backend: IntoBackend[Backend], **kwargs: Any + source: str, *, backend: IntoBackendAny, **kwargs: Any ) -> LazyFrame[Any]: """Lazily read from a parquet file. diff --git a/tests/plugins_test.py b/tests/plugins_test.py index 6de21e53f1..101bfb3f40 100644 --- a/tests/plugins_test.py +++ b/tests/plugins_test.py @@ -8,10 +8,12 @@ import narwhals.stable.v1.dependencies as nw_v1_dependencies import narwhals.stable.v2.dependencies as nw_v2_dependencies from narwhals import dependencies as nw_dependencies +from narwhals._typing import PluginName if TYPE_CHECKING: from typing_extensions import Self + from narwhals._typing import IntoBackendEager from narwhals.plugins import Plugin from narwhals.utils import Version @@ -132,3 +134,40 @@ def test_typing() -> None: import test_plugin _plugin: Plugin = test_plugin + + +def test_plugin_name_runtime() -> None: + # `PluginName` is a `NewType`: identity at runtime, nominal for type checkers. + name = PluginName("some-plugin") + assert name == "some-plugin" + assert nw.Implementation.from_backend(name) is nw.Implementation.UNKNOWN + + +if TYPE_CHECKING: + # Static-only regression guards for `PluginName` + def typing_backend_plugin_name( + plugin_name: PluginName, + dynamic_string: str, + eager_or_plugin: IntoBackendEager, + df: nw.DataFrame[Any], + ) -> None: + data = {"a": [1, 2]} + + # Accepted: an explicitly wrapped plugin name, everything `IntoBackendEager` covers. + nw.from_dict(data, backend=plugin_name) + nw.from_dict(data, backend=eager_or_plugin) + nw.new_series("a", [1, 2], backend=plugin_name) + nw.scan_csv("file.csv", backend=plugin_name) + nw.DataFrame.from_dict(data, backend=plugin_name) + nw.Implementation.from_backend(plugin_name) + + # Rejected: opaque strings do not satisfy `PluginName`. + nw.from_dict(data, backend=dynamic_string) # type: ignore[arg-type] + nw.new_series("a", [1, 2], backend=dynamic_string) # type: ignore[arg-type] + nw.Implementation.from_backend(dynamic_string) # type: ignore[arg-type] + + # Rejected: lazy-only literals on eager constructors (no regression). + nw.from_dict(data, backend="duckdb") # type: ignore[arg-type] + + # Rejected: `.lazy` does not dispatch to plugins (yet). + df.lazy(plugin_name) # type: ignore[arg-type] From d14221188935db8afa071ead7783a0fb1b79dd6f Mon Sep 17 00:00:00 2001 From: FBruzzesi Date: Wed, 15 Jul 2026 15:55:36 +0200 Subject: [PATCH 2/3] keeps using generic --- src/narwhals/_typing.py | 39 ++++++++++++----------- src/narwhals/_utils.py | 8 +++-- src/narwhals/dataframe.py | 19 +++++++++--- src/narwhals/functions.py | 38 ++++++++++++++++------- src/narwhals/series.py | 6 ++-- src/narwhals/stable/v1/__init__.py | 50 ++++++++++++++++-------------- src/narwhals/stable/v2/__init__.py | 48 ++++++++++++++++++---------- tests/plugins_test.py | 6 ++-- 8 files changed, 133 insertions(+), 81 deletions(-) diff --git a/src/narwhals/_typing.py b/src/narwhals/_typing.py index fb485bfa9a..92bf51751a 100644 --- a/src/narwhals/_typing.py +++ b/src/narwhals/_typing.py @@ -91,7 +91,25 @@ - An Implementation, such as: `Implementation.DASK`, `Implementation.PYSPARK`, ... """ -BackendT = TypeVar("BackendT", bound=Backend) +PluginName = NewType("PluginName", str) +"""Name of a plugin backend's [entry point](https://packaging.python.org/en/latest/specifications/entry-points/). + +Plugin backends are discovered at runtime, so their names cannot join the +`Literal` unions that describe the built-in backends. `PluginName` is a +[`NewType`](https://typing.python.org/en/latest/spec/aliases.html#newtype): +type checkers treat it as a distinct subtype of `str`, meaning + +- an arbitrary `str` is still rejected where a `backend` is expected, and +- a value explicitly wrapped as `PluginName("...")` is accepted. + +The contract is that a wrapped string **must** name an installed plugin's +entry point in the `narwhals.plugins` group. + +Signatures that dispatch to plugins include it in the `IntoBackend` +parameter, e.g. `IntoBackend[EagerAllowed | PluginName]`. +""" + +BackendT = TypeVar("BackendT", bound=Backend | PluginName) IntoBackend: TypeAlias = BackendT | ModuleType """Anything that can be converted into a [`narwhals.Implementation`][]. @@ -151,23 +169,8 @@ └──────────────────┘ """ -PluginName = NewType("PluginName", str) -"""Name of a plugin backend's [entry point](https://packaging.python.org/en/latest/specifications/entry-points/). - -Plugin backends are discovered at runtime, so their names cannot join the -`Literal` unions that describe the built-in backends. `PluginName` is a -[`NewType`](https://typing.python.org/en/latest/spec/aliases.html#newtype): -type checkers treat it as a distinct subtype of `str`, meaning - -- an arbitrary `str` is still rejected where a `backend` is expected, and -- a value explicitly wrapped as `PluginName("...")` is accepted. - -The contract is that a wrapped string **must** name an installed plugin's -entry point in the `narwhals.plugins` group. -""" - -IntoBackendAny: TypeAlias = "IntoBackend[Backend] | PluginName" -IntoBackendEager: TypeAlias = "IntoBackend[EagerAllowed] | PluginName" +IntoBackendAny: TypeAlias = IntoBackend[Backend] +IntoBackendEager: TypeAlias = IntoBackend[EagerAllowed] IntoBackendLazy: TypeAlias = IntoBackend[LazyAllowed] NoDefault: TypeAlias = Literal[_NoDefault.no_default] diff --git a/src/narwhals/_utils.py b/src/narwhals/_utils.py index d53d552978..d625613f66 100644 --- a/src/narwhals/_utils.py +++ b/src/narwhals/_utils.py @@ -98,7 +98,9 @@ ) from narwhals._translate import ArrowStreamExportable, IntoArrowTable, ToNarwhalsT_co from narwhals._typing import ( - IntoBackendAny, + Backend, + IntoBackend, + PluginName, _ArrowImpl, _CuDFImpl, _DaskImpl, @@ -391,7 +393,9 @@ def from_string(cls: type[Self], backend_name: str) -> Implementation: return Implementation.UNKNOWN @classmethod - def from_backend(cls: type[Self], backend: IntoBackendAny) -> Implementation: + def from_backend( + cls: type[Self], backend: IntoBackend[Backend | PluginName] + ) -> Implementation: """Instantiate from native namespace module, string, or Implementation. Arguments: diff --git a/src/narwhals/dataframe.py b/src/narwhals/dataframe.py index 20c3cda90f..46fdad0050 100644 --- a/src/narwhals/dataframe.py +++ b/src/narwhals/dataframe.py @@ -72,7 +72,13 @@ from narwhals._compliant.typing import CompliantExprAny from narwhals._expression_parsing import ExprMetadata from narwhals._translate import IntoArrowTable - from narwhals._typing import IntoBackend, IntoBackendEager, LazyAllowed, Polars + from narwhals._typing import ( + EagerAllowed, + IntoBackend, + LazyAllowed, + PluginName, + Polars, + ) from narwhals.group_by import GroupBy, LazyGroupBy from narwhals.typing import ( AsofJoinStrategy, @@ -502,7 +508,10 @@ def __init__(self, df: Any, *, level: Literal["full", "lazy", "interchange"]) -> @classmethod def from_arrow( - cls, native_frame: IntoArrowTable, *, backend: IntoBackendEager + cls, + native_frame: IntoArrowTable, + *, + backend: IntoBackend[EagerAllowed | PluginName], ) -> DataFrame[Any]: """Construct a DataFrame from an object which supports the PyCapsule Interface. @@ -559,7 +568,7 @@ def from_dict( data: Mapping[str, Any], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackendEager | None = None, + backend: IntoBackend[EagerAllowed | PluginName] | None = None, ) -> DataFrame[Any]: """Instantiate DataFrame from dictionary. @@ -622,7 +631,7 @@ def from_dicts( data: Sequence[Mapping[str, Any]], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackendEager, + backend: IntoBackend[EagerAllowed | PluginName], ) -> DataFrame[Any]: """Instantiate DataFrame from a sequence of dictionaries representing rows. @@ -696,7 +705,7 @@ def from_numpy( data: _2DArray, schema: IntoSchema | Sequence[str] | None = None, *, - backend: IntoBackendEager, + backend: IntoBackend[EagerAllowed | PluginName], ) -> DataFrame[Any]: """Construct a DataFrame from a NumPy ndarray. diff --git a/src/narwhals/functions.py b/src/narwhals/functions.py index c05233f50a..a576c6b33d 100644 --- a/src/narwhals/functions.py +++ b/src/narwhals/functions.py @@ -43,7 +43,7 @@ from narwhals._native import NativeDataFrame, NativeLazyFrame, NativeSeries from narwhals._translate import IntoArrowTable - from narwhals._typing import IntoBackendAny, IntoBackendEager + from narwhals._typing import Backend, EagerAllowed, IntoBackend, PluginName from narwhals.dataframe import DataFrame, LazyFrame from narwhals.series import Series from narwhals.typing import ( @@ -165,7 +165,11 @@ def concat(items: Iterable[FrameT], *, how: ConcatMethod = "vertical") -> FrameT def new_series( - name: str, values: Any, dtype: IntoDType | None = None, *, backend: IntoBackendEager + name: str, + values: Any, + dtype: IntoDType | None = None, + *, + backend: IntoBackend[EagerAllowed | PluginName], ) -> Series[Any]: """Instantiate Narwhals Series from iterable (e.g. list or array). @@ -203,7 +207,11 @@ def new_series( def _new_series_impl( - name: str, values: Any, dtype: IntoDType | None = None, *, backend: IntoBackendEager + name: str, + values: Any, + dtype: IntoDType | None = None, + *, + backend: IntoBackend[EagerAllowed | PluginName], ) -> Series[Any]: implementation = Implementation.from_backend(backend) if is_eager_allowed(implementation): @@ -233,7 +241,7 @@ def from_dict( data: Mapping[str, Any], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackendEager | None = None, + backend: IntoBackend[EagerAllowed | PluginName] | None = None, native_namespace: ModuleType | None = None, # noqa: ARG001 ) -> DataFrame[Any]: """Instantiate DataFrame from dictionary. @@ -324,7 +332,7 @@ def from_dicts( data: Sequence[Mapping[str, Any]], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackendEager, + backend: IntoBackend[EagerAllowed | PluginName], ) -> DataFrame[Any]: """Instantiate DataFrame from a sequence of dictionaries representing rows. @@ -385,7 +393,7 @@ def from_numpy( data: _2DArray, schema: IntoSchema | Sequence[str] | None = None, *, - backend: IntoBackendEager, + backend: IntoBackend[EagerAllowed | PluginName], ) -> DataFrame[Any]: """Construct a DataFrame from a NumPy ndarray. @@ -474,7 +482,7 @@ def _is_into_schema(obj: Any) -> TypeIs[_IntoSchema]: def from_arrow( - native_frame: IntoArrowTable, *, backend: IntoBackendEager + native_frame: IntoArrowTable, *, backend: IntoBackend[EagerAllowed | PluginName] ) -> DataFrame[Any]: # pragma: no cover """Construct a DataFrame from an object which supports the PyCapsule Interface. @@ -642,7 +650,11 @@ def _validate_separator_pyarrow(separator: str, **kwargs: Any) -> Any: def read_csv( - source: FileSource, *, backend: IntoBackendEager, separator: str = ",", **kwargs: Any + source: FileSource, + *, + backend: IntoBackend[EagerAllowed | PluginName], + separator: str = ",", + **kwargs: Any, ) -> DataFrame[Any]: """Read a CSV file into a DataFrame. @@ -713,7 +725,11 @@ def read_csv( def scan_csv( - source: FileSource, *, backend: IntoBackendAny, separator: str = ",", **kwargs: Any + source: FileSource, + *, + backend: IntoBackend[Backend | PluginName], + separator: str = ",", + **kwargs: Any, ) -> LazyFrame[Any]: """Lazily read from a CSV file. @@ -797,7 +813,7 @@ def scan_csv( def read_parquet( - source: FileSource, *, backend: IntoBackendEager, **kwargs: Any + source: FileSource, *, backend: IntoBackend[EagerAllowed | PluginName], **kwargs: Any ) -> DataFrame[Any]: """Read into a DataFrame from a parquet file. @@ -870,7 +886,7 @@ def read_parquet( def scan_parquet( - source: FileSource, *, backend: IntoBackendAny, **kwargs: Any + source: FileSource, *, backend: IntoBackend[Backend | PluginName], **kwargs: Any ) -> LazyFrame[Any]: """Lazily read from a parquet file. diff --git a/src/narwhals/series.py b/src/narwhals/series.py index 220328d16e..d0688cba41 100644 --- a/src/narwhals/series.py +++ b/src/narwhals/series.py @@ -45,7 +45,7 @@ from typing_extensions import Self from narwhals._compliant import CompliantSeries - from narwhals._typing import IntoBackendEager, NoDefault + from narwhals._typing import EagerAllowed, IntoBackend, NoDefault, PluginName from narwhals.dataframe import DataFrame, MultiIndexSelector from narwhals.dtypes import DType from narwhals.typing import ( @@ -121,7 +121,7 @@ def from_numpy( values: _1DArray, dtype: IntoDType | None = None, *, - backend: IntoBackendEager, + backend: IntoBackend[EagerAllowed | PluginName], ) -> Series[Any]: """Construct a Series from a NumPy ndarray. @@ -186,7 +186,7 @@ def from_iterable( values: Iterable[Any], dtype: IntoDType | None = None, *, - backend: IntoBackendEager, + backend: IntoBackend[EagerAllowed | PluginName], ) -> Series[Any]: """Construct a Series from an iterable. diff --git a/src/narwhals/stable/v1/__init__.py b/src/narwhals/stable/v1/__init__.py index 675ceae8da..6b89027a1f 100644 --- a/src/narwhals/stable/v1/__init__.py +++ b/src/narwhals/stable/v1/__init__.py @@ -98,11 +98,12 @@ ) from narwhals._typing import ( Arrow, + Backend, + EagerAllowed, IntoBackend, - IntoBackendAny, - IntoBackendEager, LazyAllowed, Pandas, + PluginName, Polars, ) from narwhals.dataframe import MultiColSelector, MultiIndexSelector @@ -138,7 +139,10 @@ def __init__(self, df: Any, *, level: Literal["full", "lazy", "interchange"]) -> @classmethod def from_arrow( - cls, native_frame: IntoArrowTable, *, backend: IntoBackendEager + cls, + native_frame: IntoArrowTable, + *, + backend: IntoBackend[EagerAllowed | PluginName], ) -> DataFrame[Any]: result = super().from_arrow(native_frame, backend=backend) return cast("DataFrame[Any]", result) @@ -149,7 +153,7 @@ def from_dict( data: Mapping[str, Any], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackendEager | None = None, + backend: IntoBackend[EagerAllowed | PluginName] | None = None, ) -> DataFrame[Any]: result = super().from_dict(data, schema, backend=backend) return cast("DataFrame[Any]", result) @@ -160,7 +164,7 @@ def from_dicts( data: Sequence[Any], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackendEager, + backend: IntoBackend[EagerAllowed | PluginName], ) -> DataFrame[Any]: result = super().from_dicts(data, schema, backend=backend) return cast("DataFrame[Any]", result) @@ -171,7 +175,7 @@ def from_numpy( data: _2DArray, schema: IntoSchema | Sequence[str] | None = None, *, - backend: IntoBackendEager, + backend: IntoBackend[EagerAllowed | PluginName], ) -> DataFrame[Any]: result = super().from_numpy(data, schema, backend=backend) return cast("DataFrame[Any]", result) @@ -329,7 +333,7 @@ def from_numpy( values: _1DArray, dtype: IntoDType | None = None, *, - backend: IntoBackendEager, + backend: IntoBackend[EagerAllowed | PluginName], ) -> Series[Any]: result = super().from_numpy(name, values, dtype, backend=backend) return cast("Series[Any]", result) @@ -341,7 +345,7 @@ def from_iterable( values: Iterable[Any], dtype: IntoDType | None = None, *, - backend: IntoBackendEager, + backend: IntoBackend[EagerAllowed | PluginName], ) -> Series[Any]: result = super().from_iterable(name, values, dtype, backend=backend) return cast("Series[Any]", result) @@ -950,7 +954,7 @@ def new_series( values: Any, dtype: IntoDType | None = None, *, - backend: IntoBackendEager | None = None, + backend: IntoBackend[EagerAllowed | PluginName] | None = None, native_namespace: ModuleType | None = None, # noqa: ARG001 ) -> Series[Any]: """Instantiate Narwhals Series from iterable (e.g. list or array). @@ -959,7 +963,7 @@ def new_series( an is the same as `backend` but only accepts module types - for new code, we recommend using `backend`, as that's available beyond just `narwhals.stable.v1`. """ - backend = cast("IntoBackendEager", backend) + backend = cast("IntoBackend[EagerAllowed | PluginName]", backend) return _stableify(_new_series_impl(name, values, dtype, backend=backend)) @@ -967,7 +971,7 @@ def new_series( def from_arrow( native_frame: IntoArrowTable, *, - backend: IntoBackendEager | None = None, + backend: IntoBackend[EagerAllowed | PluginName] | None = None, native_namespace: ModuleType | None = None, # noqa: ARG001 ) -> DataFrame[Any]: """Construct a DataFrame from an object which supports the PyCapsule Interface. @@ -976,7 +980,7 @@ def from_arrow( an is the same as `backend` but only accepts module types - for new code, we recommend using `backend`, as that's available beyond just `narwhals.stable.v1`. """ - backend = cast("IntoBackendEager", backend) + backend = cast("IntoBackend[EagerAllowed | PluginName]", backend) return _stableify(nw_f.from_arrow(native_frame, backend=backend)) @@ -985,7 +989,7 @@ def from_dict( data: Mapping[str, Any], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackendEager | None = None, + backend: IntoBackend[EagerAllowed | PluginName] | None = None, native_namespace: ModuleType | None = None, # noqa: ARG001 ) -> DataFrame[Any]: """Instantiate DataFrame from dictionary. @@ -1005,7 +1009,7 @@ def from_numpy( data: _2DArray, schema: IntoSchema | Sequence[str] | None = None, *, - backend: IntoBackendEager | None = None, + backend: IntoBackend[EagerAllowed | PluginName] | None = None, native_namespace: ModuleType | None = None, # noqa: ARG001 ) -> DataFrame[Any]: """Construct a DataFrame from a NumPy ndarray. @@ -1014,7 +1018,7 @@ def from_numpy( an is the same as `backend` but only accepts module types - for new code, we recommend using `backend`, as that's available beyond just `narwhals.stable.v1`. """ - backend = cast("IntoBackendEager", backend) + backend = cast("IntoBackend[EagerAllowed | PluginName]", backend) return _stableify(nw_f.from_numpy(data, schema, backend=backend)) @@ -1022,7 +1026,7 @@ def from_numpy( def read_csv( source: FileSource, *, - backend: IntoBackendEager | None = None, + backend: IntoBackend[EagerAllowed | PluginName] | None = None, native_namespace: ModuleType | None = None, # noqa: ARG001 **kwargs: Any, ) -> DataFrame[Any]: @@ -1032,7 +1036,7 @@ def read_csv( an is the same as `backend` but only accepts module types - for new code, we recommend using `backend`, as that's available beyond just `narwhals.stable.v1`. """ - backend = cast("IntoBackendEager", backend) + backend = cast("IntoBackend[EagerAllowed | PluginName]", backend) return _stableify(nw_f.read_csv(source, backend=backend, **kwargs)) @@ -1040,7 +1044,7 @@ def read_csv( def scan_csv( source: FileSource, *, - backend: IntoBackendAny | None = None, + backend: IntoBackend[Backend | PluginName] | None = None, native_namespace: ModuleType | None = None, # noqa: ARG001 **kwargs: Any, ) -> LazyFrame[Any]: @@ -1050,7 +1054,7 @@ def scan_csv( an is the same as `backend` but only accepts module types - for new code, we recommend using `backend`, as that's available beyond just `narwhals.stable.v1`. """ - backend = cast("IntoBackendAny", backend) + backend = cast("IntoBackend[Backend | PluginName]", backend) return _stableify(nw_f.scan_csv(source, backend=backend, **kwargs)) @@ -1058,7 +1062,7 @@ def scan_csv( def read_parquet( source: FileSource, *, - backend: IntoBackendEager | None = None, + backend: IntoBackend[EagerAllowed | PluginName] | None = None, native_namespace: ModuleType | None = None, # noqa: ARG001 **kwargs: Any, ) -> DataFrame[Any]: @@ -1068,7 +1072,7 @@ def read_parquet( an is the same as `backend` but only accepts module types - for new code, we recommend using `backend`, as that's available beyond just `narwhals.stable.v1`. """ - backend = cast("IntoBackendEager", backend) + backend = cast("IntoBackend[EagerAllowed | PluginName]", backend) return _stableify(nw_f.read_parquet(source, backend=backend, **kwargs)) @@ -1076,7 +1080,7 @@ def read_parquet( def scan_parquet( source: FileSource, *, - backend: IntoBackendAny | None = None, + backend: IntoBackend[Backend | PluginName] | None = None, native_namespace: ModuleType | None = None, # noqa: ARG001 **kwargs: Any, ) -> LazyFrame[Any]: @@ -1086,7 +1090,7 @@ def scan_parquet( an is the same as `backend` but only accepts module types - for new code, we recommend using `backend`, as that's available beyond just `narwhals.stable.v1`. """ - backend = cast("IntoBackendAny", backend) + backend = cast("IntoBackend[Backend | PluginName]", backend) return _stableify(nw_f.scan_parquet(source, backend=backend, **kwargs)) diff --git a/src/narwhals/stable/v2/__init__.py b/src/narwhals/stable/v2/__init__.py index b59290dcf2..04ec4d6c36 100644 --- a/src/narwhals/stable/v2/__init__.py +++ b/src/narwhals/stable/v2/__init__.py @@ -87,11 +87,12 @@ ) from narwhals._typing import ( Arrow, + Backend, + EagerAllowed, IntoBackend, - IntoBackendAny, - IntoBackendEager, LazyAllowed, Pandas, + PluginName, Polars, ) from narwhals.dataframe import MultiColSelector, MultiIndexSelector @@ -125,7 +126,10 @@ def __init__(self, df: Any, *, level: Literal["full", "lazy", "interchange"]) -> @classmethod def from_arrow( - cls, native_frame: IntoArrowTable, *, backend: IntoBackendEager + cls, + native_frame: IntoArrowTable, + *, + backend: IntoBackend[EagerAllowed | PluginName], ) -> DataFrame[Any]: result = super().from_arrow(native_frame, backend=backend) return cast("DataFrame[Any]", result) @@ -136,7 +140,7 @@ def from_dict( data: Mapping[str, Any], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackendEager | None = None, + backend: IntoBackend[EagerAllowed | PluginName] | None = None, ) -> DataFrame[Any]: result = super().from_dict(data, schema, backend=backend) return cast("DataFrame[Any]", result) @@ -147,7 +151,7 @@ def from_dicts( data: Sequence[Mapping[str, Any]], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackendEager, + backend: IntoBackend[EagerAllowed | PluginName], ) -> DataFrame[Any]: result = super().from_dicts(data, schema, backend=backend) return cast("DataFrame[Any]", result) @@ -158,7 +162,7 @@ def from_numpy( data: _2DArray, schema: IntoSchema | Sequence[str] | None = None, *, - backend: IntoBackendEager, + backend: IntoBackend[EagerAllowed | PluginName], ) -> DataFrame[Any]: result = super().from_numpy(data, schema, backend=backend) return cast("DataFrame[Any]", result) @@ -282,7 +286,7 @@ def from_numpy( values: _1DArray, dtype: IntoDType | None = None, *, - backend: IntoBackendEager, + backend: IntoBackend[EagerAllowed | PluginName], ) -> Series[Any]: result = super().from_numpy(name, values, dtype, backend=backend) return cast("Series[Any]", result) @@ -294,7 +298,7 @@ def from_iterable( values: Iterable[Any], dtype: IntoDType | None = None, *, - backend: IntoBackendEager, + backend: IntoBackend[EagerAllowed | PluginName], ) -> Series[Any]: result = super().from_iterable(name, values, dtype, backend=backend) return cast("Series[Any]", result) @@ -923,7 +927,11 @@ def when(*predicates: IntoExpr | Iterable[IntoExpr]) -> When: def new_series( - name: str, values: Any, dtype: IntoDType | None = None, *, backend: IntoBackendEager + name: str, + values: Any, + dtype: IntoDType | None = None, + *, + backend: IntoBackend[EagerAllowed | PluginName], ) -> Series[Any]: """Instantiate Narwhals Series from iterable (e.g. list or array). @@ -945,7 +953,7 @@ def new_series( def from_arrow( - native_frame: IntoArrowTable, *, backend: IntoBackendEager + native_frame: IntoArrowTable, *, backend: IntoBackend[EagerAllowed | PluginName] ) -> DataFrame[Any]: """Construct a DataFrame from an object which supports the PyCapsule Interface. @@ -967,7 +975,7 @@ def from_dict( data: Mapping[str, Any], schema: IntoSchema | Mapping[str, IntoDType | None] | None = None, *, - backend: IntoBackendEager | None = None, + backend: IntoBackend[EagerAllowed | PluginName] | None = None, ) -> DataFrame[Any]: """Instantiate DataFrame from dictionary. @@ -1005,7 +1013,7 @@ def from_numpy( data: _2DArray, schema: IntoSchema | Sequence[str] | None = None, *, - backend: IntoBackendEager, + backend: IntoBackend[EagerAllowed | PluginName], ) -> DataFrame[Any]: """Construct a DataFrame from a NumPy ndarray. @@ -1032,7 +1040,11 @@ def from_numpy( def read_csv( - source: str, *, backend: IntoBackendEager, separator: str = ",", **kwargs: Any + source: str, + *, + backend: IntoBackend[EagerAllowed | PluginName], + separator: str = ",", + **kwargs: Any, ) -> DataFrame[Any]: """Read a CSV file into a DataFrame. @@ -1056,7 +1068,11 @@ def read_csv( def scan_csv( - source: str, *, backend: IntoBackendAny, separator: str = ",", **kwargs: Any + source: str, + *, + backend: IntoBackend[Backend | PluginName], + separator: str = ",", + **kwargs: Any, ) -> LazyFrame[Any]: """Lazily read from a CSV file. @@ -1083,7 +1099,7 @@ def scan_csv( def read_parquet( - source: str, *, backend: IntoBackendEager, **kwargs: Any + source: str, *, backend: IntoBackend[EagerAllowed | PluginName], **kwargs: Any ) -> DataFrame[Any]: """Read into a DataFrame from a parquet file. @@ -1104,7 +1120,7 @@ def read_parquet( def scan_parquet( - source: str, *, backend: IntoBackendAny, **kwargs: Any + source: str, *, backend: IntoBackend[Backend | PluginName], **kwargs: Any ) -> LazyFrame[Any]: """Lazily read from a parquet file. diff --git a/tests/plugins_test.py b/tests/plugins_test.py index 101bfb3f40..eb511855b4 100644 --- a/tests/plugins_test.py +++ b/tests/plugins_test.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from typing_extensions import Self - from narwhals._typing import IntoBackendEager + from narwhals._typing import EagerAllowed, IntoBackend from narwhals.plugins import Plugin from narwhals.utils import Version @@ -148,12 +148,12 @@ def test_plugin_name_runtime() -> None: def typing_backend_plugin_name( plugin_name: PluginName, dynamic_string: str, - eager_or_plugin: IntoBackendEager, + eager_or_plugin: IntoBackend[EagerAllowed | PluginName], df: nw.DataFrame[Any], ) -> None: data = {"a": [1, 2]} - # Accepted: an explicitly wrapped plugin name, everything `IntoBackendEager` covers. + # Accepted: an explicitly wrapped plugin name, everything `IntoBackend[EagerAllowed | PluginName]` covers. nw.from_dict(data, backend=plugin_name) nw.from_dict(data, backend=eager_or_plugin) nw.new_series("a", [1, 2], backend=plugin_name) From 782ca87e4f1db0617ff08ed22e7da98deddfde62 Mon Sep 17 00:00:00 2001 From: FBruzzesi Date: Thu, 16 Jul 2026 19:03:32 +0200 Subject: [PATCH 3/3] Feedback adjustments --- src/narwhals/_typing.py | 29 ++++++++++++++++------------- src/narwhals/plugins.py | 16 +++++++++++++++- tests/plugins_test.py | 5 ++++- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/narwhals/_typing.py b/src/narwhals/_typing.py index 92bf51751a..189a1543b6 100644 --- a/src/narwhals/_typing.py +++ b/src/narwhals/_typing.py @@ -1,3 +1,14 @@ +"""Type aliases describing the backends Narwhals dispatches to. + +Built-in backends are enumerated as `Literal` unions (`Backend`, `EagerAllowed`, `LazyAllowed`, ...). +Plugin backends are discovered at runtime and cannot join those unions, so `PluginName` uses a +[`NewType`](https://typing.python.org/en/latest/spec/aliases.html#newtype) instead: +type checkers treat it as a distinct subtype of `str`, therefore: + +- an arbitrary `str` is still rejected where a `backend` is expected, and +- a value explicitly wrapped as `PluginName("...")` is accepted. +""" + from __future__ import annotations from types import ModuleType @@ -92,21 +103,13 @@ """ PluginName = NewType("PluginName", str) -"""Name of a plugin backend's [entry point](https://packaging.python.org/en/latest/specifications/entry-points/). - -Plugin backends are discovered at runtime, so their names cannot join the -`Literal` unions that describe the built-in backends. `PluginName` is a -[`NewType`](https://typing.python.org/en/latest/spec/aliases.html#newtype): -type checkers treat it as a distinct subtype of `str`, meaning - -- an arbitrary `str` is still rejected where a `backend` is expected, and -- a value explicitly wrapped as `PluginName("...")` is accepted. +"""Name of a plugin's [entry point](https://packaging.python.org/en/latest/specifications/entry-points/). -The contract is that a wrapped string **must** name an installed plugin's -entry point in the `narwhals.plugins` group. +- Wrap an entry point name to pass it wherever a `backend` is expected, e.g. `PluginName("my-plugin")`. +- Add it to a signature's `IntoBackend` parameter to advertise plugin support, e.g. `IntoBackend[EagerAllowed | PluginName]`. -Signatures that dispatch to plugins include it in the `IntoBackend` -parameter, e.g. `IntoBackend[EagerAllowed | PluginName]`. +See the `narwhals.plugins` module for how plugin authors register entry points +and the contract a wrapped name must satisfy. """ BackendT = TypeVar("BackendT", bound=Backend | PluginName) diff --git a/src/narwhals/plugins.py b/src/narwhals/plugins.py index a247fc6b22..ae823eec59 100644 --- a/src/narwhals/plugins.py +++ b/src/narwhals/plugins.py @@ -1,3 +1,16 @@ +"""Runtime discovery of, and dispatch to, Narwhals plugin backends. + +A plugin backend registers an [entry point](https://packaging.python.org/en/latest/specifications/entry-points/) +in the `narwhals.plugins` group. Plugins are discovered at runtime, so their names +cannot be enumerated in the `Literal` unions that describe built-in backends. + +`PluginName` bridges that gap: a plugin's entry point name, wrapped as +`PluginName("my-plugin")`, is accepted wherever a `backend` is expected. + +The contract for plugin authors is that the wrapped string **must** name an +installed plugin's entry point in the `narwhals.plugins` group. +""" + from __future__ import annotations import sys @@ -5,6 +18,7 @@ from typing import TYPE_CHECKING, Any, Protocol from narwhals._compliant import CompliantNamespace +from narwhals._typing import PluginName from narwhals._typing_compat import TypeVar if TYPE_CHECKING: @@ -23,7 +37,7 @@ from narwhals.utils import Version -__all__ = ["Plugin", "from_native"] +__all__ = ["Plugin", "PluginName", "from_native"] CompliantAny: TypeAlias = ( "CompliantDataFrameAny | CompliantLazyFrameAny | CompliantSeriesAny" diff --git a/tests/plugins_test.py b/tests/plugins_test.py index eb511855b4..11bcd813e3 100644 --- a/tests/plugins_test.py +++ b/tests/plugins_test.py @@ -8,7 +8,7 @@ import narwhals.stable.v1.dependencies as nw_v1_dependencies import narwhals.stable.v2.dependencies as nw_v2_dependencies from narwhals import dependencies as nw_dependencies -from narwhals._typing import PluginName +from narwhals.plugins import PluginName if TYPE_CHECKING: from typing_extensions import Self @@ -171,3 +171,6 @@ def typing_backend_plugin_name( # Rejected: `.lazy` does not dispatch to plugins (yet). df.lazy(plugin_name) # type: ignore[arg-type] + lf = df.lazy() + # Rejected: `.collect` does not dispatch to plugins (yet). + lf.collect(plugin_name) # type: ignore[arg-type]