From 096d21014b8a0a5e8e725bb09a36b27a956d3c2e Mon Sep 17 00:00:00 2001 From: Stefan Krawczyk Date: Mon, 1 Jul 2024 21:28:12 -0700 Subject: [PATCH] Adds stub of NarwhalsAdapter (#998) The purpose of this adapter is to showcase how you can write transforms that are agnostic of the dataframe type. Assumptions for this plugin: * you can only have one "backend"; you can't mix & match. That means you can't load some in pandas, and some in polars I don't think -- this is a narwhals limitation. * This change uses the narwhals decorator. This assumes that non pandas/polars stuff would be left alone by it. If not, we could just skip adding it if we don't detect a type. * This makes the user choose what the return result builder is and then requires them to nest it in the narwhals result builder that just converts the outputs to the backend that is being used. * I think this is a good enough integration to get out -- we'll likely tweak/add more functionality as feedback comes in. Squashed commits: * Adds stub of NarwhalsAdapter Assumptions narwhals has (I believe): 1. you can only have one "backend"; you can't mix & match. That means you can't load some in pandas, and some in polars I don't think. 2. This change uses the narwhals decorator. This assumes that non pandas/polars stuff would be left alone by it. If not, we could just skip adding it if we don't detect a type. Otherwise probably need a better example from narhwals. * Adds one attempt at a result builder This makes the user choose what the return type is and then requires them to nest it in the narwhals result builder that just converts the outputs to the backend that is being used. * Adds narwhals plugin v1 First version of narwhals support. * Completes Narwhals example Adds README and notebook so that people can run this example easily. Also adds circleci tests. * Adds missing dependency * Fixes polars test for polars 1.0+ * Adds narwhals to integration docs --- .ci/test.sh | 7 + .circleci/config.yml | 18 ++ docs/integrations/index.rst | 1 + examples/narwhals/README.md | 28 ++ examples/narwhals/example.png | Bin 0 -> 34790 bytes examples/narwhals/example.py | 70 +++++ examples/narwhals/notebook.ipynb | 340 +++++++++++++++++++++ examples/narwhals/requirements.txt | 4 + hamilton/plugins/h_narwhals.py | 62 ++++ plugin_tests/h_narwhals/__init__.py | 0 plugin_tests/h_narwhals/conftest.py | 4 + plugin_tests/h_narwhals/resources | 1 + plugin_tests/h_narwhals/test_h_narwhals.py | 50 +++ tests/resources/narwhals_example.py | 34 +++ 14 files changed, 619 insertions(+) create mode 100644 examples/narwhals/README.md create mode 100644 examples/narwhals/example.png create mode 100644 examples/narwhals/example.py create mode 100644 examples/narwhals/notebook.ipynb create mode 100644 examples/narwhals/requirements.txt create mode 100644 hamilton/plugins/h_narwhals.py create mode 100644 plugin_tests/h_narwhals/__init__.py create mode 100644 plugin_tests/h_narwhals/conftest.py create mode 120000 plugin_tests/h_narwhals/resources create mode 100644 plugin_tests/h_narwhals/test_h_narwhals.py create mode 100644 tests/resources/narwhals_example.py diff --git a/.ci/test.sh b/.ci/test.sh index ab5f13661..c71dfb1d8 100755 --- a/.ci/test.sh +++ b/.ci/test.sh @@ -51,6 +51,13 @@ if [[ ${TASK} == "vaex" ]]; then exit 0 fi +if [[ ${TASK} == "narwhals" ]]; then + pip install -e . + pip install polars pandas narwhals + pytest plugin_tests/h_narwhals + exit 0 +fi + if [[ ${TASK} == "tests" ]]; then pip install . pytest \ diff --git a/.circleci/config.yml b/.circleci/config.yml index dfa860cb8..36f667c9b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -155,3 +155,21 @@ workflows: name: integrations-py312 python-version: '3.12' task: integrations + - test: + requires: + - check_for_changes + name: narwhals-py39 + python-version: '3.9' + task: narwhals + - test: + requires: + - check_for_changes + name: narwhals-py310 + python-version: '3.10' + task: narwhals + - test: + requires: + - check_for_changes + name: narwhals-py311 + python-version: '3.11' + task: narwhals diff --git a/docs/integrations/index.rst b/docs/integrations/index.rst index 3f5c68d6e..5ef802111 100644 --- a/docs/integrations/index.rst +++ b/docs/integrations/index.rst @@ -26,3 +26,4 @@ This section showcases how Hamilton integrates with popular frameworks. Slack Spark Vaex + Narwhals diff --git a/examples/narwhals/README.md b/examples/narwhals/README.md new file mode 100644 index 000000000..12e2d148e --- /dev/null +++ b/examples/narwhals/README.md @@ -0,0 +1,28 @@ +# Narwhals + +[Narwhals](https://narwhals-dev.github.io/narwhals/) is a library that aims +to unify expression across dataframe libraries. It is meant to be lightweight +and focuses on python first dataframe libraries. + +This examples shows how you can write dataframe agnostic code +and then load up a pandas or polars data to then use with it. + +## Running the example + +You can run the example doing: + +```bash +# cd examples/narwhals/ +python example.py +``` +This will run both variants one after the other. + +or running the notebook: + +```bash +# cd examples/narwhals +jupyter notebook # pip install jupyter if you don't have it +``` +Or you can open up the notebook in Colab: + +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/dagworks-inc/hamilton/blob/main/examples/narwhals/notebook.ipynb) diff --git a/examples/narwhals/example.png b/examples/narwhals/example.png new file mode 100644 index 0000000000000000000000000000000000000000..765d0c2a48a46f2cce06a1a3a22a2b66418f8fc6 GIT binary patch literal 34790 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 literal 0 HcmV?d00001 diff --git a/examples/narwhals/example.py b/examples/narwhals/example.py new file mode 100644 index 000000000..2fb16b06e --- /dev/null +++ b/examples/narwhals/example.py @@ -0,0 +1,70 @@ +import narwhals as nw +import pandas as pd +import polars as pl + +from hamilton.function_modifiers import config, tag + + +@config.when(load="pandas") +def df__pandas() -> nw.DataFrame: + return pd.DataFrame({"a": [1, 1, 2, 2, 3], "b": [4, 5, 6, 7, 8]}) + + +@config.when(load="pandas") +def series__pandas() -> nw.Series: + return pd.Series([1, 3]) + + +@config.when(load="polars") +def df__polars() -> nw.DataFrame: + return pl.DataFrame({"a": [1, 1, 2, 2, 3], "b": [4, 5, 6, 7, 8]}) + + +@config.when(load="polars") +def series__polars() -> nw.Series: + return pl.Series([1, 3]) + + +@tag(nw_kwargs=["eager_only"]) +def example1(df: nw.DataFrame, series: nw.Series, col_name: str) -> int: + return df.filter(nw.col(col_name).is_in(series.to_numpy())).shape[0] + + +def group_by_mean(df: nw.DataFrame) -> nw.DataFrame: + return df.group_by("a").agg(nw.col("b").mean()).sort("a") + + +if __name__ == "__main__": + import __main__ as example + + from hamilton import base, driver + from hamilton.plugins import h_narwhals, h_polars + + # pandas + dr = ( + driver.Builder() + .with_config({"load": "pandas"}) + .with_modules(example) + .with_adapters( + h_narwhals.NarwhalsAdapter(), + h_narwhals.NarwhalsDataFrameResultBuilder(base.PandasDataFrameResult()), + ) + .build() + ) + r = dr.execute([example.group_by_mean, example.example1], inputs={"col_name": "a"}) + print(r) + + # polars + dr = ( + driver.Builder() + .with_config({"load": "polars"}) + .with_modules(example) + .with_adapters( + h_narwhals.NarwhalsAdapter(), + h_narwhals.NarwhalsDataFrameResultBuilder(h_polars.PolarsDataFrameResult()), + ) + .build() + ) + r = dr.execute([example.group_by_mean, example.example1], inputs={"col_name": "a"}) + print(r) + dr.display_all_functions("example.png") diff --git a/examples/narwhals/notebook.ipynb b/examples/narwhals/notebook.ipynb new file mode 100644 index 000000000..d6283a859 --- /dev/null +++ b/examples/narwhals/notebook.ipynb @@ -0,0 +1,340 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "initial_id", + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": "!pip install 'sf-hamilton[visualization]' pandas polars" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "# run me in google colab\n", + "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/dagworks-inc/hamilton/blob/main/examples/narwhals/notebook.ipynb)" + ], + "id": "ce17944a48a226a9" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2024-07-01T19:02:16.560492Z", + "start_time": "2024-07-01T19:02:06.001758Z" + } + }, + "cell_type": "code", + "source": "%load_ext hamilton.plugins.jupyter_magic", + "id": "a4897501e00ed4e2", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cannot import name 'PolarsDataType' from 'polars' (/Users/stefankrawczyk/.pyenv/versions/knowledge_retrieval-py39/lib/python3.9/site-packages/polars/__init__.py)\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/stefankrawczyk/.pyenv/versions/knowledge_retrieval-py39/lib/python3.9/site-packages/pyspark/pandas/__init__.py:50: UserWarning: 'PYARROW_IGNORE_TIMEZONE' environment variable was not set. It is required to set this environment variable to '1' in both driver and executor sides if you use pyarrow>=2.0.0. pandas-on-Spark will set it for you but it does not work if there is a Spark context already launched.\n", + " warnings.warn(\n" + ] + } + ], + "execution_count": 2 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2024-07-01T19:04:42.572389Z", + "start_time": "2024-07-01T19:04:42.567211Z" + } + }, + "cell_type": "code", + "source": [ + "config = {\n", + " \"mode\": \"pandas\"\n", + "}\n", + "from hamilton import driver\n", + "builder = driver.Builder()" + ], + "id": "6f0290a44e113076", + "outputs": [], + "execution_count": 8 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2024-07-01T19:06:01.729149Z", + "start_time": "2024-07-01T19:06:01.052739Z" + } + }, + "cell_type": "code", + "source": [ + "%%cell_to_module example --display --config '{\"mode\":\"pandas\"}'\n", + "\n", + "import narwhals as nw\n", + "import pandas as pd\n", + "import polars as pl\n", + "\n", + "from hamilton.function_modifiers import config, tag\n", + "\n", + "\n", + "@config.when(load=\"pandas\")\n", + "def df__pandas() -> nw.DataFrame:\n", + " return pd.DataFrame({\"a\": [1, 1, 2, 2, 3], \"b\": [4, 5, 6, 7, 8]})\n", + "\n", + "\n", + "@config.when(load=\"pandas\")\n", + "def series__pandas() -> nw.Series:\n", + " return pd.Series([1, 3])\n", + "\n", + "\n", + "@config.when(load=\"polars\")\n", + "def df__polars() -> nw.DataFrame:\n", + " return pl.DataFrame({\"a\": [1, 1, 2, 2, 3], \"b\": [4, 5, 6, 7, 8]})\n", + "\n", + "\n", + "@config.when(load=\"polars\")\n", + "def series__polars() -> nw.Series:\n", + " return pl.Series([1, 3])\n", + "\n", + "\n", + "@tag(nw_kwargs=[\"eager_only\"])\n", + "def example1(df: nw.DataFrame, series: nw.Series, col_name: str) -> int:\n", + " return df.filter(nw.col(col_name).is_in(series.to_numpy())).shape[0]\n", + "\n", + "\n", + "def group_by_mean(df: nw.DataFrame) -> nw.DataFrame:\n", + " return df.group_by(\"a\").agg(nw.col(\"b\").mean()).sort(\"a\")\n" + ], + "id": "5c57c8bad9d004cd", + "outputs": [ + { + "data": { + "image/svg+xml": "\n\n\n\n\n\n\n\ncluster__legend\n\nLegend\n\n\n\nmode\n\n\n\nmode\npandas\n\n\n\ngroup_by_mean\n\ngroup_by_mean\nDataFrame\n\n\n\nexample1\n\nexample1\nint\n\n\n\n_group_by_mean_inputs\n\ndf\nDataFrame\n\n\n\n_group_by_mean_inputs->group_by_mean\n\n\n\n\n\n_example1_inputs\n\nseries\nSeries\ncol_name\nstr\ndf\nDataFrame\n\n\n\n_example1_inputs->example1\n\n\n\n\n\ninput\n\ninput\n\n\n\nfunction\n\nfunction\n\n\n\n", + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "execution_count": 12 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2024-07-01T19:08:20.197966Z", + "start_time": "2024-07-01T19:08:20.151820Z" + } + }, + "cell_type": "code", + "source": [ + "from hamilton import base, driver\n", + "from hamilton.plugins import h_narwhals, h_polars\n", + "# pandas\n", + "dr = (\n", + " driver.Builder()\n", + " .with_config({\"load\": \"pandas\"})\n", + " .with_modules(example)\n", + " .with_adapters(\n", + " h_narwhals.NarwhalsAdapter(),\n", + " h_narwhals.NarwhalsDataFrameResultBuilder(base.PandasDataFrameResult()),\n", + " )\n", + " .build()\n", + ")\n", + "result = dr.execute([example.group_by_mean, example.example1], inputs={\"col_name\": \"a\"})\n", + "result" + ], + "id": "4ec491ce248b32ec", + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "WARNING: a single pandas index was found, but there are also 1 outputs without an index. Please check whether the dataframe created matches what what you expect to happen.\n" + ] + }, + { + "data": { + "text/plain": [ + " group_by_mean.a group_by_mean.b example1\n", + "0 1 4.5 3\n", + "1 2 6.5 3\n", + "2 3 8.0 3" + ], + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
group_by_mean.agroup_by_mean.bexample1
014.53
126.53
238.03
\n", + "
" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 18 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2024-07-01T19:08:25.361471Z", + "start_time": "2024-07-01T19:08:25.322417Z" + } + }, + "cell_type": "code", + "source": [ + "# polars\n", + "dr = (\n", + " driver.Builder()\n", + " .with_config({\"load\": \"polars\"})\n", + " .with_modules(example)\n", + " .with_adapters(\n", + " h_narwhals.NarwhalsAdapter(),\n", + " h_narwhals.NarwhalsDataFrameResultBuilder(h_polars.PolarsDataFrameResult()),\n", + " )\n", + " .build()\n", + ")\n", + "result= dr.execute([example.group_by_mean, example.example1], inputs={\"col_name\": \"a\"})\n", + "result" + ], + "id": "b9e65f6b29a58a5d", + "outputs": [ + { + "data": { + "text/plain": [ + "shape: (3, 2)\n", + "┌───────────────┬──────────┐\n", + "│ group_by_mean ┆ example1 │\n", + "│ --- ┆ --- │\n", + "│ struct[2] ┆ i32 │\n", + "╞═══════════════╪══════════╡\n", + "│ {1,4.5} ┆ 3 │\n", + "│ {2,6.5} ┆ 3 │\n", + "│ {3,8.0} ┆ 3 │\n", + "└───────────────┴──────────┘" + ], + "text/html": [ + "
\n", + "shape: (3, 2)
group_by_meanexample1
struct[2]i32
{1,4.5}3
{2,6.5}3
{3,8.0}3
" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 19 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2024-07-01T19:07:42.534409Z", + "start_time": "2024-07-01T19:07:41.961806Z" + } + }, + "cell_type": "code", + "source": "dr.display_all_functions()", + "id": "c17d7b45b69cee61", + "outputs": [ + { + "data": { + "image/svg+xml": "\n\n\n\n\n\n\n\ncluster__legend\n\nLegend\n\n\n\nload\n\n\n\nload\npolars\n\n\n\ngroup_by_mean\n\ngroup_by_mean\nDataFrame\n\n\n\nseries\n\nseries: load\nSeries\n\n\n\nexample1\n\nexample1\nint\n\n\n\nseries->example1\n\n\n\n\n\ndf\n\ndf: load\nDataFrame\n\n\n\ndf->group_by_mean\n\n\n\n\n\ndf->example1\n\n\n\n\n\n_example1_inputs\n\ncol_name\nstr\n\n\n\n_example1_inputs->example1\n\n\n\n\n\nconfig\n\n\n\nconfig\n\n\n\ninput\n\ninput\n\n\n\nfunction\n\nfunction\n\n\n\n", + "text/plain": [ + "" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 15 + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": "", + "id": "db8cb54bc64bbd27" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 2 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython2", + "version": "2.7.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/narwhals/requirements.txt b/examples/narwhals/requirements.txt new file mode 100644 index 000000000..4c5cbc75a --- /dev/null +++ b/examples/narwhals/requirements.txt @@ -0,0 +1,4 @@ +narwhals +pandas +polars +sf-hamilton[visualization] diff --git a/hamilton/plugins/h_narwhals.py b/hamilton/plugins/h_narwhals.py new file mode 100644 index 000000000..7274ae17e --- /dev/null +++ b/hamilton/plugins/h_narwhals.py @@ -0,0 +1,62 @@ +from typing import Any, Dict, Optional, Type, Union + +import narwhals as nw + +from hamilton.lifecycle import api + + +class NarwhalsAdapter(api.NodeExecutionMethod): + + def run_to_execute_node( + self, + *, + node_name: str, + node_tags: Dict[str, Any], + node_callable: Any, + node_kwargs: Dict[str, Any], + task_id: Optional[str], + **future_kwargs: Any, + ) -> Any: + """This method is responsible for executing the node and returning the result. + + :param node_name: Name of the node. + :param node_tags: Tags of the node. + :param node_callable: Callable of the node. + :param node_kwargs: Keyword arguments to pass to the node. + :param task_id: The ID of the task, none if not in a task-based environment + :param future_kwargs: Additional keyword arguments -- this is kept for backwards compatibility + :return: The result of the node execution -- up to you to return this. + """ + nw_kwargs = {} + if "nw_kwargs" in node_tags: + nw_kwargs = {k: True for k in node_tags["nw_kwargs"]} + nw_func = nw.narwhalify(node_callable, **nw_kwargs) + return nw_func(**node_kwargs) + + +class NarwhalsDataFrameResultBuilder(api.ResultBuilder): + """Builds the result. It unwraps the narwhals parts of it and delegates.""" + + def __init__(self, result_builder: Union[api.ResultBuilder, api.LegacyResultMixin]): + self.result_builder = result_builder + + def build_result(self, **outputs: Any) -> Any: + """Given a set of outputs, build the result. + + :param outputs: the outputs from the execution of the graph. + :return: the result of the execution of the graph. + """ + de_narwhaled_outputs = {} + for key, value in outputs.items(): + if isinstance(value, (nw.DataFrame, nw.Series)): + de_narwhaled_outputs[key] = nw.to_native(value) + else: + de_narwhaled_outputs[key] = value + + return self.result_builder.build_result(**de_narwhaled_outputs) + + def output_type(self) -> Type: + """Returns the output type of this result builder + :return: the type that this creates + """ + return self.result_builder.output_type() diff --git a/plugin_tests/h_narwhals/__init__.py b/plugin_tests/h_narwhals/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugin_tests/h_narwhals/conftest.py b/plugin_tests/h_narwhals/conftest.py new file mode 100644 index 000000000..bc5ef5b5a --- /dev/null +++ b/plugin_tests/h_narwhals/conftest.py @@ -0,0 +1,4 @@ +from hamilton import telemetry + +# disable telemetry for all tests! +telemetry.disable_telemetry() diff --git a/plugin_tests/h_narwhals/resources b/plugin_tests/h_narwhals/resources new file mode 120000 index 000000000..1e58ceb6d --- /dev/null +++ b/plugin_tests/h_narwhals/resources @@ -0,0 +1 @@ +../../tests/resources \ No newline at end of file diff --git a/plugin_tests/h_narwhals/test_h_narwhals.py b/plugin_tests/h_narwhals/test_h_narwhals.py new file mode 100644 index 000000000..adf588f6e --- /dev/null +++ b/plugin_tests/h_narwhals/test_h_narwhals.py @@ -0,0 +1,50 @@ +import json + +from hamilton import base, driver +from hamilton.plugins import h_narwhals, h_polars + +from .resources import narwhals_example + + +def test_pandas(): + # pandas + dr = ( + driver.Builder() + .with_config({"load": "pandas"}) + .with_modules(narwhals_example) + .with_adapters( + h_narwhals.NarwhalsAdapter(), + h_narwhals.NarwhalsDataFrameResultBuilder(base.PandasDataFrameResult()), + ) + .build() + ) + r = dr.execute( + [narwhals_example.group_by_mean, narwhals_example.example1], inputs={"col_name": "a"} + ) + assert r.to_dict() == { + "example1": {0: 3, 1: 3, 2: 3}, + "group_by_mean.a": {0: 1, 1: 2, 2: 3}, + "group_by_mean.b": {0: 4.5, 1: 6.5, 2: 8.0}, + } + + +def test_polars(): + # polars + dr = ( + driver.Builder() + .with_config({"load": "polars"}) + .with_modules(narwhals_example) + .with_adapters( + h_narwhals.NarwhalsAdapter(), + h_narwhals.NarwhalsDataFrameResultBuilder(h_polars.PolarsDataFrameResult()), + ) + .build() + ) + r = dr.execute( + [narwhals_example.group_by_mean, narwhals_example.example1], inputs={"col_name": "a"} + ) + assert json.loads(r.write_json()) == [ + {"example1": 3, "group_by_mean": {"a": 1, "b": 4.5}}, + {"example1": 3, "group_by_mean": {"a": 2, "b": 6.5}}, + {"example1": 3, "group_by_mean": {"a": 3, "b": 8.0}}, + ] diff --git a/tests/resources/narwhals_example.py b/tests/resources/narwhals_example.py new file mode 100644 index 000000000..e32037a20 --- /dev/null +++ b/tests/resources/narwhals_example.py @@ -0,0 +1,34 @@ +import narwhals as nw +import pandas as pd +import polars as pl + +from hamilton.function_modifiers import config, tag + + +@config.when(load="pandas") +def df__pandas() -> nw.DataFrame: + return pd.DataFrame({"a": [1, 1, 2, 2, 3], "b": [4, 5, 6, 7, 8]}) + + +@config.when(load="pandas") +def series__pandas() -> nw.Series: + return pd.Series([1, 3]) + + +@config.when(load="polars") +def df__polars() -> nw.DataFrame: + return pl.DataFrame({"a": [1, 1, 2, 2, 3], "b": [4, 5, 6, 7, 8]}) + + +@config.when(load="polars") +def series__polars() -> nw.Series: + return pl.Series([1, 3]) + + +@tag(nw_kwargs=["eager_only"]) +def example1(df: nw.DataFrame, series: nw.Series, col_name: str) -> int: + return df.filter(nw.col(col_name).is_in(series.to_numpy())).shape[0] + + +def group_by_mean(df: nw.DataFrame) -> nw.DataFrame: + return df.group_by("a").agg(nw.col("b").mean()).sort("a")