diff --git a/.github/workflows/downstream_tests.yml b/.github/workflows/downstream_tests.yml index 812405fc3..95b4d14e0 100644 --- a/.github/workflows/downstream_tests.yml +++ b/.github/workflows/downstream_tests.yml @@ -1,8 +1,9 @@ name: Test Downstream Libraries on: - workflow_call: - workflow_dispatch: + pull_request: + push: + branches: [main] jobs: altair: @@ -230,59 +231,59 @@ jobs: cd tubular pytest tests --config-file=pyproject.toml - vegafusion: - env: - UV_SYSTEM_PYTHON: true + # vegafusion: + # env: + # UV_SYSTEM_PYTHON: true - strategy: - matrix: - python-version: ["3.11"] - os: [ubuntu-latest] + # strategy: + # matrix: + # python-version: ["3.11"] + # os: [ubuntu-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install uv - uses: astral-sh/setup-uv@v3 - with: - enable-cache: "true" - cache-suffix: ${{ matrix.python-version }} - cache-dependency-glob: "**requirements*.txt" - - name: clone-vegafusion - run: | - git clone --single-branch -b v2 https://github.com/vega/vegafusion.git - cd vegafusion - git log - - name: Cache rust dependencies - uses: Swatinem/rust-cache@v2 - with: - workspaces: vegafusion - - name: Build wheels - uses: PyO3/maturin-action@v1 - with: - command: build - manylinux: 2014 - rust-toolchain: stable - args: --release -m vegafusion/vegafusion-python/Cargo.toml --features=protobuf-src --strip - - name: Install wheels - working-directory: vegafusion/target/wheels/ - run: | - ls -la - python -m pip install vegafusion-*manylinux*.whl + # runs-on: ${{ matrix.os }} + # steps: + # - uses: actions/checkout@v4 + # - uses: actions/setup-python@v5 + # with: + # python-version: ${{ matrix.python-version }} + # - name: Install uv + # uses: astral-sh/setup-uv@v3 + # with: + # enable-cache: "true" + # cache-suffix: ${{ matrix.python-version }} + # cache-dependency-glob: "**requirements*.txt" + # - name: clone-vegafusion + # run: | + # git clone --single-branch -b v2 https://github.com/vega/vegafusion.git + # cd vegafusion + # git log + # - name: Cache rust dependencies + # uses: Swatinem/rust-cache@v2 + # with: + # workspaces: vegafusion + # - name: Build wheels + # uses: PyO3/maturin-action@v1 + # with: + # command: build + # manylinux: 2014 + # rust-toolchain: stable + # args: --release -m vegafusion/vegafusion-python/Cargo.toml --features=protobuf-src --strip + # - name: Install wheels + # working-directory: vegafusion/target/wheels/ + # run: | + # ls -la + # python -m pip install vegafusion-*manylinux*.whl - # Optional dependencies - python -m pip install pyarrow pandas polars-lts-cpu "duckdb>=1.0" "vl-convert-python>=1.0.1rc1" scikit-image "pandas>=2.2" jupytext voila anywidget ipywidgets chromedriver-binary-auto + # # Optional dependencies + # python -m pip install pyarrow pandas polars-lts-cpu "duckdb>=1.0" "vl-convert-python>=1.0.1rc1" scikit-image "pandas>=2.2" jupytext voila anywidget ipywidgets chromedriver-binary-auto - # Test dependencies - python -m pip install pytest altair vega-datasets scikit-image jupytext voila ipykernel anywidget ipywidgets selenium flaky tenacity chromedriver-binary-auto - - name: Test lazy imports - working-directory: vegafusion/vegafusion-python/ - run: python checks/check_lazy_imports.py - - name: Test vegafusion - working-directory: vegafusion/vegafusion-python/ - env: - VEGAFUSION_TEST_HEADLESS: 1 - run: pytest + # # Test dependencies + # python -m pip install pytest altair vega-datasets scikit-image jupytext voila ipykernel anywidget ipywidgets selenium flaky tenacity chromedriver-binary-auto + # - name: Test lazy imports + # working-directory: vegafusion/vegafusion-python/ + # run: python checks/check_lazy_imports.py + # - name: Test vegafusion + # working-directory: vegafusion/vegafusion-python/ + # env: + # VEGAFUSION_TEST_HEADLESS: 1 + # run: pytest diff --git a/docs/backcompat.md b/docs/backcompat.md index 2090eae75..807d51ed6 100644 --- a/docs/backcompat.md +++ b/docs/backcompat.md @@ -99,8 +99,27 @@ before making any change. ### After `stable.v1` -- `Datetime` and `Duration` dtypes hash using both `time_unit` and `time_zone`. - The effect of this can be seen when doing `dtype in {...}` checks: +- Since Narwhals 1.13.0, the `strict` parameter in `from_native`, `to_native`, and `narwhalify` + has been deprecated in favour of `pass_through`. This is because several users expressed + confusion/surprise over what `strict=False` did. + ```python + # v1 syntax: + nw.from_native(df, strict=False) + + # main namespace (and, when we get there, v2) syntax: + nw.from_native(df, pass_through=True) + ``` + If you are using Narwhals>=1.13.0, then we recommend using `pass_through`, as that + works consistently across namespaces. + + In the future: + + - in the main Narwhals namespace, `strict` will be removed in favour of `pass_through` + - in `stable.v1`, we will keep both `strict` and `pass_through` + +- Since Narwhals 1.9.0, `Datetime` and `Duration` dtypes hash using both `time_unit` and + `time_zone`. + The effect of this can be seen when placing these dtypes in sets: ```python exec="1" source="above" session="backcompat" import narwhals.stable.v1 as nw_v1 diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py index 3ddaa2814..bf8bb5c98 100644 --- a/narwhals/dataframe.py +++ b/narwhals/dataframe.py @@ -2874,7 +2874,7 @@ def to_native(self) -> FrameT: └─────┴─────┴─────┘ """ - return to_native(narwhals_object=self, strict=True) + return to_native(narwhals_object=self, pass_through=False) # inherited def pipe(self, function: Callable[[Any], Self], *args: Any, **kwargs: Any) -> Self: diff --git a/narwhals/functions.py b/narwhals/functions.py index b8dfffbeb..20efa7b45 100644 --- a/narwhals/functions.py +++ b/narwhals/functions.py @@ -361,7 +361,7 @@ def _from_dict_impl( else: msg = "Calling `from_dict` without `native_namespace` is only supported if all input values are already Narwhals Series" raise TypeError(msg) - data = {key: to_native(value, strict=False) for key, value in data.items()} + data = {key: to_native(value, pass_through=True) for key, value in data.items()} implementation = Implementation.from_native_namespace(native_namespace) if implementation is Implementation.POLARS: diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py index 5bc762dda..e11cc6870 100644 --- a/narwhals/stable/v1/__init__.py +++ b/narwhals/stable/v1/__init__.py @@ -51,7 +51,6 @@ from narwhals.stable.v1.dtypes import Unknown from narwhals.translate import _from_native_impl from narwhals.translate import get_native_namespace -from narwhals.translate import to_native from narwhals.translate import to_py_scalar from narwhals.typing import IntoDataFrameT from narwhals.typing import IntoFrameT @@ -63,6 +62,7 @@ from narwhals.utils import maybe_get_index from narwhals.utils import maybe_reset_index from narwhals.utils import maybe_set_index +from narwhals.utils import validate_strict_and_pass_though if TYPE_CHECKING: from types import ModuleType @@ -775,12 +775,212 @@ def from_native( """ +@overload +def from_native( + native_object: IntoDataFrameT | IntoSeriesT, + *, + pass_through: Literal[True], + eager_only: None = ..., + eager_or_interchange_only: Literal[True], + series_only: None = ..., + allow_series: Literal[True], +) -> DataFrame[IntoDataFrameT]: ... + + +@overload +def from_native( + native_object: IntoDataFrameT | IntoSeriesT, + *, + pass_through: Literal[True], + eager_only: Literal[True], + eager_or_interchange_only: None = ..., + series_only: None = ..., + allow_series: Literal[True], +) -> DataFrame[IntoDataFrameT] | Series: ... + + +@overload +def from_native( + native_object: IntoDataFrameT, + *, + pass_through: Literal[True], + eager_only: None = ..., + eager_or_interchange_only: Literal[True], + series_only: None = ..., + allow_series: None = ..., +) -> DataFrame[IntoDataFrameT]: ... + + +@overload +def from_native( + native_object: T, + *, + pass_through: Literal[True], + eager_only: None = ..., + eager_or_interchange_only: Literal[True], + series_only: None = ..., + allow_series: None = ..., +) -> T: ... + + +@overload +def from_native( + native_object: IntoDataFrameT, + *, + pass_through: Literal[True], + eager_only: Literal[True], + eager_or_interchange_only: None = ..., + series_only: None = ..., + allow_series: None = ..., +) -> DataFrame[IntoDataFrameT]: ... + + +@overload +def from_native( + native_object: T, + *, + pass_through: Literal[True], + eager_only: Literal[True], + eager_or_interchange_only: None = ..., + series_only: None = ..., + allow_series: None = ..., +) -> T: ... + + +@overload +def from_native( + native_object: IntoFrameT | IntoSeriesT, + *, + pass_through: Literal[True], + eager_only: None = ..., + eager_or_interchange_only: None = ..., + series_only: None = ..., + allow_series: Literal[True], +) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | Series: ... + + +@overload +def from_native( + native_object: IntoSeriesT, + *, + pass_through: Literal[True], + eager_only: None = ..., + eager_or_interchange_only: None = ..., + series_only: Literal[True], + allow_series: None = ..., +) -> Series: ... + + +@overload +def from_native( + native_object: IntoFrameT, + *, + pass_through: Literal[True], + eager_only: None = ..., + eager_or_interchange_only: None = ..., + series_only: None = ..., + allow_series: None = ..., +) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT]: ... + + +@overload +def from_native( + native_object: T, + *, + pass_through: Literal[True], + eager_only: None = ..., + eager_or_interchange_only: None = ..., + series_only: None = ..., + allow_series: None = ..., +) -> T: ... + + +@overload +def from_native( + native_object: IntoDataFrameT, + *, + pass_through: Literal[False] = ..., + eager_only: None = ..., + eager_or_interchange_only: Literal[True], + series_only: None = ..., + allow_series: None = ..., +) -> DataFrame[IntoDataFrameT]: + """ + from_native(df, pass_through=False, eager_or_interchange_only=True) + from_native(df, eager_or_interchange_only=True) + """ + + +@overload +def from_native( + native_object: IntoDataFrameT, + *, + pass_through: Literal[False] = ..., + eager_only: Literal[True], + eager_or_interchange_only: None = ..., + series_only: None = ..., + allow_series: None = ..., +) -> DataFrame[IntoDataFrameT]: + """ + from_native(df, pass_through=False, eager_only=True) + from_native(df, eager_only=True) + """ + + +@overload +def from_native( + native_object: IntoFrameT | IntoSeriesT, + *, + pass_through: Literal[False] = ..., + eager_only: None = ..., + eager_or_interchange_only: None = ..., + series_only: None = ..., + allow_series: Literal[True], +) -> DataFrame[Any] | LazyFrame[Any] | Series: + """ + from_native(df, pass_through=False, allow_series=True) + from_native(df, allow_series=True) + """ + + +@overload +def from_native( + native_object: IntoSeriesT, + *, + pass_through: Literal[False] = ..., + eager_only: None = ..., + eager_or_interchange_only: None = ..., + series_only: Literal[True], + allow_series: None = ..., +) -> Series: + """ + from_native(df, pass_through=False, series_only=True) + from_native(df, series_only=True) + """ + + +@overload +def from_native( + native_object: IntoFrameT, + *, + pass_through: Literal[False] = ..., + eager_only: None = ..., + eager_or_interchange_only: None = ..., + series_only: None = ..., + allow_series: None = ..., +) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT]: + """ + from_native(df, pass_through=False) + from_native(df) + """ + + # All params passed in as variables @overload def from_native( native_object: Any, *, - strict: bool, + pass_through: bool, eager_only: bool | None, eager_or_interchange_only: bool | None = None, series_only: bool | None, @@ -791,7 +991,8 @@ def from_native( def from_native( native_object: Any, *, - strict: bool = True, + strict: bool | None = None, + pass_through: bool | None = None, eager_only: bool | None = None, eager_or_interchange_only: bool | None = None, series_only: bool | None = None, @@ -811,8 +1012,19 @@ def from_native( - pandas.Series - polars.Series - anything with a `__narwhals_series__` method - strict: Whether to raise if object can't be converted (default) or - to just leave it as-is. + strict: Determine what happens if the object isn't supported by Narwhals: + + - `True` (default): raise an error + - `False`: pass object through as-is + + **Deprecated** (v1.13.0): + Please use `pass_through` instead. Note that `strict` is still available + (and won't emit a deprecation warning) if you use `narwhals.stable.v1`, + see [perfect backwards compatibility policy](https://narwhals-dev.github.io/narwhals/backcompat/). + pass_through: Determine what happens if the object isn't supported by Narwhals: + + - `False` (default): raise an error + - `True`: pass object through as-is eager_only: Whether to only allow eager objects. eager_or_interchange_only: Whether to only allow eager objects or objects which implement the Dataframe Interchange Protocol. @@ -829,9 +1041,14 @@ def from_native( return native_object if isinstance(native_object, Series) and (series_only or allow_series): return native_object + + pass_through = validate_strict_and_pass_though( + strict, pass_through, pass_through_default=False, emit_deprecation_warning=False + ) + result = _from_native_impl( native_object, - strict=strict, + pass_through=pass_through, eager_only=eager_only, eager_or_interchange_only=eager_or_interchange_only, series_only=series_only, @@ -841,10 +1058,72 @@ def from_native( return _stableify(result) +@overload +def to_native( + narwhals_object: DataFrame[IntoDataFrameT], *, strict: Literal[True] = ... +) -> IntoDataFrameT: ... +@overload +def to_native( + narwhals_object: LazyFrame[IntoFrameT], *, strict: Literal[True] = ... +) -> IntoFrameT: ... +@overload +def to_native(narwhals_object: Series, *, strict: Literal[True] = ...) -> Any: ... +@overload +def to_native(narwhals_object: Any, *, strict: bool) -> Any: ... +@overload +def to_native( + narwhals_object: DataFrame[IntoDataFrameT], *, pass_through: Literal[False] = ... +) -> IntoDataFrameT: ... +@overload +def to_native( + narwhals_object: LazyFrame[IntoFrameT], *, pass_through: Literal[False] = ... +) -> IntoFrameT: ... +@overload +def to_native(narwhals_object: Series, *, pass_through: Literal[False] = ...) -> Any: ... +@overload +def to_native(narwhals_object: Any, *, pass_through: bool) -> Any: ... + + +def to_native( + narwhals_object: DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | Series, + *, + strict: bool | None = None, + pass_through: bool | None = None, +) -> IntoFrameT | Any: + """ + Convert Narwhals object to native one. + + Arguments: + narwhals_object: Narwhals object. + strict: whether to raise on non-Narwhals input. + + Returns: + Object of class that user started with. + """ + from narwhals.dataframe import BaseFrame + from narwhals.series import Series + from narwhals.utils import validate_strict_and_pass_though + + pass_through = validate_strict_and_pass_though( + strict, pass_through, pass_through_default=False, emit_deprecation_warning=False + ) + + if isinstance(narwhals_object, BaseFrame): + return narwhals_object._compliant_frame._native_frame + if isinstance(narwhals_object, Series): + return narwhals_object._compliant_series._native_series + + if not pass_through: + msg = f"Expected Narwhals object, got {type(narwhals_object)}." + raise TypeError(msg) + return narwhals_object + + def narwhalify( func: Callable[..., Any] | None = None, *, - strict: bool = False, + strict: bool | None = None, + pass_through: bool | None = None, eager_only: bool | None = False, eager_or_interchange_only: bool | None = False, series_only: bool | None = False, @@ -905,13 +1184,17 @@ def func(df): allow_series: Whether to allow series (default is only dataframe / lazyframe). """ + pass_through = validate_strict_and_pass_though( + strict, pass_through, pass_through_default=True, emit_deprecation_warning=False + ) + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: @wraps(func) def wrapper(*args: Any, **kwargs: Any) -> Any: args = [ from_native( arg, - strict=strict, + pass_through=pass_through, eager_only=eager_only, eager_or_interchange_only=eager_or_interchange_only, series_only=series_only, @@ -923,7 +1206,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: kwargs = { name: from_native( value, - strict=strict, + pass_through=pass_through, eager_only=eager_only, eager_or_interchange_only=eager_or_interchange_only, series_only=series_only, @@ -944,7 +1227,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: result = func(*args, **kwargs) - return to_native(result, strict=strict) + return to_native(result, pass_through=pass_through) return wrapper diff --git a/narwhals/translate.py b/narwhals/translate.py index 65cc44226..129fe671f 100644 --- a/narwhals/translate.py +++ b/narwhals/translate.py @@ -57,22 +57,23 @@ @overload def to_native( - narwhals_object: DataFrame[IntoDataFrameT], *, strict: Literal[True] = ... + narwhals_object: DataFrame[IntoDataFrameT], *, pass_through: Literal[False] = ... ) -> IntoDataFrameT: ... @overload def to_native( - narwhals_object: LazyFrame[IntoFrameT], *, strict: Literal[True] = ... + narwhals_object: LazyFrame[IntoFrameT], *, pass_through: Literal[False] = ... ) -> IntoFrameT: ... @overload -def to_native(narwhals_object: Series, *, strict: Literal[True] = ...) -> Any: ... +def to_native(narwhals_object: Series, *, pass_through: Literal[False] = ...) -> Any: ... @overload -def to_native(narwhals_object: Any, *, strict: bool) -> Any: ... +def to_native(narwhals_object: Any, *, pass_through: bool) -> Any: ... def to_native( narwhals_object: DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | Series, *, - strict: bool = True, + strict: bool | None = None, + pass_through: bool | None = None, ) -> IntoFrameT | Any: """ Convert Narwhals object to native one. @@ -86,13 +87,18 @@ def to_native( """ from narwhals.dataframe import BaseFrame from narwhals.series import Series + from narwhals.utils import validate_strict_and_pass_though + + pass_through = validate_strict_and_pass_though( + strict, pass_through, pass_through_default=False, emit_deprecation_warning=True + ) if isinstance(narwhals_object, BaseFrame): return narwhals_object._compliant_frame._native_frame if isinstance(narwhals_object, Series): return narwhals_object._compliant_series._native_series - if strict: + if not pass_through: msg = f"Expected Narwhals object, got {type(narwhals_object)}." raise TypeError(msg) return narwhals_object @@ -102,7 +108,7 @@ def to_native( def from_native( native_object: IntoDataFrameT | IntoSeriesT, *, - strict: Literal[False], + pass_through: Literal[True], eager_only: None = ..., eager_or_interchange_only: Literal[True], series_only: None = ..., @@ -114,7 +120,7 @@ def from_native( def from_native( native_object: IntoDataFrameT | IntoSeriesT, *, - strict: Literal[False], + pass_through: Literal[True], eager_only: Literal[True], eager_or_interchange_only: None = ..., series_only: None = ..., @@ -126,7 +132,7 @@ def from_native( def from_native( native_object: IntoDataFrameT, *, - strict: Literal[False], + pass_through: Literal[True], eager_only: None = ..., eager_or_interchange_only: Literal[True], series_only: None = ..., @@ -138,7 +144,7 @@ def from_native( def from_native( native_object: T, *, - strict: Literal[False], + pass_through: Literal[True], eager_only: None = ..., eager_or_interchange_only: Literal[True], series_only: None = ..., @@ -150,7 +156,7 @@ def from_native( def from_native( native_object: IntoDataFrameT, *, - strict: Literal[False], + pass_through: Literal[True], eager_only: Literal[True], eager_or_interchange_only: None = ..., series_only: None = ..., @@ -162,7 +168,7 @@ def from_native( def from_native( native_object: T, *, - strict: Literal[False], + pass_through: Literal[True], eager_only: Literal[True], eager_or_interchange_only: None = ..., series_only: None = ..., @@ -174,7 +180,7 @@ def from_native( def from_native( native_object: IntoFrameT | IntoSeriesT, *, - strict: Literal[False], + pass_through: Literal[True], eager_only: None = ..., eager_or_interchange_only: None = ..., series_only: None = ..., @@ -186,7 +192,7 @@ def from_native( def from_native( native_object: IntoSeriesT, *, - strict: Literal[False], + pass_through: Literal[True], eager_only: None = ..., eager_or_interchange_only: None = ..., series_only: Literal[True], @@ -198,7 +204,7 @@ def from_native( def from_native( native_object: IntoFrameT, *, - strict: Literal[False], + pass_through: Literal[True], eager_only: None = ..., eager_or_interchange_only: None = ..., series_only: None = ..., @@ -210,7 +216,7 @@ def from_native( def from_native( native_object: T, *, - strict: Literal[False], + pass_through: Literal[True], eager_only: None = ..., eager_or_interchange_only: None = ..., series_only: None = ..., @@ -222,14 +228,14 @@ def from_native( def from_native( native_object: IntoDataFrameT, *, - strict: Literal[True] = ..., + pass_through: Literal[False] = ..., eager_only: None = ..., eager_or_interchange_only: Literal[True], series_only: None = ..., allow_series: None = ..., ) -> DataFrame[IntoDataFrameT]: """ - from_native(df, strict=True, eager_or_interchange_only=True) + from_native(df, pass_through=False, eager_or_interchange_only=True) from_native(df, eager_or_interchange_only=True) """ @@ -238,14 +244,14 @@ def from_native( def from_native( native_object: IntoDataFrameT, *, - strict: Literal[True] = ..., + pass_through: Literal[False] = ..., eager_only: Literal[True], eager_or_interchange_only: None = ..., series_only: None = ..., allow_series: None = ..., ) -> DataFrame[IntoDataFrameT]: """ - from_native(df, strict=True, eager_only=True) + from_native(df, pass_through=False, eager_only=True) from_native(df, eager_only=True) """ @@ -254,14 +260,14 @@ def from_native( def from_native( native_object: IntoFrameT | IntoSeriesT, *, - strict: Literal[True] = ..., + pass_through: Literal[False] = ..., eager_only: None = ..., eager_or_interchange_only: None = ..., series_only: None = ..., allow_series: Literal[True], ) -> DataFrame[Any] | LazyFrame[Any] | Series: """ - from_native(df, strict=True, allow_series=True) + from_native(df, pass_through=False, allow_series=True) from_native(df, allow_series=True) """ @@ -270,14 +276,14 @@ def from_native( def from_native( native_object: IntoSeriesT, *, - strict: Literal[True] = ..., + pass_through: Literal[False] = ..., eager_only: None = ..., eager_or_interchange_only: None = ..., series_only: Literal[True], allow_series: None = ..., ) -> Series: """ - from_native(df, strict=True, series_only=True) + from_native(df, pass_through=False, series_only=True) from_native(df, series_only=True) """ @@ -286,14 +292,14 @@ def from_native( def from_native( native_object: IntoFrameT, *, - strict: Literal[True] = ..., + pass_through: Literal[False] = ..., eager_only: None = ..., eager_or_interchange_only: None = ..., series_only: None = ..., allow_series: None = ..., ) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT]: """ - from_native(df, strict=True) + from_native(df, pass_through=False) from_native(df) """ @@ -303,7 +309,7 @@ def from_native( def from_native( native_object: Any, *, - strict: bool, + pass_through: bool, eager_only: bool | None, eager_or_interchange_only: bool | None = None, series_only: bool | None, @@ -314,7 +320,8 @@ def from_native( def from_native( native_object: Any, *, - strict: bool = True, + strict: bool | None = None, + pass_through: bool | None = None, eager_only: bool | None = None, eager_or_interchange_only: bool | None = None, series_only: bool | None = None, @@ -334,8 +341,19 @@ def from_native( - pandas.Series - polars.Series - anything with a `__narwhals_series__` method - strict: Whether to raise if object can't be converted (default) or - to just leave it as-is. + strict: Determine what happens if the object isn't supported by Narwhals: + + - `True` (default): raise an error + - `False`: pass object through as-is + + **Deprecated** (v1.13.0): + Please use `pass_through` instead. Note that `strict` is still available + (and won't emit a deprecation warning) if you use `narwhals.stable.v1`, + see [perfect backwards compatibility policy](https://narwhals-dev.github.io/narwhals/backcompat/). + pass_through: Determine what happens if the object isn't supported by Narwhals: + + - `False` (default): raise an error + - `True`: pass object through as-is eager_only: Whether to only allow eager objects. eager_or_interchange_only: Whether to only allow eager objects or objects which implement the Dataframe Interchange Protocol. @@ -346,10 +364,15 @@ def from_native( narwhals.DataFrame or narwhals.LazyFrame or narwhals.Series """ from narwhals import dtypes + from narwhals.utils import validate_strict_and_pass_though + + pass_through = validate_strict_and_pass_though( + strict, pass_through, pass_through_default=False, emit_deprecation_warning=True + ) return _from_native_impl( native_object, - strict=strict, + pass_through=pass_through, eager_only=eager_only, eager_or_interchange_only=eager_or_interchange_only, series_only=series_only, @@ -361,7 +384,7 @@ def from_native( def _from_native_impl( # noqa: PLR0915 native_object: Any, *, - strict: bool = True, + pass_through: bool = False, eager_only: bool | None = None, eager_or_interchange_only: bool | None = None, series_only: bool | None = None, @@ -403,7 +426,7 @@ def _from_native_impl( # noqa: PLR0915 # Extensions if hasattr(native_object, "__narwhals_dataframe__"): if series_only: - if strict: + if not pass_through: msg = "Cannot only use `series_only` with dataframe" raise TypeError(msg) return native_object @@ -413,12 +436,12 @@ def _from_native_impl( # noqa: PLR0915 ) elif hasattr(native_object, "__narwhals_lazyframe__"): if series_only: - if strict: + 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 strict: + if not pass_through: msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with lazyframe" raise TypeError(msg) return native_object @@ -428,7 +451,7 @@ def _from_native_impl( # noqa: PLR0915 ) elif hasattr(native_object, "__narwhals_series__"): if not allow_series: - if strict: + if not pass_through: msg = "Please set `allow_series=True` or `series_only=True`" raise TypeError(msg) return native_object @@ -440,7 +463,7 @@ def _from_native_impl( # noqa: PLR0915 # Polars elif is_polars_dataframe(native_object): if series_only: - if strict: + if not pass_through: msg = "Cannot only use `series_only` with polars.DataFrame" raise TypeError(msg) return native_object @@ -455,12 +478,12 @@ def _from_native_impl( # noqa: PLR0915 ) elif is_polars_lazyframe(native_object): if series_only: - if strict: + if not pass_through: msg = "Cannot only use `series_only` with polars.LazyFrame" raise TypeError(msg) return native_object if eager_only or eager_or_interchange_only: - if strict: + if not pass_through: msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with polars.LazyFrame" raise TypeError(msg) return native_object @@ -476,7 +499,7 @@ def _from_native_impl( # noqa: PLR0915 elif is_polars_series(native_object): pl = get_polars() if not allow_series: - if strict: + if not pass_through: msg = "Please set `allow_series=True` or `series_only=True`" raise TypeError(msg) return native_object @@ -492,7 +515,7 @@ def _from_native_impl( # noqa: PLR0915 # pandas elif is_pandas_dataframe(native_object): if series_only: - if strict: + if not pass_through: msg = "Cannot only use `series_only` with dataframe" raise TypeError(msg) return native_object @@ -508,7 +531,7 @@ def _from_native_impl( # noqa: PLR0915 ) elif is_pandas_series(native_object): if not allow_series: - if strict: + if not pass_through: msg = "Please set `allow_series=True` or `series_only=True`" raise TypeError(msg) return native_object @@ -527,7 +550,7 @@ def _from_native_impl( # noqa: PLR0915 elif is_modin_dataframe(native_object): # pragma: no cover mpd = get_modin() if series_only: - if strict: + if not pass_through: msg = "Cannot only use `series_only` with modin.DataFrame" raise TypeError(msg) return native_object @@ -543,7 +566,7 @@ def _from_native_impl( # noqa: PLR0915 elif is_modin_series(native_object): # pragma: no cover mpd = get_modin() if not allow_series: - if strict: + if not pass_through: msg = "Please set `allow_series=True` or `series_only=True`" raise TypeError(msg) return native_object @@ -561,7 +584,7 @@ def _from_native_impl( # noqa: PLR0915 elif is_cudf_dataframe(native_object): # pragma: no cover cudf = get_cudf() if series_only: - if strict: + if not pass_through: msg = "Cannot only use `series_only` with cudf.DataFrame" raise TypeError(msg) return native_object @@ -577,7 +600,7 @@ def _from_native_impl( # noqa: PLR0915 elif is_cudf_series(native_object): # pragma: no cover cudf = get_cudf() if not allow_series: - if strict: + if not pass_through: msg = "Please set `allow_series=True` or `series_only=True`" raise TypeError(msg) return native_object @@ -595,7 +618,7 @@ def _from_native_impl( # noqa: PLR0915 elif is_pyarrow_table(native_object): pa = get_pyarrow() if series_only: - if strict: + if not pass_through: msg = "Cannot only use `series_only` with arrow table" raise TypeError(msg) return native_object @@ -610,7 +633,7 @@ def _from_native_impl( # noqa: PLR0915 elif is_pyarrow_chunked_array(native_object): pa = get_pyarrow() if not allow_series: - if strict: + if not pass_through: msg = "Please set `allow_series=True` or `series_only=True`" raise TypeError(msg) return native_object @@ -627,12 +650,12 @@ def _from_native_impl( # noqa: PLR0915 # Dask elif is_dask_dataframe(native_object): if series_only: - if strict: + if not pass_through: msg = "Cannot only use `series_only` with dask DataFrame" raise TypeError(msg) return native_object if eager_only or eager_or_interchange_only: - if strict: + if not pass_through: msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with dask DataFrame" raise TypeError(msg) return native_object @@ -651,7 +674,7 @@ def _from_native_impl( # noqa: PLR0915 # DuckDB elif is_duckdb_relation(native_object): if eager_only or series_only: # pragma: no cover - if strict: + if not pass_through: msg = ( "Cannot only use `series_only=True` or `eager_only=False` " "with DuckDB Relation" @@ -667,7 +690,7 @@ def _from_native_impl( # noqa: PLR0915 # Ibis elif is_ibis_table(native_object): # pragma: no cover if eager_only or series_only: - if strict: + if not pass_through: msg = ( "Cannot only use `series_only=True` or `eager_only=False` " "with Ibis table" @@ -682,7 +705,7 @@ def _from_native_impl( # noqa: PLR0915 # Interchange protocol elif hasattr(native_object, "__dataframe__"): if eager_only or series_only: - if strict: + if not pass_through: msg = ( "Cannot only use `series_only=True` or `eager_only=False` " "with object which only implements __dataframe__" @@ -694,7 +717,7 @@ def _from_native_impl( # noqa: PLR0915 level="interchange", ) - elif strict: + elif not pass_through: msg = f"Expected pandas-like dataframe, Polars dataframe, or Polars lazyframe, got: {type(native_object)}" raise TypeError(msg) return native_object @@ -721,7 +744,8 @@ def get_native_namespace(obj: Any) -> Any: def narwhalify( func: Callable[..., Any] | None = None, *, - strict: bool = False, + strict: bool | None = None, + pass_through: bool | None = None, eager_only: bool | None = False, eager_or_interchange_only: bool | None = False, series_only: bool | None = False, @@ -781,6 +805,11 @@ def func(df): series_only: Whether to only allow series. allow_series: Whether to allow series (default is only dataframe / lazyframe). """ + from narwhals.utils import validate_strict_and_pass_though + + pass_through = validate_strict_and_pass_though( + strict, pass_through, pass_through_default=True, emit_deprecation_warning=True + ) def decorator(func: Callable[..., Any]) -> Callable[..., Any]: @wraps(func) @@ -788,7 +817,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: args = [ from_native( arg, - strict=strict, + pass_through=pass_through, eager_only=eager_only, eager_or_interchange_only=eager_or_interchange_only, series_only=series_only, @@ -800,7 +829,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: kwargs = { name: from_native( value, - strict=strict, + pass_through=pass_through, eager_only=eager_only, eager_or_interchange_only=eager_or_interchange_only, series_only=series_only, @@ -821,7 +850,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: result = func(*args, **kwargs) - return to_native(result, strict=strict) + return to_native(result, pass_through=pass_through) return wrapper diff --git a/narwhals/utils.py b/narwhals/utils.py index 66c2badee..e538bee19 100644 --- a/narwhals/utils.py +++ b/narwhals/utils.py @@ -549,3 +549,84 @@ def parse_columns_to_drop( def is_sequence_but_not_str(sequence: Any) -> TypeGuard[Sequence[Any]]: return isinstance(sequence, Sequence) and not isinstance(sequence, str) + + +def find_stacklevel() -> int: + """ + Find the first place in the stack that is not inside narwhals. + + Taken from: + https://github.com/pandas-dev/pandas/blob/ab89c53f48df67709a533b6a95ce3d911871a0a8/pandas/util/_exceptions.py#L30-L51 + """ + import inspect + from pathlib import Path + + import narwhals as nw + + pkg_dir = str(Path(nw.__file__).parent) + + # https://stackoverflow.com/questions/17407119/python-inspect-stack-is-slow + frame = inspect.currentframe() + n = 0 + try: + while frame: + fname = inspect.getfile(frame) + if fname.startswith(pkg_dir) or ( + (qualname := getattr(frame.f_code, "co_qualname", None)) + # ignore @singledispatch wrappers + and qualname.startswith("singledispatch.") + ): + frame = frame.f_back + n += 1 + else: # pragma: no cover + break + else: # pragma: no cover + pass + finally: + # https://docs.python.org/3/library/inspect.html + # > Though the cycle detector will catch these, destruction of the frames + # > (and local variables) can be made deterministic by removing the cycle + # > in a finally clause. + del frame + return n + + +def issue_deprecation_warning(message: str, _version: str) -> None: + """ + Issue a deprecation warning. + + Parameters + ---------- + message + The message associated with the warning. + version + Narwhals version when the warning was introduced. Just used for internal + bookkeeping. + """ + warn(message=message, category=DeprecationWarning, stacklevel=find_stacklevel()) + + +def validate_strict_and_pass_though( + strict: bool | None, + pass_through: bool | None, + *, + pass_through_default: bool, + emit_deprecation_warning: bool, +) -> bool: + if strict is None and pass_through is None: + pass_through = pass_through_default + elif strict is not None and pass_through is None: + if emit_deprecation_warning: + msg = ( + "`strict` in `from_native` is deprecated, please use `pass_through` instead.\n\n" + "Note: `strict` will remain available in `narwhals.stable.v1`.\n" + "See https://narwhals-dev.github.io/narwhals/backcompat/ for more information.\n" + ) + issue_deprecation_warning(msg, _version="1.13.0") + pass_through = not strict + elif strict is None and pass_through is not None: + pass + else: + msg = "Cannot pass both `strict` and `pass_through`" + raise ValueError(msg) + return pass_through diff --git a/tests/translate/from_native_test.py b/tests/translate/from_native_test.py index e1fc4a59b..05c922503 100644 --- a/tests/translate/from_native_test.py +++ b/tests/translate/from_native_test.py @@ -226,9 +226,15 @@ def test_from_native_strict_false_typing() -> None: nw.from_native(df, strict=False, eager_only=True) nw.from_native(df, strict=False, eager_or_interchange_only=True) - unstable_nw.from_native(df, strict=False) - unstable_nw.from_native(df, strict=False, eager_only=True) - unstable_nw.from_native(df, strict=False, eager_or_interchange_only=True) + with pytest.deprecated_call(match="please use `pass_through` instead"): + unstable_nw.from_native(df, strict=False) # type: ignore[call-overload] + unstable_nw.from_native(df, strict=False, eager_only=True) # type: ignore[call-overload] + unstable_nw.from_native(df, strict=False, eager_or_interchange_only=True) # type: ignore[call-overload] + + +def test_from_native_strict_false_invalid() -> None: + with pytest.raises(ValueError, match="Cannot pass both `strict`"): + nw.from_native({"a": [1, 2, 3]}, strict=True, pass_through=False) # type: ignore[call-overload] def test_from_mock_interchange_protocol_non_strict() -> None: diff --git a/tests/translate/narwhalify_test.py b/tests/translate/narwhalify_test.py index 5c700a191..4dc346543 100644 --- a/tests/translate/narwhalify_test.py +++ b/tests/translate/narwhalify_test.py @@ -61,21 +61,23 @@ def func( def test_narwhalify_method_invalid() -> None: - class Foo: - @nw.narwhalify(strict=True, eager_only=True) - def func(self) -> Foo: # pragma: no cover - return self + with pytest.deprecated_call(match="please use `pass_through` instead"): - @nw.narwhalify(strict=True, eager_only=True) - def fun2(self, df: Any) -> Any: # pragma: no cover - return df + class Foo: + @nw.narwhalify(strict=True, eager_only=True) + def func(self) -> Foo: # pragma: no cover + return self - with pytest.raises(TypeError): - Foo().func() + @nw.narwhalify(strict=True, eager_only=True) + def fun2(self, df: Any) -> Any: # pragma: no cover + return df + + with pytest.raises(TypeError): + Foo().func() def test_narwhalify_invalid() -> None: - @nw.narwhalify(strict=True) + @nw.narwhalify(pass_through=False) def func() -> None: # pragma: no cover return None diff --git a/tests/translate/to_native_test.py b/tests/translate/to_native_test.py index 3d116a459..e832e73eb 100644 --- a/tests/translate/to_native_test.py +++ b/tests/translate/to_native_test.py @@ -13,27 +13,27 @@ @pytest.mark.parametrize( - ("method", "strict", "context"), + ("method", "pass_through", "context"), [ - ("head", True, does_not_raise()), ("head", False, does_not_raise()), - ("to_numpy", False, does_not_raise()), + ("head", True, does_not_raise()), + ("to_numpy", True, does_not_raise()), ( "to_numpy", - True, + False, pytest.raises(TypeError, match="Expected Narwhals object, got"), ), ], ) def test_to_native( - constructor_eager: ConstructorEager, method: str, strict: Any, context: Any + constructor_eager: ConstructorEager, method: str, *, pass_through: bool, context: Any ) -> None: df = nw.from_native(constructor_eager({"a": [1, 2, 3]})) with context: - nw.to_native(getattr(df, method)(), strict=strict) + nw.to_native(getattr(df, method)(), pass_through=pass_through) s = df["a"] with context: - nw.to_native(getattr(s, method)(), strict=strict) + nw.to_native(getattr(s, method)(), pass_through=pass_through)