Repository: vega/altair
Pull request: refactor: Remove `toolz` dependency
This PR is a proof-of-concept, entirely* removing the hard dependency on `toolz`.
~~*Excluding usage in tests and twice for a deprecation warning.~~

I've tried to provide reasoning on each commit, but generally, it seemed to me that the behaviour `altair` needs is easily replicated within `stdlib`. 

### Additional Notes

- `pipe` and `curry` were the only imports
  - `pipe` is trivial to replace.
  - `curry` seems overkill when the only non-default argument is always `data`
    - Also has the drawback of [less type safety](https://github.com/pytoolz/toolz/issues/496)
- While this is documented under [Advanced Usage](https://altair-viz.github.io/user_guide/data_transformers.html#piping), I haven't come across any more complex examples.
  - I'd imagine users could see a benefit to performance, given the complexity of [curry](https://github.com/pytoolz/toolz/blob/7a0382e326eb2549e4dc7b833dc666a877feca38/toolz/functoolz.py#L168C5-L168C6).
  - If anyone has some sample code that heavily uses data transformers, it could be helpful to benchmark any changes.

If there was any desire to pursue this further, in b9dc070c30a23f09adac2d3686114ac3fc9c5cc9 I mentioned that this could be implemented as a decorator. 
I stopped short of that for now, but would be happy to go down that route to simplify the `@overload` usage. 

#### Edit
@mattijn is this worth opening an issue for discussion, or can that take place here?

AFAIK the last remaining quirks would be:
- [x] Handling the existing deprecation functions in [`bf99d72`](https://github.com/vega/altair/pull/3426/commits/bf99d725d0c607e9ee668928baf85ee96a9a285e).
  - I would assume 4 years is enough for a scheduled removal. 
  - Otherwise these could use proper optional imports (rather than the nesting solution I've tentatively used).
- [x] Updating `pyproject.toml`.
  - Not entirely sure on the interaction with existing dependency groups.
  - Just want to highlight it as an extra item to-do.

Problem statement:
Opt in or opt out on vegafusion dependencies
Opening this issue to discuss and collect insights.

Currently we have an altair package that relies on a few hard dependencies:

```yaml
dependencies = [
    "typing_extensions>=4.0.1; python_version<\"3.11\"",
    "jinja2",
    "jsonschema>=3.0",
    "numpy",
    "pandas>=0.25",
    "toolz",
    "packaging"
]
```
In parallel efforts we are trying to reduce these hard dependencies (like pandas, do we really need numpy?)

But simultaneously, with the recent developments around `vegafusion` in combination with vl-convert, it would be great to have it somehow installed as a hard dependency instead as the current soft dependency approach.

I see four options, but maybe there are more (from a conda perspective)
1. Do nothing, keep it as it is.
2. Extend `altair` with `vegafusion`, `anywidget` and `vl-convert-python` as hard dependencies.
3. Create an `altair-base` package which has the current minimal dependencies.
4. Create an `altair-vegafusion` package that includes `vegafusion`, `anywidget` and `vl-convert-python` as hard dependencies.

Option 3 enables an opt-out approach.
Option 4 enables an opt-in approach.

Topics for consideration are at least:
- WASM builds
- ease-of-use
- ease-of-installation

Hints from issue discussion:
Cross reference https://github.com/altair-viz/altair/issues/2818.  I think I would lean toward adding an extra_requires group for the optional dependencies, at least to start with as this would be a non-breaking change.

Eventually, I think the altair-base approach is worth considering. But this would need to correspond to a major version bump IMO.
I think it would be nice to use vegafusion and vl-convert more by default in altair, e.g. enabling the vegafusion transformer as the default transformer and offline mode by default for jupytercharts. I don't have a strong preference whether it is via an opt-out or opt-in mechanism, but leaning slightly towards that the default is with all batteries includes / how we believe most people would get the most out of the library.

If we decide to go that path, I wonder what the default behavior should be when these libraries are not installed, should it be that altair shows a warning and asks to install vegafusion or change the renderer to "html". Or should it silently fall back to the html renderer when it can't find vegafusion? The former sounds safer and would likely reduce some confusing edgecases. I agree that a change like that would be in a major version.
I agree that it's nice if users can get as much as possible out of Altair by default without (much) configuration. At the same time, `altair` is mentioned in many requirements files by now and changing the dependencies to include a lot more, even in a major version bump, might ruffle some feathers.

I see it as two linked but somewhat separate discussions:

# Simplifying installation
`extra_requires` groups solve this nicely in my opinion and I'd lean towards this instead of adding more dependencies to `altair` and creating an `altair-base`. It's the `pip` way to go to solve something like this and also taken by other libraries such as [pandas](https://pandas.pydata.org/docs/getting_started/install.html#optional-dependencies). There example also shows nicely that this better scales to more optional dependencies in the future. Maybe we want to include `avenger` at one point or `altair_ally`, `altair_tiles`, ... The dependency groups allow us to do:

* `altair`: What we have now
* `altair[all]`: All optional dependencies below
* `altair[save]`: Altair + vl-convert
* `altair[performance]`: Altair + VegaFusion, Avenger?
* `altair[maps]`: Altair + altair_tiles?
* `altair[ally]`: Altair + altair_ally?
* ...

Can be freely combined as `altair[save, performance]`.

# Enabling the magic of VegaFusion by default if it is installed
We could tackle this somewhat independently of the first topic. What do you think?

Related discussion: [`.to_dict/.to_json` have to return a Vega spec if VegaFusion is used](https://github.com/altair-viz/altair/pull/3094#pullrequestreview-1512045987)

Separating into two issues sounds good :+1: For the packaging part, I'm on board with using `extra_requires` and keeping `altair` as the base with `altair[all]` being all optional dependencies (and that we create a corresponding `altair-all` conda package).

Would the next step be to decide which package to include in `all`? I'm thinking maybe `vl-convert` and everything `vegafusion`-related to start; maybe also `vega_datasets`? And then consider adding the additional packages (tiles, ally, avenger)? What do you think?
For `all`, I'd recommend including at least:
 - `vega-dataset` (for all of our examples)
 - `vl-convert-python` (for image export, and other things)
 - `vegafusion-python-embed` and `vegafusion` (for `chart.transformed_data` and the `"vegafusion"` data transformer)
 - `anywidget` (for JupyterChart)
 
`vegafusion-jupyter` is going to be deprecated and won't be needed anymore with the integration of VegaFusion into JupyterChart. 

`avenger` isn't ready for prime-time yet.

I would defer to @joelostblom and @binste on whether to include `altair_tiles` and `altair_ally`, but I don't have any objection. Do either have any dependencies that might be problematic?
Good note on `anywidget`, I agree that should be included and your list looks great to me. I don't have any problematic dependencies in altair_ally at the moment, but I want to do some more work on the library before it is included in an official altair context; I'm planning to make time for it later in the spring.
`altair_tiles` only has 2 additional dependencies (`mercantile` and `xyzservices`) which are pure Python packages, both are well-maintained. So I think we could include it in `all` as well! I've been using it now for a while in production use cases and for single-view maps, it works. If we include it, I can also add a small example for it or some reference to it in the `geoshape` mark documentation.

I agree with your list for `all` @jonmmease.
Great, let's include `altair_tiles` then as well!  And yeah, it would be great to add some documentation for it directly in the main Altair docs.
Related to packaging, I PRed an altair recipe into the default pyodide image in https://github.com/pyodide/pyodide/pull/4580. The pyodide devs were very helpful and seem open to new packages being added, so let them know if there is anything else altair-related that you think should be in Pyodide by default.

Commit message: refactor(typing): Replace single-use `NonLikeDataType` alias with `Union`

See [review](https://github.com/vega/altair/pull/3426#discussion_r1638030144)
The name was not descriptive, and upon further thought, was less helpful than spelling out the type in full.

Patch to review:
diff --git a/altair/_magics.py b/altair/_magics.py
index bac190aa3..246ea2a1f 100644
--- a/altair/_magics.py
+++ b/altair/_magics.py
@@ -10,7 +10,6 @@
 import IPython
 from IPython.core import magic_arguments
 import pandas as pd
-from toolz import curried
 
 from altair.vegalite import v5 as vegalite_v5
 
@@ -41,7 +40,9 @@ def _prepare_data(data, data_transformers):
     if data is None or isinstance(data, dict):
         return data
     elif isinstance(data, pd.DataFrame):
-        return curried.pipe(data, data_transformers.get())
+        if func := data_transformers.get():
+            data = func(data)
+        return data
     elif isinstance(data, str):
         return {"url": data}
     else:
diff --git a/altair/utils/_importers.py b/altair/utils/_importers.py
index b7fa8a958..cc407a45d 100644
--- a/altair/utils/_importers.py
+++ b/altair/utils/_importers.py
@@ -1,6 +1,12 @@
+import warnings
+from importlib import import_module
+from importlib.metadata import version as importlib_version
 from types import ModuleType
+from typing import Any, Callable
+
 from packaging.version import Version
-from importlib.metadata import version as importlib_version
+
+from altair.utils.deprecation import AltairDeprecationWarning
 
 
 def import_vegafusion() -> ModuleType:
@@ -95,3 +101,24 @@ def pyarrow_available() -> bool:
         return True
     except (ImportError, RuntimeError):
         return False
+
+
+def import_toolz_function(
+    function: str, module: str = "toolz.curried"
+) -> Callable[..., Any]:
+    msg = (
+        f"alt.{function}() is deprecated, and will be removed in a future release. "
+        f"Use {module}.{function}() instead."
+    )
+    warnings.warn(msg, AltairDeprecationWarning, stacklevel=1)
+    msg_start = f"Usage of deprecated alt.{function}() requires {module}\n\n"
+    msg = f"{msg_start}ImportError: "
+    try:
+        toolz_curried = import_module(module)
+    except ImportError as err:
+        msg = f"{msg} {err.args[0]}"
+        raise ImportError(msg) from err
+    if func := getattr(toolz_curried, function, None):
+        return func
+    else:
+        raise ImportError(msg_start)
diff --git a/altair/utils/_vegafusion_data.py b/altair/utils/_vegafusion_data.py
index ce30e8d6d..32c36b44d 100644
--- a/altair/utils/_vegafusion_data.py
+++ b/altair/utils/_vegafusion_data.py
@@ -1,8 +1,9 @@
-from toolz import curried
+from __future__ import annotations
 import uuid
 from weakref import WeakValueDictionary
-
 from typing import (
+    Any,
+    Optional,
     Union,
     Dict,
     Set,
@@ -10,14 +11,22 @@
     TypedDict,
     Final,
     TYPE_CHECKING,
+    overload,
+    Callable,
 )
 
 from altair.utils._importers import import_vegafusion
 from altair.utils.core import DataFrameLike
-from altair.utils.data import DataType, ToValuesReturnType, MaxRowsError
+from altair.utils.data import (
+    DataType,
+    ToValuesReturnType,
+    MaxRowsError,
+    SupportsGeoInterface,
+)
 from altair.vegalite.data import default_data_transformer
 
 if TYPE_CHECKING:
+    import pandas as pd
     from vegafusion.runtime import ChartState  # type: ignore
 
 # Temporary storage for dataframes that have been extracted
@@ -36,21 +45,41 @@ class _ToVegaFusionReturnUrlDict(TypedDict):
     url: str
 
 
-@curried.curry
+_VegaFusionReturnType = Union[_ToVegaFusionReturnUrlDict, ToValuesReturnType]
+
+
+@overload
+def vegafusion_data_transformer(
+    data: None = ..., max_rows: int = ...
+) -> Callable[..., Any]: ...
+
+
+@overload
+def vegafusion_data_transformer(
+    data: DataFrameLike, max_rows: int
+) -> ToValuesReturnType: ...
+
+
+@overload
 def vegafusion_data_transformer(
-    data: DataType, max_rows: int = 100000
-) -> Union[_ToVegaFusionReturnUrlDict, ToValuesReturnType]:
+    data: Union[dict, pd.DataFrame, SupportsGeoInterface], max_rows: int
+) -> _VegaFusionReturnType: ...
+
+
+def vegafusion_data_transformer(
+    data: Optional[DataType] = None, max_rows: int = 100000
+) -> Union[Callable[..., Any], _VegaFusionReturnType]:
     """VegaFusion Data Transformer"""
-    if hasattr(data, "__geo_interface__"):
-        # Use default transformer for geo interface objects
-        # # (e.g. a geopandas GeoDataFrame)
-        return default_data_transformer(data)
+    if data is None:
+        return vegafusion_data_transformer
     elif isinstance(data, DataFrameLike):
         table_name = f"table_{uuid.uuid4()}".replace("-", "_")
         extracted_inline_tables[table_name] = data
         return {"url": VEGAFUSION_PREFIX + table_name}
     else:
-        # Use default transformer if we don't recognize data type
+        # Use default transformer for geo interface objects
+        # # (e.g. a geopandas GeoDataFrame)
+        # Or if we don't recognize data type
         return default_data_transformer(data)
 
 
diff --git a/altair/utils/data.py b/altair/utils/data.py
index 871b43092..342967e06 100644
--- a/altair/utils/data.py
+++ b/altair/utils/data.py
@@ -1,37 +1,60 @@
+from functools import partial
 import json
-import os
 import random
 import hashlib
-import warnings
-from typing import Union, MutableMapping, Optional, Dict, Sequence, TYPE_CHECKING, List
+import sys
+from pathlib import Path
+from typing import (
+    Union,
+    MutableMapping,
+    Optional,
+    Dict,
+    Sequence,
+    TYPE_CHECKING,
+    List,
+    TypeVar,
+    Protocol,
+    TypedDict,
+    Literal,
+    overload,
+    runtime_checkable,
+    Any,
+)
 
 import pandas as pd
-from toolz import curried
-from typing import TypeVar
 
-from ._importers import import_pyarrow_interchange
+from ._importers import import_pyarrow_interchange, import_toolz_function
 from .core import sanitize_dataframe, sanitize_arrow_table, DataFrameLike
 from .core import sanitize_geo_interface
-from .deprecation import AltairDeprecationWarning
 from .plugin_registry import PluginRegistry
 
-
-from typing import Protocol, TypedDict, Literal
-
+if sys.version_info >= (3, 13):
+    from typing import TypeIs
+else:
+    from typing_extensions import TypeIs
 
 if TYPE_CHECKING:
     import pyarrow.lib
 
 
+@runtime_checkable
 class SupportsGeoInterface(Protocol):
     __geo_interface__: MutableMapping
 
 
 DataType = Union[dict, pd.DataFrame, SupportsGeoInterface, DataFrameLike]
 TDataType = TypeVar("TDataType", bound=DataType)
+NonGeoDataType = Union[dict, pd.DataFrame, DataFrameLike]
 
 VegaLiteDataDict = Dict[str, Union[str, dict, List[dict]]]
 ToValuesReturnType = Dict[str, Union[dict, List[dict]]]
+SampleReturnType = Optional[
+    Union[pd.DataFrame, Dict[str, Sequence], "pyarrow.lib.Table"]
+]
+
+
+def is_data_type(obj: Any) -> TypeIs[DataType]:
+    return isinstance(obj, (dict, pd.DataFrame, DataFrameLike, SupportsGeoInterface))
 
 
 # ==============================================================================
@@ -46,8 +69,13 @@ class SupportsGeoInterface(Protocol):
 # form.
 # ==============================================================================
 class DataTransformerType(Protocol):
-    def __call__(self, data: DataType, **kwargs) -> VegaLiteDataDict:
-        pass
+    @overload
+    def __call__(self, data: None = None, **kwargs) -> "DataTransformerType": ...
+    @overload
+    def __call__(self, data: DataType, **kwargs) -> VegaLiteDataDict: ...
+    def __call__(
+        self, data: Optional[DataType] = None, **kwargs
+    ) -> Union["DataTransformerType", VegaLiteDataDict]: ...
 
 
 class DataTransformerRegistry(PluginRegistry[DataTransformerType]):
@@ -69,12 +97,19 @@ class MaxRowsError(Exception):
     pass
 
 
-@curried.curry
-def limit_rows(data: TDataType, max_rows: Optional[int] = 5000) -> TDataType:
+@overload
+def limit_rows(data: None = ..., max_rows: Optional[int] = ...) -> partial: ...
+@overload
+def limit_rows(data: DataType, max_rows: Optional[int]) -> DataType: ...
+def limit_rows(
+    data: Optional[DataType] = None, max_rows: Optional[int] = 5000
+) -> Union[partial, DataType]:
     """Raise MaxRowsError if the data model has more than max_rows.
 
     If max_rows is None, then do not perform any check.
     """
+    if data is None:
+        return partial(limit_rows, max_rows=max_rows)
     check_data_type(data)
 
     def raise_max_rows_error():
@@ -91,7 +126,7 @@ def raise_max_rows_error():
             "on how to plot large datasets."
         )
 
-    if hasattr(data, "__geo_interface__"):
+    if isinstance(data, SupportsGeoInterface):
         if data.__geo_interface__["type"] == "FeatureCollection":
             values = data.__geo_interface__["features"]
         else:
@@ -102,9 +137,7 @@ def raise_max_rows_error():
         if "values" in data:
             values = data["values"]
         else:
-            # mypy gets confused as it doesn't see Dict[Any, Any]
-            # as equivalent to TDataType
-            return data  # type: ignore[return-value]
+            return data
     elif isinstance(data, DataFrameLike):
         pa_table = arrow_table_from_dfi_dataframe(data)
         if max_rows is not None and pa_table.num_rows > max_rows:
@@ -119,11 +152,22 @@ def raise_max_rows_error():
     return data
 
 
-@curried.curry
+@overload
+def sample(
+    data: None = ..., n: Optional[int] = ..., frac: Optional[float] = ...
+) -> partial: ...
+@overload
+def sample(
+    data: DataType, n: Optional[int], frac: Optional[float]
+) -> SampleReturnType: ...
 def sample(
-    data: DataType, n: Optional[int] = None, frac: Optional[float] = None
-) -> Optional[Union[pd.DataFrame, Dict[str, Sequence], "pyarrow.lib.Table"]]:
+    data: Optional[DataType] = None,
+    n: Optional[int] = None,
+    frac: Optional[float] = None,
+) -> Union[partial, SampleReturnType]:
     """Reduce the size of the data model by sampling without replacement."""
+    if data is None:
+        return partial(sample, n=n, frac=frac)
     check_data_type(data)
     if isinstance(data, pd.DataFrame):
         return data.sample(n=n, frac=frac)
@@ -157,65 +201,115 @@ def sample(
         return None
 
 
-class _JsonFormatDict(TypedDict):
-    type: Literal["json"]
+_FormatType = Literal["csv", "json"]
 
 
-class _CsvFormatDict(TypedDict):
-    type: Literal["csv"]
+class _FormatDict(TypedDict):
+    type: _FormatType
 
 
-class _ToJsonReturnUrlDict(TypedDict):
+class _ToFormatReturnUrlDict(TypedDict):
     url: str
-    format: _JsonFormatDict
+    format: _FormatDict
 
 
-class _ToCsvReturnUrlDict(TypedDict):
-    url: str
-    format: _CsvFormatDict
+@overload
+def to_json(
+    data: None = ...,
+    prefix: str = ...,
+    extension: str = ...,
+    filename: str = ...,
+    urlpath: str = ...,
+) -> partial: ...
 
 
-@curried.curry
+@overload
 def to_json(
     data: DataType,
+    prefix: str,
+    extension: str,
+    filename: str,
+    urlpath: str,
+) -> _ToFormatReturnUrlDict: ...
+
+
+def to_json(
+    data: Optional[DataType] = None,
     prefix: str = "altair-data",
     extension: str = "json",
     filename: str = "{prefix}-{hash}.{extension}",
     urlpath: str = "",
-) -> _ToJsonReturnUrlDict:
+) -> Union[partial, _ToFormatReturnUrlDict]:
     """
     Write the data model to a .json file and return a url based data model.
     """
-    data_json = _data_to_json_string(data)
-    data_hash = _compute_data_hash(data_json)
-    filename = filename.format(prefix=prefix, hash=data_hash, extension=extension)
-    with open(filename, "w") as f:
-        f.write(data_json)
-    return {"url": os.path.join(urlpath, filename), "format": {"type": "json"}}
+    kwds = _to_text_kwds(prefix, extension, filename, urlpath)
+    if data is None:
+        return partial(to_json, **kwds)
+    else:
+        data_str = _data_to_json_string(data)
+        return _to_text(data_str, **kwds, format=_FormatDict(type="json"))
+
+
+@overload
+def to_csv(
+    data: None = ...,
+    prefix: str = ...,
+    extension: str = ...,
+    filename: str = ...,
+    urlpath: str = ...,
+) -> partial: ...
 
 
-@curried.curry
+@overload
 def to_csv(
-    data: Union[dict, pd.DataFrame, DataFrameLike],
+    data: NonGeoDataType,
+    prefix: str,
+    extension: str,
+    filename: str,
+    urlpath: str,
+) -> _ToFormatReturnUrlDict: ...
+
+
+def to_csv(
+    data: Optional[NonGeoDataType] = None,
     prefix: str = "altair-data",
     extension: str = "csv",
     filename: str = "{prefix}-{hash}.{extension}",
     urlpath: str = "",
-) -> _ToCsvReturnUrlDict:
+) -> Union[partial, _ToFormatReturnUrlDict]:
     """Write the data model to a .csv file and return a url based data model."""
-    data_csv = _data_to_csv_string(data)
-    data_hash = _compute_data_hash(data_csv)
+    kwds = _to_text_kwds(prefix, extension, filename, urlpath)
+    if data is None:
+        return partial(to_csv, **kwds)
+    else:
+        data_str = _data_to_csv_string(data)
+        return _to_text(data_str, **kwds, format=_FormatDict(type="csv"))
+
+
+def _to_text(
+    data: str,
+    prefix: str,
+    extension: str,
+    filename: str,
+    urlpath: str,
+    format: _FormatDict,
+) -> _ToFormatReturnUrlDict:
+    data_hash = _compute_data_hash(data)
     filename = filename.format(prefix=prefix, hash=data_hash, extension=extension)
-    with open(filename, "w") as f:
-        f.write(data_csv)
-    return {"url": os.path.join(urlpath, filename), "format": {"type": "csv"}}
+    Path(filename).write_text(data)
+    url = str(Path(urlpath, filename))
+    return _ToFormatReturnUrlDict({"url": url, "format": format})
+
+
+def _to_text_kwds(prefix: str, extension: str, filename: str, urlpath: str, /) -> Dict[str, str]:  # fmt: skip
+    return {"prefix": prefix, "extension": extension, "filename": filename, "urlpath": urlpath}  # fmt: skip
 
 
-@curried.curry
 def to_values(data: DataType) -> ToValuesReturnType:
     """Replace a DataFrame by a data model with values."""
     check_data_type(data)
-    if hasattr(data, "__geo_interface__"):
+    if isinstance(data, SupportsGeoInterface):
         if isinstance(data, pd.DataFrame):
             data = sanitize_dataframe(data)
         # Maybe the type could be further clarified here that it is
@@ -238,9 +332,7 @@ def to_values(data: DataType) -> ToValuesReturnType:
 
 
 def check_data_type(data: DataType) -> None:
-    if not isinstance(data, (dict, pd.DataFrame, DataFrameLike)) and not any(
-        hasattr(data, attr) for attr in ["__geo_interface__"]
-    ):
+    if not is_data_type(data):
         raise TypeError(
             "Expected dict, DataFrame or a __geo_interface__ attribute, got: {}".format(
                 type(data)
@@ -258,7 +350,7 @@ def _compute_data_hash(data_str: str) -> str:
 def _data_to_json_string(data: DataType) -> str:
     """Return a JSON string representation of the input data"""
     check_data_type(data)
-    if hasattr(data, "__geo_interface__"):
+    if isinstance(data, SupportsGeoInterface):
         if isinstance(data, pd.DataFrame):
             data = sanitize_dataframe(data)
         # Maybe the type could be further clarified here that it is
@@ -284,7 +376,7 @@ def _data_to_json_string(data: DataType) -> str:
 def _data_to_csv_string(data: Union[dict, pd.DataFrame, DataFrameLike]) -> str:
     """return a CSV string representation of the input data"""
     check_data_type(data)
-    if hasattr(data, "__geo_interface__"):
+    if isinstance(data, SupportsGeoInterface):
         raise NotImplementedError(
             "to_csv does not work with data that "
             "contains the __geo_interface__ attribute"
@@ -317,13 +409,7 @@ def pipe(data, *funcs):
 
     Deprecated: use toolz.curried.pipe() instead.
     """
-    warnings.warn(
-        "alt.pipe() is deprecated, and will be removed in a future release. "
-        "Use toolz.curried.pipe() instead.",
-        AltairDeprecationWarning,
-        stacklevel=1,
-    )
-    return curried.pipe(data, *funcs)
+    return import_toolz_function("pipe")(data, *funcs)
 
 
 def curry(*args, **kwargs):
@@ -331,13 +417,7 @@ def curry(*args, **kwargs):
 
     Deprecated: use toolz.curried.curry() instead.
     """
-    warnings.warn(
-        "alt.curry() is deprecated, and will be removed in a future release. "
-        "Use toolz.curried.curry() instead.",
-        AltairDeprecationWarning,
-        stacklevel=1,
-    )
-    return curried.curry(*args, **kwargs)
+    return import_toolz_function("curry")(*args, **kwargs)
 
 
 def arrow_table_from_dfi_dataframe(dfi_df: DataFrameLike) -> "pyarrow.lib.Table":
diff --git a/altair/utils/plugin_registry.py b/altair/utils/plugin_registry.py
index f6281ed14..b1cce92ec 100644
--- a/altair/utils/plugin_registry.py
+++ b/altair/utils/plugin_registry.py
@@ -1,10 +1,8 @@
-from typing import Any, Dict, List, Optional, Generic, TypeVar, cast
+from functools import partial
+from typing import Any, Dict, List, Optional, Generic, TypeVar, Union, cast, Callable
 from types import TracebackType
-
 from importlib.metadata import entry_points
 
-from toolz import curry
-
 
 PluginType = TypeVar("PluginType")
 
@@ -71,7 +69,7 @@ class PluginRegistry(Generic[PluginType]):
     # in the registry rather than passed to the plugins
     _global_settings = {}  # type: Dict[str, Any]
 
-    def __init__(self, entry_point_group: str = "", plugin_type: type = object):
+    def __init__(self, entry_point_group: str = "", plugin_type: type = Callable):  # type: ignore[assignment]
         """Create a PluginRegistry for a named entry point group.
 
         Parameters
@@ -90,7 +88,9 @@ def __init__(self, entry_point_group: str = "", plugin_type: type = object):
         self._options = {}  # type: Dict[str, Any]
         self._global_settings = self.__class__._global_settings.copy()  # type: dict
 
-    def register(self, name: str, value: Optional[PluginType]) -> Optional[PluginType]:
+    def register(
+        self, name: str, value: Union[Optional[PluginType], Any]
+    ) -> Optional[PluginType]:
         """Register a plugin by name and value.
 
         This method is used for explicit registration of a plugin and shouldn't be
@@ -199,10 +199,15 @@ def options(self) -> Dict[str, Any]:
         """Return the current options dictionary"""
         return self._options
 
-    def get(self) -> Optional[PluginType]:
+    def get(self) -> Optional[Union[PluginType, Callable[..., Any]]]:
         """Return the currently active plugin."""
         if self._options:
-            return curry(self._active, **self._options)
+            if func := self._active:
+                # NOTE: Fully do not understand this one
+                # error: Argument 1 to "partial" has incompatible type "PluginType"; expected "Callable[..., Never]"
+                return partial(func, **self._options)  # type: ignore[arg-type]
+            else:
+                raise TypeError("Unclear what this meant by passing to curry.")
         else:
             return self._active
 
diff --git a/altair/vegalite/data.py b/altair/vegalite/data.py
index fbeda0fee..84f30fbaa 100644
--- a/altair/vegalite/data.py
+++ b/altair/vegalite/data.py
@@ -1,4 +1,3 @@
-from toolz import curried
 from ..utils.core import sanitize_dataframe
 from ..utils.data import (
     MaxRowsError,
@@ -14,13 +13,30 @@
 from ..utils.data import DataTransformerRegistry as _DataTransformerRegistry
 from ..utils.data import DataType, ToValuesReturnType
 from ..utils.plugin_registry import PluginEnabler
+from typing import Optional, Union, overload, Callable
 
 
-@curried.curry
+@overload
 def default_data_transformer(
-    data: DataType, max_rows: int = 5000
-) -> ToValuesReturnType:
-    return curried.pipe(data, limit_rows(max_rows=max_rows), to_values)
+    data: None = ..., max_rows: int = ...
+) -> Callable[[DataType], ToValuesReturnType]: ...
+@overload
+def default_data_transformer(
+    data: DataType, max_rows: int = ...
+) -> ToValuesReturnType: ...
+def default_data_transformer(
+    data: Optional[DataType] = None, max_rows: int = 5000
+) -> Union[Callable[[DataType], ToValuesReturnType], ToValuesReturnType]:
+    if data is None:
+
+        def pipe(data: DataType, /) -> ToValuesReturnType:
+            data = limit_rows(data, max_rows=max_rows)
+            return to_values(data)
+
+        return pipe
+
+    else:
+        return to_values(limit_rows(data, max_rows=max_rows))
 
 
 class DataTransformerRegistry(_DataTransformerRegistry):
diff --git a/altair/vegalite/v5/api.py b/altair/vegalite/v5/api.py
index 37a13c2ae..6425c77fe 100644
--- a/altair/vegalite/v5/api.py
+++ b/altair/vegalite/v5/api.py
@@ -4,8 +4,6 @@
 import io
 import json
 import jsonschema
-import pandas as pd
-from toolz.curried import pipe as _pipe
 import itertools
 import sys
 import pathlib
@@ -29,7 +27,7 @@
     compile_with_vegafusion as _compile_with_vegafusion,
 )
 from ...utils.core import DataFrameLike
-from ...utils.data import DataType
+from ...utils.data import DataType, is_data_type as _is_data_type
 from ...utils.deprecation import AltairDeprecationWarning
 
 if sys.version_info >= (3, 13):
@@ -114,16 +112,14 @@ def _prepare_data(data, context=None):
         return data
 
     # convert dataframes  or objects with __geo_interface__ to dict
-    elif isinstance(data, pd.DataFrame) or hasattr(data, "__geo_interface__"):
-        data = _pipe(data, data_transformers.get())
+    elif not isinstance(data, dict) and _is_data_type(data):
+        if func := data_transformers.get():
+            data = func(data)
 
     # convert string input to a URLData
     elif isinstance(data, str):
         data = core.UrlData(data)
 
-    elif isinstance(data, DataFrameLike):
-        data = _pipe(data, data_transformers.get())
-
     # consolidate inline data to top-level datasets
     if context is not None and data_transformers.consolidate_datasets:
         data = _consolidate_data(data, context)
diff --git a/pyproject.toml b/pyproject.toml
index a7ad687f7..6196d4790 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,7 +22,6 @@ dependencies = [
     "numpy",
     # If you update the minimum required pandas version, also update it in build.yml
     "pandas>=0.25",
-    "toolz",
     "packaging"
 ]
 description = "Vega-Altair: A declarative statistical visualization library for Python."
diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py
index 0d746e79d..e37cb402f 100644
--- a/tests/utils/test_data.py
+++ b/tests/utils/test_data.py
@@ -1,9 +1,8 @@
 import os
-
+import re
+from typing import Any, Callable
 import pytest
 import pandas as pd
-from toolz import pipe
-
 from altair.utils.data import (
     limit_rows,
     MaxRowsError,
@@ -11,7 +10,19 @@
     to_values,
     to_json,
     to_csv,
+    curry,
+    pipe,
 )
+from altair.utils._importers import import_toolz_function
+from altair.utils.deprecation import AltairDeprecationWarning
+
+
+def _pipe(data: Any, *funcs: Callable[..., Any]) -> Any:
+    # Redefined to maintain existing tests
+    # Originally part of `toolz` dependency
+    for func in funcs:
+        data = func(data)
+    return data
 
 
 def _create_dataframe(N):
@@ -30,9 +41,9 @@ def test_limit_rows():
     result = limit_rows(data, max_rows=20)
     assert data is result
     with pytest.raises(MaxRowsError):
-        pipe(data, limit_rows(max_rows=5))
+        _pipe(data, limit_rows(max_rows=5))
     data = _create_data_with_values(10)
-    result = pipe(data, limit_rows(max_rows=20))
+    result = _pipe(data, limit_rows(max_rows=20))
     assert data is result
     with pytest.raises(MaxRowsError):
         limit_rows(data, max_rows=5)
@@ -41,7 +52,7 @@ def test_limit_rows():
 def test_sample():
     """Test the sample data transformer."""
     data = _create_dataframe(20)
-    result = pipe(data, sample(n=10))
+    result = _pipe(data, sample(n=10))
     assert len(result) == 10
     assert isinstance(result, pd.DataFrame)
     data = _create_data_with_values(20)
@@ -50,7 +61,7 @@ def test_sample():
     assert "values" in result
     assert len(result["values"]) == 10
     data = _create_dataframe(20)
-    result = pipe(data, sample(frac=0.5))
+    result = _pipe(data, sample(frac=0.5))
     assert len(result) == 10
     assert isinstance(result, pd.DataFrame)
     data = _create_data_with_values(20)
@@ -63,7 +74,7 @@ def test_sample():
 def test_to_values():
     """Test the to_values data transformer."""
     data = _create_dataframe(10)
-    result = pipe(data, to_values)
+    result = _pipe(data, to_values)
     assert result == {"values": data.to_dict(orient="records")}
 
 
@@ -71,7 +82,7 @@ def test_type_error():
     """Ensure that TypeError is raised for types other than dict/DataFrame."""
     for f in (sample, limit_rows, to_values):
         with pytest.raises(TypeError):
-            pipe(0, f)
+            _pipe(0, f)
 
 
 def test_dataframe_to_json():
@@ -81,8 +92,8 @@ def test_dataframe_to_json():
     """
     data = _create_dataframe(10)
     try:
-        result1 = pipe(data, to_json)
-        result2 = pipe(data, to_json)
+        result1 = _pipe(data, to_json)
+        result2 = _pipe(data, to_json)
         filename = result1["url"]
         output = pd.read_json(filename)
     finally:
@@ -99,8 +110,8 @@ def test_dict_to_json():
     """
     data = _create_data_with_values(10)
     try:
-        result1 = pipe(data, to_json)
-        result2 = pipe(data, to_json)
+        result1 = _pipe(data, to_json)
+        result2 = _pipe(data, to_json)
         filename = result1["url"]
         output = pd.read_json(filename).to_dict(orient="records")
     finally:
@@ -117,8 +128,8 @@ def test_dataframe_to_csv():
     """
     data = _create_dataframe(10)
     try:
-        result1 = pipe(data, to_csv)
-        result2 = pipe(data, to_csv)
+        result1 = _pipe(data, to_csv)
+        result2 = _pipe(data, to_csv)
         filename = result1["url"]
         output = pd.read_csv(filename)
     finally:
@@ -135,8 +146,8 @@ def test_dict_to_csv():
     """
     data = _create_data_with_values(10)
     try:
-        result1 = pipe(data, to_csv)
-        result2 = pipe(data, to_csv)
+        result1 = _pipe(data, to_csv)
+        result2 = _pipe(data, to_csv)
         filename = result1["url"]
         output = pd.read_csv(filename).to_dict(orient="records")
     finally:
@@ -144,3 +155,24 @@ def test_dict_to_csv():
 
     assert result1 == result2
     assert data == {"values": output}
+
+
+def test_toolz():
+    expected_msg = r"Usage.+ requires"
+    data = _create_data_with_values(10)
+    try:
+        with pytest.warns(AltairDeprecationWarning, match="toolz.curried.pipe"):
+            result1 = pipe(data, to_values)
+        assert isinstance(result1, dict)
+        kwds = {"prefix": "dummy"}
+        with pytest.warns(AltairDeprecationWarning, match="toolz.curried.curry"):
+            result2 = curry(to_csv, **kwds)
+        assert "curry" in type(result2).__name__
+        assert result2.func_name == to_csv.__name__
+        assert result2.keywords == kwds
+    except ImportError as err:
+        assert re.search(expected_msg, err.msg)
+    with pytest.raises(ImportError, match=expected_msg):
+        dummy = "fake_function_name"
+        with pytest.warns(AltairDeprecationWarning, match=f"toolz.curried.{dummy}"):
+            func = import_toolz_function(dummy)  # noqa: F841


Code review comments:
diff_hunk: @@ -135,12 +146,33 @@ def test_dict_to_csv():
     """
     data = _create_data_with_values(10)
     try:
-        result1 = pipe(data, to_csv)
-        result2 = pipe(data, to_csv)
+        result1 = _pipe(data, to_csv)
+        result2 = _pipe(data, to_csv)
         filename = result1["url"]
         output = pd.read_csv(filename).to_dict(orient="records")
     finally:
         os.remove(filename)
 
     assert result1 == result2
     assert data == {"values": output}
+
+
+def test_toolz():
path: tests/utils/test_data.py
text: @author:
Marked for removal: `tests.utils.test_data.test_toolz`

Dependent on resolution to comment in https://github.com/vega/altair/pull/3426/files#r1639506036

diff_hunk: @@ -317,27 +409,15 @@ def pipe(data, *funcs):
 
     Deprecated: use toolz.curried.pipe() instead.
     """
-    warnings.warn(
-        "alt.pipe() is deprecated, and will be removed in a future release. "
-        "Use toolz.curried.pipe() instead.",
-        AltairDeprecationWarning,
-        stacklevel=1,
-    )
-    return curried.pipe(data, *funcs)
+    return import_toolz_function("pipe")(data, *funcs)
 
 
 def curry(*args, **kwargs):
path: altair/utils/data.py
text: @author:
Marked for removal: `altair.utils.data.curry`

diff_hunk: @@ -95,3 +101,24 @@ def pyarrow_available() -> bool:
         return True
     except (ImportError, RuntimeError):
         return False
+
+
+def import_toolz_function(
path: altair/utils/_importers.py
text: @author:
Marked for removal: `altair.utils._importers.import_toolz_function`.

Alternatively, could be made generic, if this functionality is useful for other modules/packages?

@user2:
I don't know if this is useful for any other module, @user1 do you know?

@user1:
Nope. No idea. 

@author:
Realising now that comment hasn't highlighted the contents of the function. 

Also the deprecation part might be distracting from the part that could be useful.
```py
...
try:
    module = import_module("toolz.curried")
except ImportError as err:
    msg = f"{msg} {err.args[0]}"
    raise ImportError(msg) from err
...
``` 

The other functions in `_importers`:
- `import_pyarrow_interchange`
- `import_vl_convert`
- `import_vegafusion`

They all do fairly similar things, although based on version constraints and use regular imports, like `import vl_convert as vlc` instead of `import_module("vl_convert")`:

<details>
<summary>import_vl_convert</summary>

```py
def import_vl_convert() -> ModuleType:
    min_version = "1.3.0"
    try:
        version = importlib_version("vl-convert-python")
        if Version(version) < Version(min_version):
            raise RuntimeError(
                f"The vl-convert-python package must be version {min_version} or greater. "
                f"Found version {version}"
            )
        import vl_convert as vlc

        return vlc
    except ImportError as err:
        raise ImportError(
            f"The vl-convert Vega-Lite compiler and file export feature requires\n"
            f"version {min_version} or greater of the 'vl-convert-python' package. \n"
            f"This can be installed with pip using:\n"
            f'   pip install "vl-convert-python>={min_version}"\n'
            "or conda:\n"
            f'   conda install -c conda-forge "vl-convert-python>={min_version}"\n\n'
            f"ImportError: {err.args[0]}"
        ) from err
```

</details>


So rather than having a **dedicated function per import** to maintain and keep consistent - you could do something like below. 
I've taken this from my own code I was working on during #3420, as it shows a larger number of optional dependencies: 

```py
from types import ModuleType
from typing import NamedTuple

class ModuleAvailable(NamedTuple):
    module: ModuleType | None = None
    available: bool = True


_init_modules = (
    "msgspec",
    "tomli_w",
    "aiofiles",
    "xlsxwriter",
    "plotly",
    "altair",
    "lxml",
    "ftfy",
    "playwright",
    "keyring",
)
_available_modules: dict[str, ModuleAvailable] = {
    name: ModuleAvailable() for name in _init_modules
}

def import_optional(
    module_name: str,
    /,
    scope: _UserDefinedScope | str | Literal["ignore"],
    extra: str = "",
) -> ModuleType:
    if item := _available_modules.get(module_name):
        if module := item.module:
            return module
        elif not item.available:
            msg = _optional_module_msg(scope, module_name, extra) # defined elsewhere
            raise ModuleNotFoundError(msg)
    return _import(module_name, scope, extra)

def _import(
    module_name: str, /, scope: _UserDefinedScope | str, extra: str
) -> ModuleType:
    global _available_modules
    lib_name = module_name.split(".", 1)[0]
    try:
        module = import_module(module_name)
        _available_modules[module_name] = ModuleAvailable(module=module)
        if module_name != lib_name:
            _available_modules[lib_name] = ModuleAvailable(module=sys.modules[lib_name])
    except ImportError:
        unavailable = ModuleAvailable(available=False)
        _available_modules[module_name] = unavailable
        if module_name != lib_name:
            _available_modules[lib_name] = unavailable
        msg = _optional_module_msg(scope, module_name, extra) # defined elsewhere
        raise ModuleNotFoundError(msg) from None
    return module
```

Something like [polars.dependencies](https://github.com/pola-rs/polars/blob/687dee3095ae00137ff7fdb31533ecf960b324e7/py-polars/polars/dependencies.py) is a much more developed spin on this - particularly [dependencies.import_optional](https://github.com/pola-rs/polars/blob/687dee3095ae00137ff7fdb31533ecf960b324e7/py-polars/polars/dependencies.py#L231) which has version bounds.



@author:
Just to be clear, this is more me floating an idea - not something that needs to be tied to this PR

@user1:
Thanks for mentioning this suggestions! Can we remove this `import_toolz_function()` function? I don't think it is used anywhere anymore.

@author:
Sure thing @user1

Fixed in 88fbd1141b39ca74b47106d254969661e77bdabf