Skip to content
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

feat: has_operation #1807

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions docs/api-reference/narwhals.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Here are the top-level functions available in Narwhals.
- generate_temporary_column_name
- get_level
- get_native_namespace
- has_operation
- is_ordered_categorical
- len
- lit
Expand Down
2 changes: 2 additions & 0 deletions narwhals/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
from narwhals.translate import to_py_scalar
from narwhals.utils import Implementation
from narwhals.utils import generate_temporary_column_name
from narwhals.utils import has_operation
from narwhals.utils import is_ordered_categorical
from narwhals.utils import maybe_align_index
from narwhals.utils import maybe_convert_dtypes
Expand Down Expand Up @@ -130,6 +131,7 @@
"generate_temporary_column_name",
"get_level",
"get_native_namespace",
"has_operation",
"is_ordered_categorical",
"len",
"lit",
Expand Down
11 changes: 6 additions & 5 deletions narwhals/expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from narwhals.utils import _validate_rolling_arguments
from narwhals.utils import flatten
from narwhals.utils import issue_deprecation_warning
from narwhals.utils import metaproperty

if TYPE_CHECKING:
from typing import TypeVar
Expand Down Expand Up @@ -2642,23 +2643,23 @@ def rank(
aggregates=self._aggregates,
)

@property
@metaproperty(ExprStringNamespace)
def str(self: Self) -> ExprStringNamespace[Self]:
return ExprStringNamespace(self)

@property
@metaproperty(ExprDateTimeNamespace)
def dt(self: Self) -> ExprDateTimeNamespace[Self]:
return ExprDateTimeNamespace(self)

@property
@metaproperty(ExprCatNamespace)
def cat(self: Self) -> ExprCatNamespace[Self]:
return ExprCatNamespace(self)

@property
@metaproperty(ExprNameNamespace)
def name(self: Self) -> ExprNameNamespace[Self]:
return ExprNameNamespace(self)

@property
@metaproperty(ExprListNamespace)
def list(self: Self) -> ExprListNamespace[Self]:
return ExprListNamespace(self)

Expand Down
9 changes: 5 additions & 4 deletions narwhals/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from narwhals.typing import IntoSeriesT
from narwhals.utils import _validate_rolling_arguments
from narwhals.utils import generate_repr
from narwhals.utils import metaproperty
from narwhals.utils import parse_version

if TYPE_CHECKING:
Expand Down Expand Up @@ -4908,18 +4909,18 @@ def rank(
self._compliant_series.rank(method=method, descending=descending)
)

@property
@metaproperty(SeriesStringNamespace)
def str(self: Self) -> SeriesStringNamespace[Self]:
return SeriesStringNamespace(self)

@property
@metaproperty(SeriesDateTimeNamespace)
def dt(self: Self) -> SeriesDateTimeNamespace[Self]:
return SeriesDateTimeNamespace(self)

@property
@metaproperty(SeriesCatNamespace)
def cat(self: Self) -> SeriesCatNamespace[Self]:
return SeriesCatNamespace(self)

@property
@metaproperty(SeriesListNamespace)
def list(self: Self) -> SeriesListNamespace[Self]:
return SeriesListNamespace(self)
2 changes: 2 additions & 0 deletions narwhals/stable/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
from narwhals.utils import Version
from narwhals.utils import find_stacklevel
from narwhals.utils import generate_temporary_column_name
from narwhals.utils import has_operation
from narwhals.utils import is_ordered_categorical
from narwhals.utils import maybe_align_index
from narwhals.utils import maybe_convert_dtypes
Expand Down Expand Up @@ -2376,6 +2377,7 @@ def scan_parquet(
"generate_temporary_column_name",
"get_level",
"get_native_namespace",
"has_operation",
"is_ordered_categorical",
"len",
"lit",
Expand Down
242 changes: 242 additions & 0 deletions narwhals/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,24 @@
import re
from enum import Enum
from enum import auto
from importlib import import_module
from inspect import getmembers
from inspect import getmodule
from inspect import getmro
from inspect import isclass
from inspect import isfunction
from inspect import ismethod
from secrets import token_hex
from typing import TYPE_CHECKING
from typing import Any
from typing import Callable
from typing import Generic
from typing import Iterable
from typing import Sequence
from typing import TypeVar
from typing import Union
from typing import cast
from typing import overload
from warnings import warn

from narwhals.dependencies import get_cudf
Expand All @@ -23,6 +33,7 @@
from narwhals.dependencies import get_polars
from narwhals.dependencies import get_pyarrow
from narwhals.dependencies import get_pyspark_sql
from narwhals.dependencies import get_sqlframe
from narwhals.dependencies import is_cudf_series
from narwhals.dependencies import is_modin_series
from narwhals.dependencies import is_pandas_dataframe
Expand Down Expand Up @@ -108,6 +119,7 @@ def from_native_namespace(
get_dask_dataframe(): Implementation.DASK,
get_duckdb(): Implementation.DUCKDB,
get_ibis(): Implementation.IBIS,
get_sqlframe(): Implementation.SQLFRAME,
}
return mapping.get(native_namespace, Implementation.UNKNOWN)

Expand Down Expand Up @@ -195,6 +207,16 @@ def to_native_namespace(self: Self) -> ModuleType:
import duckdb # ignore-banned-import

return duckdb # type: ignore[no-any-return]

if self is Implementation.IBIS: # pragma: no cover
import ibis # ignore-banned-import

return ibis # type: ignore[no-any-return]

if self is Implementation.SQLFRAME: # pragma: no cover
import sqlframe # ignore-banned-import

return sqlframe # type: ignore[no-any-return]
msg = "Not supported Implementation" # pragma: no cover
raise AssertionError(msg)

Expand Down Expand Up @@ -363,6 +385,70 @@ def is_ibis(self: Self) -> bool:
return self is Implementation.IBIS # pragma: no cover


T = TypeVar("T")
Namespace = TypeVar("Namespace")


class MetaProperty(Generic[T, Namespace]):
def __init__(
self, func: Callable[[T], Namespace], class_value: type[Namespace]
) -> None:
self._class_value = class_value
self._inst_method = func

@overload
def __get__(self, instance: None, owner: type[T]) -> type[Namespace]: ...
@overload
def __get__(self, instance: T, owner: type[T]) -> Namespace: ...
def __get__(self, instance: T | None, owner: type[T]) -> Namespace | type[Namespace]:
if instance is None:
return self._class_value
return self._inst_method(instance)


def metaproperty(
returns: type[Namespace],
) -> Callable[[Callable[[T], Namespace]], Namespace]: # TODO(Unassigned): Fix typing
"""Property decorator that changes the returned value when accessing from the class.

Arguments:
returns: The object to return upon class attribute accession.

Returns:
metaproperty descriptor.

Arguments:
returns: The object to return upon class attribute accession.

Returns:
A decorator that applies the custom metaproperty behavior.

Examples:
>>> from narwhals.utils import metaproperty
>>> class T:
... @property
... def f(self):
... return 5
...
... @metaproperty(str)
... def g(self):
... return 5

>>> t = T()
>>> assert t.f == t.g # 5
>>> assert isinstance(T.f, property)
>>> assert T.g is str

"""

def wrapper(
func: Callable[[T], Namespace],
) -> Namespace: # TODO(Unassigned): Fix typing
return MetaProperty(func, returns) # type: ignore[return-value]

return wrapper


MIN_VERSIONS: dict[Implementation, tuple[int, ...]] = {
Implementation.PANDAS: (0, 25, 3),
Implementation.MODIN: (0, 25, 3),
Expand Down Expand Up @@ -1170,3 +1256,159 @@ def check_column_names_are_unique(columns: list[str]) -> None:
msg = "".join(f"\n- '{k}' {v} times" for k, v in duplicates.items())
msg = f"Expected unique column names, got:{msg}"
raise DuplicateError(msg)


def get_class_that_defines_method(method: Callable[..., Any]) -> type:
"""Returns the class from a given unbound function or method.

https://stackoverflow.com/questions/3589311/get-defining-class-of-unbound-method-object-in-python-3/25959545#25959545

Returns:
type
"""
if ismethod(method):
return next(
cls
for cls in getmro(method.__self__.__class__)
if method.__name__ in cls.__dict__
)

elif isfunction(method):
maybe_cls = getattr(
getmodule(method),
method.__qualname__.split(".<locals>", 1)[0].rsplit(".", 1)[0],
)
if isclass(maybe_cls):
return maybe_cls

msg = f"Unable to parse the owners type of {method}"
raise TypeError(msg)


def has_operation(native_namespace: ModuleType, operation: Any) -> bool:
"""Indicate whether a provided operation is available within a native namespace.

Arguments:
native_namespace: module to check against, any
operation: an unbound narwhals function, reached from the class implementation.

Returns:
boolean indicating whether the provided operation is a

Raises:
ValueError: `native_namespace` could not be mapped to a narwhals implementation.

Examples:
>>> import narwhals as nw
>>> import pandas as pd

>>> nw.has_operation(pd, nw.Expr.mean)
True

>>> nw.has_operation(pd, nw.Expr.dt.date)
True

>>> nw.has_operation(pd, nw.Series.mean)
True

>>> nw.has_operation(pd, nw.Series.dt.date)
True

>>> nw.has_operation(pd, nw.DataFrame.join_asof)
True

>>> import duckdb
>>> nw.has_operation(duckdb, nw.Expr.mean)
True

>>> nw.has_operation(duckdb, nw.Series.mean)
False

"""
implementation = Implementation.from_native_namespace(native_namespace)
if implementation is Implementation.POLARS:
return True

nw_cls = get_class_that_defines_method(operation)
backend_mapping = {
Implementation.PANDAS: "_pandas_like",
Implementation.MODIN: "_pandas_like",
Implementation.CUDF: "_pandas_like",
Implementation.PYARROW: "_arrow",
Implementation.PYSPARK: "_spark_like",
Implementation.DASK: "_dask",
Implementation.DUCKDB: "_duckdb",
Implementation.IBIS: "_ibis",
}
try:
backend = backend_mapping[implementation]
except KeyError as e:
msg = f"Unknown namespace {native_namespace.__name__!r}"
for impl in Implementation:
if impl is Implementation.UNKNOWN:
continue

try:
ns = impl.to_native_namespace()
except ImportError:
continue
if native_namespace.__name__.casefold() in ns.__name__.casefold():
msg += f", did you mean {ns.__name__!r}?"
break
raise ValueError(msg) from e

_, _, module_name = nw_cls.__module__.partition(".")
try:
module_ = import_module(f"narwhals.{backend}.{module_name}")
except ModuleNotFoundError: # pragma: no cover
return False

classes_ = getmembers(
module_,
predicate=lambda c: (
isclass(c)
and c.__name__.endswith(nw_cls.__name__)
and not c.__name__.startswith("Compliant") # Exclude protocols
and not c.__name__.startswith("DuckDBInterchange")
),
)
if not classes_:
return False
cls = classes_[0][-1]
if hasattr(cls, operation.__name__):
return is_implemented(getattr(cls, operation.__name__))
return False


def is_implemented(func: Callable[..., Any]) -> bool:
from ast import Call
from ast import Name
from ast import NodeVisitor
from ast import Raise
from ast import Return
from ast import parse
from inspect import getsource
from textwrap import dedent

class NotImplementedVisitor(NodeVisitor):
def __init__(self) -> None:
self.has_notimplemented = False
self.has_return = False
super().__init__()

def visit_Return(self, node: Return) -> None: # noqa: N802
self.has_return = True

def visit_Raise(self, node: Raise) -> None: # noqa: N802
if (
isinstance(node.exc, Call) and node.exc.func.id == "NotImplementedError" # type: ignore[attr-defined]
) or (isinstance(node.exc, Name) and node.exc.id == "NotImplementedError"):
self.has_notimplemented = True

source = dedent(getsource(func))
tree = parse(source)

v = NotImplementedVisitor()
v.visit(tree)

return v.has_return or not v.has_notimplemented
Loading
Loading