Repository: marimo-team/marimo
Pull request: fix: fix altair api break, cleanup DependencyManager
Fixes #2005 

Altair made an (arguably) breaking change. Their data transformers now pass narwhal dataframes instead of pandas, which breaks some downstream features in marimo. This PR now supports [narwhals](https://github.com/narwhals-dev/narwhals)

This is nice actually nice since now we can pass polars, pyarrow, etc to altair charts and get back the polars, pyarrow, etc charts.  

This also DRYs up `DependencyManager` 

Problem statement:
Possible incompatibility when using marimo_csv transformer with altair 5.4.0
### Describe the bug

When generating a plot using the marimo_csv transformer, I'm getting a DataFrame constructor not properly called error.

This seems to be linked to an upgrade from altair `5.3.0` to `5.4.0` on my end, but only happens with the marimo csv transformer as far as I can tell.

```
Traceback (most recent call last):
  File "/Users/andrewhill/Desktop/absel/env/lib/python3.10/site-packages/marimo/_output/formatting.py", line 172, in try_format
    mimetype, data = formatter(obj)
  File "/Users/andrewhill/Desktop/absel/env/lib/python3.10/site-packages/marimo/_output/formatters/altair_formatters.py", line 44, in _show_chart
    srcdoc=html.escape(chart.to_html()),
  File "/Users/andrewhill/Desktop/absel/env/lib/python3.10/site-packages/altair/vegalite/v5/api.py", line 1946, in to_html
    self.to_dict(),
  File "/Users/andrewhill/Desktop/absel/env/lib/python3.10/site-packages/altair/vegalite/v5/api.py", line 3772, in to_dict
    return super().to_dict(**kwds)
  File "/Users/andrewhill/Desktop/absel/env/lib/python3.10/site-packages/altair/vegalite/v5/api.py", line 1798, in to_dict
    copy.data = _prepare_data(data, context)
  File "/Users/andrewhill/Desktop/absel/env/lib/python3.10/site-packages/altair/vegalite/v5/api.py", line 275, in _prepare_data
    data = func(data)
  File "/Users/andrewhill/Desktop/absel/env/lib/python3.10/site-packages/marimo/_plugins/ui/_impl/charts/altair_transformer.py", line 56, in _to_marimo_csv
    data_csv = _data_to_csv_string(data)
  File "/Users/andrewhill/Desktop/absel/env/lib/python3.10/site-packages/marimo/_plugins/ui/_impl/charts/altair_transformer.py", line 99, in _data_to_csv_string
    return get_table_manager(data).to_csv().decode("utf-8")
  File "/Users/andrewhill/Desktop/absel/env/lib/python3.10/site-packages/marimo/_plugins/ui/_impl/tables/default_table.py", line 89, in to_csv
    return self._as_table_manager().to_csv(format_mapping)
  File "/Users/andrewhill/Desktop/absel/env/lib/python3.10/site-packages/marimo/_plugins/ui/_impl/tables/default_table.py", line 161, in _as_table_manager
    return PandasTableManagerFactory.create()(pd.DataFrame(self.data))
  File "/Users/andrewhill/Desktop/absel/env/lib/python3.10/site-packages/pandas/core/frame.py", line 886, in __init__
    raise ValueError("DataFrame constructor not properly called!")
ValueError: DataFrame constructor not properly called!
```

### Environment

```
{
  "marimo": "0.7.19",
  "OS": "Darwin",
  "OS Version": "23.5.0",
  "Processor": "arm",
  "Python Version": "3.10.4",
  "Binaries": {
    "Browser": "--",
    "Node": "v21.7.1"
  },
  "Requirements": {
    "click": "8.1.3",
    "importlib-resources": "missing",
    "jedi": "0.19.1",
    "markdown": "3.6",
    "pymdown-extensions": "10.8.1",
    "pygments": "2.17.2",
    "tomlkit": "0.12.4",
    "uvicorn": "0.29.0",
    "starlette": "0.37.2",
    "websockets": "12.0",
    "typing-extensions": "4.11.0",
    "ruff": "0.3.7"
  }
}
```

### Code to reproduce

```
@app.cell
def __():
    import marimo as mo
    import polars as pl
    import altair as alt

   # if remove this line and restart kernel, plot appears as expected
    alt.data_transformers.enable("marimo_csv")

    test_data = pl.DataFrame({
        "name": ["Alice", "Bob", "Charlie", "David"],
        "salary": [50000, 60000, 75000, 55000],
    })


    (
        alt.Chart(test_data.to_pandas())
        .encode(
            y="salary",
            x="name",
        )
        .mark_point(color="red")
    )

    return alt, mo, pl, test_data


if __name__ == "__main__":
    app.run()
```

Hints from issue discussion:
It looks like altair moved to https://github.com/narwhals-dev/narwhals which has broken the `data_transformers` API. I can fix this in our repo, but I would consider this a breaking change from them.

Commit message: fix

Patch to review:
diff --git a/marimo/_ast/visitor.py b/marimo/_ast/visitor.py
index 53ab4aa19e7..a4d1b833f83 100644
--- a/marimo/_ast/visitor.py
+++ b/marimo/_ast/visitor.py
@@ -365,7 +365,7 @@ def visit_Call(self, node: ast.Call) -> None:
             elif isinstance(first_arg, ast.JoinedStr):
                 sql = normalize_sql_f_string(first_arg)
 
-            if isinstance(sql, str) and DependencyManager.has_duckdb() and sql:
+            if isinstance(sql, str) and DependencyManager.duckdb.has() and sql:
                 import duckdb  # type: ignore[import-not-found,import-untyped,unused-ignore] # noqa: E501
 
                 # Add all tables in the query to the ref scope
diff --git a/marimo/_data/preview_column.py b/marimo/_data/preview_column.py
index 208a7dd9d91..33aabe51808 100644
--- a/marimo/_data/preview_column.py
+++ b/marimo/_data/preview_column.py
@@ -51,7 +51,7 @@ def get_column_preview(
 
         # We require altair to render the chart
         error = None
-        if not DependencyManager.has_altair():
+        if not DependencyManager.altair.has():
             error = (
                 "Altair is required to render charts. "
                 "Install it with `pip install altair`."
@@ -115,7 +115,7 @@ def _get_altair_chart(
     summary: ColumnSummary,
 ) -> tuple[Optional[str], Optional[str], bool]:
     # We require altair to render the chart
-    if not DependencyManager.has_altair() or not table.supports_altair():
+    if not DependencyManager.altair.has() or not table.supports_altair():
         return None, None, False
 
     import altair as alt  # type: ignore[import-not-found,import-untyped,unused-ignore] # noqa: E501
diff --git a/marimo/_data/series.py b/marimo/_data/series.py
index 506d5061da0..9828cf5ccd1 100644
--- a/marimo/_data/series.py
+++ b/marimo/_data/series.py
@@ -61,7 +61,7 @@ def validate_number(value: Any) -> float:
             raise ValueError("Expected a number. Got: " + str(type(value)))
         return value
 
-    if DependencyManager.has_pandas():
+    if DependencyManager.pandas.has():
         import pandas as pd
 
         if isinstance(series, pd.Series):
@@ -71,7 +71,7 @@ def validate_number(value: Any) -> float:
                 label=_get_name(series),
             )
 
-    if DependencyManager.has_polars():
+    if DependencyManager.polars.has():
         import polars as pl
 
         if isinstance(series, pl.Series):
@@ -88,7 +88,7 @@ def get_category_series_info(series: Any) -> CategorySeriesInfo:
     """
     Get the summary of a categorical series.
     """
-    if DependencyManager.has_pandas():
+    if DependencyManager.pandas.has():
         import pandas as pd
 
         if isinstance(series, pd.Series):
@@ -97,7 +97,7 @@ def get_category_series_info(series: Any) -> CategorySeriesInfo:
                 label=_get_name(series),
             )
 
-    if DependencyManager.has_polars():
+    if DependencyManager.polars.has():
         import polars as pl
 
         if isinstance(series, pl.Series):
@@ -119,7 +119,7 @@ def validate_date(value: Any) -> str:
             raise ValueError("Expected a date. Got: " + str(type(value)))
         return value.strftime("%Y-%m-%d")
 
-    if DependencyManager.has_pandas():
+    if DependencyManager.pandas.has():
         import pandas as pd
 
         if isinstance(series, pd.Series):
@@ -129,7 +129,7 @@ def validate_date(value: Any) -> str:
                 label=_get_name(series),
             )
 
-    if DependencyManager.has_polars():
+    if DependencyManager.polars.has():
         import polars as pl
 
         if isinstance(series, pl.Series):
diff --git a/marimo/_dependencies/dependencies.py b/marimo/_dependencies/dependencies.py
index b95610990e4..14397eb0e3c 100644
--- a/marimo/_dependencies/dependencies.py
+++ b/marimo/_dependencies/dependencies.py
@@ -1,203 +1,143 @@
 # Copyright 2024 Marimo. All rights reserved.
 from __future__ import annotations
 
+import importlib.metadata
 import importlib.util
+from dataclasses import dataclass
 
+from packaging import version
 
-class DependencyManager:
-    """Utilities for checking the status of dependencies."""
+from marimo import _loggers
 
-    @staticmethod
-    def require_pandas(why: str) -> None:
-        """
-        Raise an ModuleNotFoundError if pandas is not installed.
+LOGGER = _loggers.marimo_logger()
 
-        Args:
-            why: A string of the form "for <reason>" that will be appended
 
-        """
-        if not DependencyManager.has_pandas():
-            raise ModuleNotFoundError(
-                f"pandas is required {why}. "
-                + "You can install it with 'pip install pandas'"
-            ) from None
+@dataclass
+class Dependency:
+    pkg: str
+    min_version: str | None = None
+    max_version: str | None = None
 
-    @staticmethod
-    def require_polars(why: str) -> None:
-        """
-        Raise an ModuleNotFoundError if polars is not installed.
+    def has(self) -> bool:
+        """Return True if the dependency is installed."""
+        has_dep = importlib.util.find_spec(self.pkg) is not None
+        if not has_dep:
+            return False
 
-        Args:
-            why: A string of the form "for <reason>" that will be appended
+        if self.min_version or self.max_version:
+            self.warn_if_mismatch_version(self.min_version, self.max_version)
+        return True
 
-        """
-        if not DependencyManager.has_polars():
-            raise ModuleNotFoundError(
-                f"polars is required {why}. "
-                + "You can install it with 'pip install polars'"
-            ) from None
+    def has_at_version(
+        self, min_version: str | None, max_version: str | None = None
+    ) -> bool:
+        if not self.has():
+            return False
+        return _version_check(
+            pkg=self.pkg,
+            v=self.get_version(),
+            min_v=min_version,
+            max_v=max_version,
+        )
 
-    @staticmethod
-    def require_numpy(why: str) -> None:
+    def require(self, why: str) -> None:
         """
-        Raise an ModuleNotFoundError if numpy is not installed.
+        Raise an ModuleNotFoundError if the package is not installed.
 
         Args:
             why: A string of the form "for <reason>" that will be appended
 
         """
-        if not DependencyManager.has_numpy():
+        if not self.has():
             raise ModuleNotFoundError(
-                f"numpy is required {why}. "
-                + "You can install it with 'pip install numpy'"
+                f"{self.pkg} is required {why}. "
+                + f"You can install it with 'pip install {self.pkg}'."
             ) from None
 
-    @staticmethod
-    def require_altair(why: str) -> None:
-        """
-        Raise an ModuleNotFoundError if altair is not installed.
+    def get_version(self) -> str:
+        return importlib.metadata.version(self.pkg)
+
+    def warn_if_mismatch_version(
+        self,
+        min_version: str | None = None,
+        max_version: str | None = None,
+    ) -> bool:
+        return _version_check(
+            pkg=self.pkg,
+            v=self.get_version(),
+            min_v=min_version,
+            max_v=max_version,
+            raise_error=False,
+        )
+
+    def require_version(
+        self,
+        min_version: str | None = None,
+        max_version: str | None = None,
+    ) -> None:
+        _version_check(
+            pkg=self.pkg,
+            v=self.get_version(),
+            min_v=min_version,
+            max_v=max_version,
+            raise_error=True,
+        )
+
+
+def _version_check(
+    *,
+    pkg: str,
+    v: str,
+    min_v: str | None = None,
+    max_v: str | None = None,
+    raise_error: bool = False,
+) -> bool:
+    if min_v is None and max_v is None:
+        return True
+
+    parsed_min_version = version.parse(min_v) if min_v else None
+    parsed_max_version = version.parse(max_v) if max_v else None
+    parsed_v = version.parse(v)
+
+    if parsed_min_version is not None and parsed_v < parsed_min_version:
+        msg = f"Mismatched version of {pkg}: expected >={min_v}, got {v}"
+        if raise_error:
+            raise RuntimeError(msg)
+        LOGGER.warning(f"{msg}. Some features may not work correctly.")
+        return False
+
+    if parsed_max_version is not None and parsed_v >= parsed_max_version:
+        msg = f"Mismatched version of {pkg}: expected <{max_v}, got {v}"
+        if raise_error:
+            raise RuntimeError(msg)
+        LOGGER.warning(f"{msg}. Some features may not work correctly.")
+        return False
+
+    return True
 
-        Args:
-            why: A string of the form "for <reason>" that will be appended
 
-        """
-        if not DependencyManager.has_altair():
-            raise ModuleNotFoundError(
-                f"altair is required {why}. "
-                + "You can install it with 'pip install altair'"
-            ) from None
-
-    @staticmethod
-    def require_duckdb(why: str) -> None:
-        """
-        Raise an ModuleNotFoundError if duckdb is not installed.
-
-        Args:
-            why: A string of the form "for <reason>" that will be appended
-
-        """
-        if not DependencyManager.has_duckdb():
-            raise ModuleNotFoundError(
-                f"duckdb is required {why}. "
-                + "You can install it with 'pip install duckdb'"
-            ) from None
-
-    @staticmethod
-    def require_pillow(why: str) -> None:
-        """
-        Raise an ModuleNotFoundError if pillow is not installed.
-
-        Args:
-            why: A string of the form "for <reason>" that will be appended
-
-        """
-        if not DependencyManager.has_pillow():
-            raise ModuleNotFoundError(
-                f"pillow is required {why}. "
-                + "You can install it with 'pip install pillow'"
-            ) from None
-
-    @staticmethod
-    def require_plotly(why: str) -> None:
-        """
-        Raise an ModuleNotFoundError if plotly is not installed.
-
-        Args:
-            why: A string of the form "for <reason>" that will be appended
-
-        """
-        if not DependencyManager.has_plotly():
-            raise ModuleNotFoundError(
-                f"plotly is required {why}. "
-                + "You can install it with 'pip install plotly'"
-            ) from None
-
-    @staticmethod
-    def require_pyarrow(why: str) -> None:
-        """
-        Raise an ModuleNotFoundError if pyarrow is not installed.
-
-        Args:
-            why: A string of the form "for <reason>" that will be appended
+class DependencyManager:
+    """Utilities for checking the status of dependencies."""
 
-        """
-        if not DependencyManager.has_pyarrow():
-            raise ModuleNotFoundError(
-                f"pyarrow is required {why}. "
-                + "You can install it with 'pip install pyarrow'"
-            ) from None
+    pandas = Dependency("pandas")
+    polars = Dependency("polars")
+    numpy = Dependency("numpy")
+    altair = Dependency("altair", min_version="5.3.0", max_version="6.0.0")
+    duckdb = Dependency("duckdb")
+    pillow = Dependency("PIL")
+    plotly = Dependency("plotly")
+    pyarrow = Dependency("pyarrow")
+    openai = Dependency("openai")
+    matplotlib = Dependency("matplotlib")
+    anywidget = Dependency("anywidget")
+    watchdog = Dependency("watchdog")
+    ipython = Dependency("IPython")
+    nbformat = Dependency("nbformat")
+    narwhals = Dependency("narwhals")
+    ruff = Dependency("ruff")
+    black = Dependency("black")
 
     @staticmethod
     def has(pkg: str) -> bool:
         """Return True if any lib is installed."""
-        return importlib.util.find_spec(pkg) is not None
-
-    @staticmethod
-    def has_openai() -> bool:
-        """Return True if openai is installed."""
-        return importlib.util.find_spec("openai") is not None
-
-    @staticmethod
-    def has_pandas() -> bool:
-        """Return True if pandas is installed."""
-        return importlib.util.find_spec("pandas") is not None
-
-    @staticmethod
-    def has_pyarrow() -> bool:
-        """Return True if pyarrow is installed."""
-        return importlib.util.find_spec("pyarrow") is not None
-
-    @staticmethod
-    def has_polars() -> bool:
-        """Return True if polars is installed."""
-        return importlib.util.find_spec("polars") is not None
-
-    @staticmethod
-    def has_numpy() -> bool:
-        """Return True if numpy is installed."""
-        return importlib.util.find_spec("numpy") is not None
-
-    @staticmethod
-    def has_altair() -> bool:
-        """Return True if altair is installed."""
-        return importlib.util.find_spec("altair") is not None
-
-    @staticmethod
-    def has_duckdb() -> bool:
-        """Return True if duckdb is installed."""
-        return importlib.util.find_spec("duckdb") is not None
-
-    @staticmethod
-    def has_pillow() -> bool:
-        """Return True if pillow is installed."""
-        return importlib.util.find_spec("PIL") is not None
-
-    @staticmethod
-    def has_plotly() -> bool:
-        """Return True if plotly is installed."""
-        return importlib.util.find_spec("plotly") is not None
-
-    @staticmethod
-    def has_matplotlib() -> bool:
-        """Return True if matplotlib is installed."""
-        return importlib.util.find_spec("matplotlib") is not None
-
-    @staticmethod
-    def has_anywidget() -> bool:
-        """Return True if anywidget is installed."""
-        return importlib.util.find_spec("anywidget") is not None
-
-    @staticmethod
-    def has_watchdog() -> bool:
-        """Return True if watchdog is installed."""
-        return importlib.util.find_spec("watchdog") is not None
-
-    @staticmethod
-    def has_ipython() -> bool:
-        """Return True if IPython is installed."""
-        return importlib.util.find_spec("IPython") is not None
-
-    @staticmethod
-    def has_nbformat() -> bool:
-        """Return True if nbformat is installed."""
-        return importlib.util.find_spec("nbformat") is not None
+        return Dependency(pkg).has()
diff --git a/marimo/_messaging/ops.py b/marimo/_messaging/ops.py
index a943e7b2f64..2064dd3af34 100644
--- a/marimo/_messaging/ops.py
+++ b/marimo/_messaging/ops.py
@@ -6,7 +6,6 @@
 
 from __future__ import annotations
 
-import importlib.metadata
 import json
 import sys
 import time
@@ -25,8 +24,6 @@
     cast,
 )
 
-from packaging import version
-
 from marimo import _loggers as loggers
 from marimo._ast.app import _AppConfig
 from marimo._ast.cell import CellConfig, CellId_t, CellStatusType
@@ -342,12 +339,7 @@ class KernelCapabilities:
     terminal: bool = False
 
     def __post_init__(self) -> None:
-        if DependencyManager.has_duckdb():
-            self.sql = version.parse(
-                importlib.metadata.version("duckdb")
-            ) >= version.parse("1.0.0")
-        else:
-            self.sql = False
+        self.sql = DependencyManager.duckdb.has_at_version(min_version="1.0.0")
         # Only available in mac/linux
         self.terminal = not is_windows() and not is_pyodide()
 
diff --git a/marimo/_output/formatters/holoviews_formatters.py b/marimo/_output/formatters/holoviews_formatters.py
index 5a472835f08..3c520b5cfa6 100644
--- a/marimo/_output/formatters/holoviews_formatters.py
+++ b/marimo/_output/formatters/holoviews_formatters.py
@@ -40,7 +40,7 @@ def _show_chart(
 
             # If its a dict, then its a plotly figure,
             # and we should convert it to a plotly object
-            if DependencyManager.has_plotly() and isinstance(
+            if DependencyManager.plotly.has() and isinstance(
                 backend_output, dict
             ):
                 plotly_html = PlotlyFormatter.render_plotly_dict(
diff --git a/marimo/_plugins/core/json_encoder.py b/marimo/_plugins/core/json_encoder.py
index d76fd7be65b..ac7910a4a7c 100644
--- a/marimo/_plugins/core/json_encoder.py
+++ b/marimo/_plugins/core/json_encoder.py
@@ -16,7 +16,7 @@ class WebComponentEncoder(JSONEncoder):
     def default(self, o: Any) -> Any:
         obj = o
         # Handle numpy objects
-        if DependencyManager.has_numpy():
+        if DependencyManager.numpy.has():
             import numpy as np
 
             dtypes = (np.datetime64, np.complexfloating)
@@ -34,7 +34,7 @@ def default(self, o: Any) -> Any:
                 return str(obj)
 
         # Handle pandas objects
-        if DependencyManager.has_pandas():
+        if DependencyManager.pandas.has():
             import pandas as pd
 
             # Opinionated or known types
diff --git a/marimo/_plugins/stateless/image.py b/marimo/_plugins/stateless/image.py
index 5d057e18596..9b5a285119b 100644
--- a/marimo/_plugins/stateless/image.py
+++ b/marimo/_plugins/stateless/image.py
@@ -56,13 +56,13 @@ def _normalize_image(src: ImageLike) -> Image:
         or hasattr(src, "__array__")
         or hasattr(src, "toarray")
     ):
-        DependencyManager.require_pillow(
+        DependencyManager.pillow.require(
             "to render images from arrays in `mo.image`"
         )
         from PIL import Image as _Image
 
         if not hasattr(src, "__array_interface__"):
-            DependencyManager.require_numpy(
+            DependencyManager.numpy.require(
                 "to render images from generic arrays in `mo.image`"
             )
             import numpy
diff --git a/marimo/_plugins/ui/_impl/altair_chart.py b/marimo/_plugins/ui/_impl/altair_chart.py
index 74265c6b0b6..fbf97dd1199 100644
--- a/marimo/_plugins/ui/_impl/altair_chart.py
+++ b/marimo/_plugins/ui/_impl/altair_chart.py
@@ -203,7 +203,7 @@ def __init__(
         label: str = "",
         on_change: Optional[Callable[[pd.DataFrame], None]] = None,
     ) -> None:
-        DependencyManager.require_altair(why="to use `mo.ui.altair_chart`")
+        DependencyManager.altair.require(why="to use `mo.ui.altair_chart`")
 
         import altair as alt
 
@@ -294,7 +294,7 @@ def _get_dataframe_from_chart(
         return chart.data
 
     def _convert_value(self, value: ChartSelection) -> Any:
-        from altair import UndefinedType
+        from altair import Undefined
 
         self._chart_selection = value
         flat, _ = flatten.flatten(value)
@@ -306,7 +306,7 @@ def _convert_value(self, value: ChartSelection) -> Any:
         # When using layered charts, you can no longer access the
         # chart data directly
         # Instead, we should push user to call .apply_selection(df)
-        if isinstance(self.dataframe, UndefinedType):
+        if self.dataframe is Undefined:
             return self.dataframe
 
         # If we have transforms, we need to filter the dataframe
@@ -399,10 +399,10 @@ def __and__(self, value: Any) -> Any:
 
     @property
     def value(self) -> pd.DataFrame:
-        from altair import UndefinedType
+        from altair import Undefined
 
         value = super().value
-        if isinstance(value, UndefinedType):
+        if value is Undefined:
             sys.stderr.write(
                 "The underlying chart data is not available in layered"
                 " or stacked charts. "
diff --git a/marimo/_plugins/ui/_impl/dataframes/transforms/apply.py b/marimo/_plugins/ui/_impl/dataframes/transforms/apply.py
index e37aff4365c..eebb0b6aad1 100644
--- a/marimo/_plugins/ui/_impl/dataframes/transforms/apply.py
+++ b/marimo/_plugins/ui/_impl/dataframes/transforms/apply.py
@@ -60,12 +60,12 @@ def get_handler_for_dataframe(
 
     raises ValueError if the dataframe type is not supported.
     """
-    if DependencyManager.has_pandas():
+    if DependencyManager.pandas.has():
         import pandas as pd
 
         if isinstance(df, pd.DataFrame):
             return PandasTransformHandler()
-    if DependencyManager.has_polars():
+    if DependencyManager.polars.has():
         import polars as pl
 
         if isinstance(df, pl.DataFrame):
diff --git a/marimo/_plugins/ui/_impl/plotly.py b/marimo/_plugins/ui/_impl/plotly.py
index ce15c339eec..3660f1f95b9 100644
--- a/marimo/_plugins/ui/_impl/plotly.py
+++ b/marimo/_plugins/ui/_impl/plotly.py
@@ -106,7 +106,7 @@ def __init__(
         label: str = "",
         on_change: Optional[Callable[[JSONType], None]] = None,
     ) -> None:
-        DependencyManager.require_plotly("for `mo.ui.plotly`")
+        DependencyManager.plotly.require("for `mo.ui.plotly`")
 
         import plotly.io as pio  # type:ignore
 
diff --git a/marimo/_plugins/ui/_impl/tables/default_table.py b/marimo/_plugins/ui/_impl/tables/default_table.py
index 98b8217ae8c..c74848c290f 100644
--- a/marimo/_plugins/ui/_impl/tables/default_table.py
+++ b/marimo/_plugins/ui/_impl/tables/default_table.py
@@ -51,9 +51,9 @@ def __init__(self, data: JsonTableData):
     def supports_download(self) -> bool:
         # If we have pandas/polars/pyarrow, we can convert to CSV or JSON
         return (
-            DependencyManager.has_pandas()
-            or DependencyManager.has_polars()
-            or DependencyManager.has_pyarrow()
+            DependencyManager.pandas.has()
+            or DependencyManager.polars.has()
+            or DependencyManager.pyarrow.has()
         )
 
     def apply_formatting(self, format_mapping: FormatMapping) -> JsonTableData:
@@ -155,15 +155,15 @@ def get_row_headers(self) -> list[str]:
         return []
 
     def _as_table_manager(self) -> TableManager[Any]:
-        if DependencyManager.has_pandas():
+        if DependencyManager.pandas.has():
             import pandas as pd
 
             return PandasTableManagerFactory.create()(pd.DataFrame(self.data))
-        if DependencyManager.has_polars():
+        if DependencyManager.polars.has():
             import polars as pl
 
             return PolarsTableManagerFactory.create()(pl.DataFrame(self.data))
-        if DependencyManager.has_pyarrow():
+        if DependencyManager.pyarrow.has():
             import pyarrow as pa
 
             if isinstance(self.data, dict):
diff --git a/marimo/_plugins/ui/_impl/tables/df_protocol_table.py b/marimo/_plugins/ui/_impl/tables/df_protocol_table.py
index 980a22ed357..6ed4fdd4865 100644
--- a/marimo/_plugins/ui/_impl/tables/df_protocol_table.py
+++ b/marimo/_plugins/ui/_impl/tables/df_protocol_table.py
@@ -47,7 +47,7 @@ def __init__(self, data: DataFrameLike):
     def _ensure_delegate(
         self,
     ) -> TableManager[Union[pa.Table, pa.RecordBatch]]:
-        DependencyManager.require_pyarrow(
+        DependencyManager.pyarrow.require(
             "for table support using the dataframe protocol"
         )
 
diff --git a/marimo/_plugins/ui/_impl/tables/utils.py b/marimo/_plugins/ui/_impl/tables/utils.py
index 279837a9eac..cb0ec609ef5 100644
--- a/marimo/_plugins/ui/_impl/tables/utils.py
+++ b/marimo/_plugins/ui/_impl/tables/utils.py
@@ -47,6 +47,13 @@ def get_table_manager_or_none(data: Any) -> TableManager[Any] | None:
             if manager.is_type(data):
                 return manager(data)
 
+    # Unpack narwhal dataframe wrapper
+    if DependencyManager.narwhals.has():
+        import narwhals  # type: ignore[import-not-found,import-untyped,unused-ignore] # noqa: E501
+
+        if isinstance(data, narwhals.DataFrame):
+            return get_table_manager_or_none(narwhals.to_native(data))
+
     # If we have a DataFrameLike object, use the DataFrameProtocolTableManager
     if is_dataframe_like(data):
         try:
diff --git a/marimo/_runtime/app/script_runner.py b/marimo/_runtime/app/script_runner.py
index 8eabc85b2d9..b5f37f891e1 100644
--- a/marimo/_runtime/app/script_runner.py
+++ b/marimo/_runtime/app/script_runner.py
@@ -72,7 +72,7 @@ def run(self) -> RunOutput:
                 register_formatters()
 
             post_execute_hooks = []
-            if DependencyManager.has_matplotlib():
+            if DependencyManager.matplotlib.has():
                 from marimo._output.mpl import close_figures
 
                 post_execute_hooks.append(close_figures)
diff --git a/marimo/_runtime/runner/hooks_post_execution.py b/marimo/_runtime/runner/hooks_post_execution.py
index 5f328a305a5..7d01a2281b6 100644
--- a/marimo/_runtime/runner/hooks_post_execution.py
+++ b/marimo/_runtime/runner/hooks_post_execution.py
@@ -91,7 +91,7 @@ def _broadcast_duckdb_tables(
 ) -> None:
     del run_result
     del runner
-    if not DependencyManager.has_duckdb():
+    if not DependencyManager.duckdb.has():
         return
 
     try:
diff --git a/marimo/_smoke_tests/bugs/2005.py b/marimo/_smoke_tests/bugs/2005.py
new file mode 100644
index 00000000000..562d8a4d514
--- /dev/null
+++ b/marimo/_smoke_tests/bugs/2005.py
@@ -0,0 +1,43 @@
+# Copyright 2024 Marimo. All rights reserved.
+import marimo
+
+__generated_with = "0.7.19"
+app = marimo.App(width="medium")
+
+
+@app.cell
+def __():
+    import marimo as mo
+    import polars as pl
+    import altair as alt
+
+    alt.data_transformers.enable("marimo_csv")
+
+    test_data = pl.DataFrame(
+        {
+            "name": ["Alice", "Bob", "Charlie", "David"],
+            "salary": [50000, 60000, 75000, 55000],
+        }
+    )
+
+
+    chart = (
+        alt.Chart(test_data.to_pandas())
+        .encode(
+            y="salary",
+            x="name",
+        )
+        .mark_point(color="red")
+    )
+    chart
+    return alt, chart, mo, pl, test_data
+
+
+@app.cell
+def __(chart, mo):
+    mo.ui.altair_chart(chart)
+    return
+
+
+if __name__ == "__main__":
+    app.run()
diff --git a/marimo/_sql/sql.py b/marimo/_sql/sql.py
index 80d280b927b..f0e2fed16f1 100644
--- a/marimo/_sql/sql.py
+++ b/marimo/_sql/sql.py
@@ -33,7 +33,7 @@ def sql(
     Returns:
         The result of the query.
     """
-    DependencyManager.require_duckdb("to execute sql")
+    DependencyManager.duckdb.require("to execute sql")
 
     import duckdb  # type: ignore[import-not-found,import-untyped,unused-ignore] # noqa: E501
 
@@ -58,7 +58,7 @@ def sql(
     custom_total_count: Optional[Literal["too_many"]] = None
 
     df: Any
-    if DependencyManager.has_polars():
+    if DependencyManager.polars.has():
         df = relation.pl()
         if enforce_own_limit:
             custom_total_count = (
@@ -67,7 +67,7 @@ def sql(
                 else None
             )
             df = df.limit(default_result_limit)
-    elif DependencyManager.has_pandas():
+    elif DependencyManager.pandas.has():
         df = relation.df()
         if enforce_own_limit:
             custom_total_count = (
diff --git a/marimo/_utils/file_watcher.py b/marimo/_utils/file_watcher.py
index a4e2e9f8caf..c7e4acc98d0 100644
--- a/marimo/_utils/file_watcher.py
+++ b/marimo/_utils/file_watcher.py
@@ -1,4 +1,6 @@
 # Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
 import asyncio
 import os
 from abc import ABC, abstractmethod
@@ -14,7 +16,7 @@
 class FileWatcher(ABC):
     @staticmethod
     def create(path: Path, callback: Callback) -> "FileWatcher":
-        if DependencyManager.has_watchdog():
+        if DependencyManager.watchdog.has():
             LOGGER.debug("Using watchdog file watcher")
             return _create_watchdog(path, callback, asyncio.get_event_loop())
         else:
diff --git a/marimo/_utils/formatter.py b/marimo/_utils/formatter.py
index fa2d21d30ae..11804b15ad2 100644
--- a/marimo/_utils/formatter.py
+++ b/marimo/_utils/formatter.py
@@ -1,4 +1,6 @@
 # Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
 import subprocess
 from typing import Dict
 
@@ -26,9 +28,9 @@ class DefaultFormatter(Formatter):
     """
 
     def format(self, codes: CellCodes) -> CellCodes:
-        if DependencyManager.has("ruff"):
+        if DependencyManager.ruff.has():
             return RuffFormatter(self.line_length).format(codes)
-        elif DependencyManager.has("black"):
+        elif DependencyManager.black.has():
             return BlackFormatter(self.line_length).format(codes)
         else:
             LOGGER.warning(
diff --git a/tests/_ast/test_app.py b/tests/_ast/test_app.py
index f4d183a953b..1dc5df20aca 100644
--- a/tests/_ast/test_app.py
+++ b/tests/_ast/test_app.py
@@ -416,7 +416,7 @@ def __(x: int) -> tuple[int]:
         assert defs["y"] == 1
 
     @pytest.mark.skipif(
-        condition=not DependencyManager.has_matplotlib(),
+        condition=not DependencyManager.matplotlib.has(),
         reason="requires matplotlib",
     )
     def test_marimo_mpl_backend_not_used(self):
@@ -434,7 +434,7 @@ def __() -> tuple[str]:
         assert defs["backend"] != "module://marimo._output.mpl"
 
     @pytest.mark.skipif(
-        condition=not DependencyManager.has_matplotlib(),
+        condition=not DependencyManager.matplotlib.has(),
         reason="requires matplotlib",
     )
     def test_app_run_matplotlib_figures_closed(self) -> None:
diff --git a/tests/_ast/test_visitor.py b/tests/_ast/test_visitor.py
index 6ae10f0f54d..c4565f27b14 100644
--- a/tests/_ast/test_visitor.py
+++ b/tests/_ast/test_visitor.py
@@ -865,7 +865,7 @@ def test_private_ref_requirement_caught() -> None:
     }
 
 
-HAS_DEPS = DependencyManager.has_duckdb()
+HAS_DEPS = DependencyManager.duckdb.has()
 
 
 @pytest.mark.skipif(not HAS_DEPS, reason="Requires duckdb")
diff --git a/tests/_cli/test_cli_export.py b/tests/_cli/test_cli_export.py
index 3637e563e49..442e2efc390 100644
--- a/tests/_cli/test_cli_export.py
+++ b/tests/_cli/test_cli_export.py
@@ -69,7 +69,7 @@ def test_cli_export_async(temp_async_marimo_file: str) -> None:
         assert '<marimo-code hidden=""></marimo-code>' not in html
 
     @pytest.mark.skipif(
-        condition=DependencyManager.has_watchdog(),
+        condition=DependencyManager.watchdog.has(),
         reason="hangs when watchdog is installed",
     )
     async def test_export_watch(self, temp_marimo_file: str) -> None:
@@ -111,7 +111,7 @@ async def test_export_watch(self, temp_marimo_file: str) -> None:
                 break
 
     @pytest.mark.skipif(
-        condition=DependencyManager.has_watchdog(),
+        condition=DependencyManager.watchdog.has(),
         reason="hangs when watchdog is installed",
     )
     def test_export_watch_no_out_dir(self, temp_marimo_file: str) -> None:
@@ -253,7 +253,7 @@ def test_export_script_async(temp_async_marimo_file: str) -> None:
         )
 
     @pytest.mark.skipif(
-        condition=DependencyManager.has_watchdog(),
+        condition=DependencyManager.watchdog.has(),
         reason="hangs when watchdog is installed",
     )
     async def test_export_watch_script(self, temp_marimo_file: str) -> None:
@@ -297,7 +297,7 @@ async def test_export_watch_script(self, temp_marimo_file: str) -> None:
         assert path.exists(temp_out_file)
 
     @pytest.mark.skipif(
-        condition=DependencyManager.has_watchdog(),
+        condition=DependencyManager.watchdog.has(),
         reason="hangs when watchdog is installed",
     )
     def test_export_watch_script_no_out_dir(
@@ -362,7 +362,7 @@ def test_export_markdown_broken(temp_unparsable_marimo_file: str) -> None:
         snapshot("broken.txt", output)
 
     @pytest.mark.skipif(
-        condition=DependencyManager.has_watchdog(),
+        condition=DependencyManager.watchdog.has(),
         reason="hangs when watchdog is installed",
     )
     async def test_export_watch_markdown(self, temp_marimo_file: str) -> None:
@@ -406,7 +406,7 @@ async def test_export_watch_markdown(self, temp_marimo_file: str) -> None:
         assert path.exists(temp_out_file)
 
     @pytest.mark.skipif(
-        condition=DependencyManager.has_watchdog(),
+        condition=DependencyManager.watchdog.has(),
         reason="hangs when watchdog is installed",
     )
     def test_export_watch_markdown_no_out_dir(
@@ -438,7 +438,7 @@ def test_export_watch_markdown_no_out_dir(
 
 class TestExportIpynb:
     @pytest.mark.skipif(
-        not DependencyManager.has_nbformat(),
+        not DependencyManager.nbformat.has(),
         reason="This test requires nbformat.",
     )
     def test_export_ipynb(self, temp_marimo_file_with_md: str) -> None:
diff --git a/tests/_data/test_charts.py b/tests/_data/test_charts.py
index 9e75d3f4e86..75e23b98479 100644
--- a/tests/_data/test_charts.py
+++ b/tests/_data/test_charts.py
@@ -22,7 +22,7 @@
 
 snapshot = snapshotter(__file__)
 
-HAS_DEPS = DependencyManager.has_pandas() and DependencyManager.has_altair()
+HAS_DEPS = DependencyManager.pandas.has() and DependencyManager.altair.has()
 
 
 def test_get_chart_builder():
diff --git a/tests/_data/test_get_datasets.py b/tests/_data/test_get_datasets.py
index ffe78ba963e..a018b596702 100644
--- a/tests/_data/test_get_datasets.py
+++ b/tests/_data/test_get_datasets.py
@@ -5,7 +5,7 @@
 from marimo._data.get_datasets import has_updates_to_datasource
 from marimo._dependencies.dependencies import DependencyManager
 
-HAS_DEPS = DependencyManager.has_duckdb()
+HAS_DEPS = DependencyManager.duckdb.has()
 
 
 @pytest.mark.skipif(not HAS_DEPS, reason="optional dependencies not installed")
diff --git a/tests/_data/test_preview_column.py b/tests/_data/test_preview_column.py
index daacd6d31db..7b08ed67b7a 100644
--- a/tests/_data/test_preview_column.py
+++ b/tests/_data/test_preview_column.py
@@ -12,7 +12,7 @@
 from marimo._runtime.requests import PreviewDatasetColumnRequest
 from tests.mocks import snapshotter
 
-HAS_DEPS = DependencyManager.has_pandas() and DependencyManager.has_altair()
+HAS_DEPS = DependencyManager.pandas.has() and DependencyManager.altair.has()
 
 snapshot = snapshotter(__file__)
 
diff --git a/tests/_data/test_series.py b/tests/_data/test_series.py
index 443422332f4..3589cd625a2 100644
--- a/tests/_data/test_series.py
+++ b/tests/_data/test_series.py
@@ -14,9 +14,9 @@
 from marimo._dependencies.dependencies import DependencyManager
 
 HAS_DEPS = (
-    DependencyManager.has_pandas()
-    and DependencyManager.has_numpy()
-    and DependencyManager.has_polars()
+    DependencyManager.pandas.has()
+    and DependencyManager.numpy.has()
+    and DependencyManager.polars.has()
 )
 
 if HAS_DEPS:
diff --git a/tests/_dependencies/test_dependencies.py b/tests/_dependencies/test_dependencies.py
new file mode 100644
index 00000000000..121f67eef88
--- /dev/null
+++ b/tests/_dependencies/test_dependencies.py
@@ -0,0 +1,142 @@
+from __future__ import annotations
+
+import pytest
+
+from marimo._dependencies.dependencies import (
+    Dependency,
+    DependencyManager,
+    _version_check,
+)
+
+
+def test_dependencies() -> None:
+    # Only testing 2 random dependencies
+    if DependencyManager.altair.has():
+        import altair
+
+        assert altair is not None
+        DependencyManager.altair.require("for testing")
+
+    if DependencyManager.pandas.has():
+        import pandas
+
+        assert pandas is not None
+        DependencyManager.pandas.require("for testing")
+
+
+def test_without_dependencies() -> None:
+    missing = Dependency("missing")
+    assert missing is not None
+    assert not missing.has()
+
+    with pytest.raises(ModuleNotFoundError) as excinfo:
+        missing.require("for testing")
+
+    assert "for testing" in str(excinfo.value)
+
+
+@pytest.mark.skipif(
+    not DependencyManager.altair.has(),
+    reason="altair is not installed",
+)
+def test_versions():
+    assert (
+        DependencyManager.altair.require_version(
+            min_version="0.0.0", max_version="6.0.0"
+        )
+        is None
+    )
+
+    with pytest.raises(RuntimeError) as excinfo:
+        DependencyManager.altair.require_version(min_version="6.0.0")
+
+    version = DependencyManager.altair.get_version()
+    assert (
+        str(excinfo.value)
+        == f"Mismatched version of altair: expected >=6.0.0, got {version}"
+    )
+
+    # Override version
+    assert (
+        DependencyManager.altair.has_at_version(
+            min_version="0.0.0", max_version="6.0.0"
+        )
+        is True
+    )
+    assert (
+        DependencyManager.altair.has_at_version(min_version="7.0.0") is False
+    )
+
+
+def test_version_check():
+    # within range
+    assert (
+        _version_check(pkg="test", v="1.0.0", min_v="0.0.0", max_v="2.0.0")
+        is True
+    )
+    assert (
+        _version_check(pkg="test", v="1.0.0", min_v="1.0.0", max_v="2.0.0")
+        is True
+    )
+
+    # outside range
+    assert (
+        _version_check(pkg="test", v="0.4.0", min_v="1.0.0", max_v="2.0.0")
+        is False
+    )
+    assert (
+        _version_check(pkg="test", v="2.0.0", min_v="1.0.0", max_v="2.0.0")
+        is False
+    )
+
+    # does not raise
+    assert (
+        _version_check(
+            pkg="test",
+            v="1.0.0",
+            min_v="1.0.0",
+            max_v="2.0.0",
+            raise_error=True,
+        )
+        is True
+    )
+    assert (
+        _version_check(
+            pkg="test",
+            v="1.5.0",
+            min_v="1.0.0",
+            max_v="2.0.0",
+            raise_error=True,
+        )
+        is True
+    )
+
+    # too low
+    with pytest.raises(RuntimeError) as excinfo:
+        _version_check(
+            pkg="test",
+            v="1.0.0",
+            min_v="2.0.0",
+            max_v="3.0.0",
+            raise_error=True,
+        )
+
+    assert (
+        str(excinfo.value)
+        == "Mismatched version of test: expected >=2.0.0, got 1.0.0"
+    )
+
+    with pytest.raises(RuntimeError) as excinfo:
+        _version_check(
+            pkg="test",
+            v="3.0.0",
+            min_v="2.0.0",
+            max_v="3.0.0",
+            raise_error=True,
+        )
+
+    # too high
+    assert (
+        str(excinfo.value)
+        == "Mismatched version of test: expected <3.0.0, got 3.0.0"
+    )
diff --git a/tests/_output/formatters/test_altair.py b/tests/_output/formatters/test_altair.py
index 57c8f0f8710..e4b967e0d56 100644
--- a/tests/_output/formatters/test_altair.py
+++ b/tests/_output/formatters/test_altair.py
@@ -6,7 +6,7 @@
 from marimo._runtime.runtime import Kernel
 from tests.conftest import ExecReqProvider
 
-HAS_DEPS = DependencyManager.has_altair()
+HAS_DEPS = DependencyManager.altair.has()
 
 
 @pytest.mark.skipif(not HAS_DEPS, reason="optional dependencies not installed")
diff --git a/tests/_output/formatters/test_formatters.py b/tests/_output/formatters/test_formatters.py
index d05b4c53f62..55950d4feb2 100644
--- a/tests/_output/formatters/test_formatters.py
+++ b/tests/_output/formatters/test_formatters.py
@@ -31,7 +31,7 @@ def test_path_finder_find_spec() -> None:
     assert spec is not None
 
 
-HAS_DEPS = DependencyManager.has_pandas() and DependencyManager.has_polars()
+HAS_DEPS = DependencyManager.pandas.has() and DependencyManager.polars.has()
 
 
 @pytest.mark.skipif(not HAS_DEPS, reason="optional dependencies not installed")
diff --git a/tests/_output/formatters/test_structures.py b/tests/_output/formatters/test_structures.py
index 48e6a8f6c6c..d36f987692f 100644
--- a/tests/_output/formatters/test_structures.py
+++ b/tests/_output/formatters/test_structures.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 from marimo._dependencies.dependencies import DependencyManager
 from marimo._runtime.runtime import Kernel
 from tests.conftest import ExecReqProvider
@@ -6,7 +8,7 @@
 async def test_matplotlib_special_case(
     executing_kernel: Kernel, exec_req: ExecReqProvider
 ) -> None:
-    if DependencyManager.has_matplotlib() and DependencyManager.has_numpy():
+    if DependencyManager.matplotlib.has() and DependencyManager.numpy.has():
         from marimo._output.formatters.formatters import register_formatters
 
         register_formatters()
diff --git a/tests/_plugins/core/test_json_encoder.py b/tests/_plugins/core/test_json_encoder.py
index a7d07968e02..38577f08df2 100644
--- a/tests/_plugins/core/test_json_encoder.py
+++ b/tests/_plugins/core/test_json_encoder.py
@@ -9,7 +9,7 @@
 from marimo._dependencies.dependencies import DependencyManager
 from marimo._plugins.core.json_encoder import WebComponentEncoder
 
-HAS_DEPS = DependencyManager.has_pandas() and DependencyManager.has_altair()
+HAS_DEPS = DependencyManager.pandas.has() and DependencyManager.altair.has()
 
 
 @pytest.mark.skipif(not HAS_DEPS, reason="optional dependencies not installed")
diff --git a/tests/_plugins/stateless/test_image.py b/tests/_plugins/stateless/test_image.py
index fb70c32af2f..8a9cfc9b762 100644
--- a/tests/_plugins/stateless/test_image.py
+++ b/tests/_plugins/stateless/test_image.py
@@ -11,7 +11,7 @@
 from marimo._runtime.runtime import Kernel
 from tests.conftest import ExecReqProvider
 
-HAS_DEPS = DependencyManager.has_numpy() and DependencyManager.has_pillow()
+HAS_DEPS = DependencyManager.numpy.has() and DependencyManager.pillow.has()
 
 
 async def test_image() -> None:
diff --git a/tests/_plugins/ui/_impl/dataframes/test_dataframe.py b/tests/_plugins/ui/_impl/dataframes/test_dataframe.py
index d869f206159..5f8b5e84d72 100644
--- a/tests/_plugins/ui/_impl/dataframes/test_dataframe.py
+++ b/tests/_plugins/ui/_impl/dataframes/test_dataframe.py
@@ -14,9 +14,9 @@
 )
 
 HAS_DEPS = (
-    DependencyManager.has_pandas()
-    and DependencyManager.has_numpy()
-    and DependencyManager.has_polars()
+    DependencyManager.pandas.has()
+    and DependencyManager.numpy.has()
+    and DependencyManager.polars.has()
 )
 
 if HAS_DEPS:
diff --git a/tests/_plugins/ui/_impl/dataframes/test_handlers.py b/tests/_plugins/ui/_impl/dataframes/test_handlers.py
index ae49833e2d3..e71c3f7ae13 100644
--- a/tests/_plugins/ui/_impl/dataframes/test_handlers.py
+++ b/tests/_plugins/ui/_impl/dataframes/test_handlers.py
@@ -28,7 +28,7 @@
     TransformType,
 )
 
-HAS_DEPS = DependencyManager.has_pandas() and DependencyManager.has_polars()
+HAS_DEPS = DependencyManager.pandas.has() and DependencyManager.polars.has()
 
 if HAS_DEPS:
     import pandas as pd
diff --git a/tests/_plugins/ui/_impl/tables/test_default_table.py b/tests/_plugins/ui/_impl/tables/test_default_table.py
index 68500fe5a9f..8d4f715b7b1 100644
--- a/tests/_plugins/ui/_impl/tables/test_default_table.py
+++ b/tests/_plugins/ui/_impl/tables/test_default_table.py
@@ -9,7 +9,7 @@
 from marimo._dependencies.dependencies import DependencyManager
 from marimo._plugins.ui._impl.tables.default_table import DefaultTableManager
 
-HAS_DEPS = DependencyManager.has_pandas()
+HAS_DEPS = DependencyManager.pandas.has()
 
 
 class TestDefaultTable(unittest.TestCase):
diff --git a/tests/_plugins/ui/_impl/tables/test_df_protocol.py b/tests/_plugins/ui/_impl/tables/test_df_protocol.py
index 2071d1d50ba..44b1acfe8c0 100644
--- a/tests/_plugins/ui/_impl/tables/test_df_protocol.py
+++ b/tests/_plugins/ui/_impl/tables/test_df_protocol.py
@@ -13,7 +13,7 @@
 )
 from marimo._plugins.ui._impl.tables.types import DataFrameLike
 
-HAS_DEPS = DependencyManager.has_pyarrow()
+HAS_DEPS = DependencyManager.pyarrow.has()
 
 
 @pytest.mark.skipif(not HAS_DEPS, reason="optional dependencies not installed")
diff --git a/tests/_plugins/ui/_impl/tables/test_pandas_table.py b/tests/_plugins/ui/_impl/tables/test_pandas_table.py
index 17925c163f7..63ef2b4c6d1 100644
--- a/tests/_plugins/ui/_impl/tables/test_pandas_table.py
+++ b/tests/_plugins/ui/_impl/tables/test_pandas_table.py
@@ -11,7 +11,7 @@
     PandasTableManagerFactory,
 )
 
-HAS_DEPS = DependencyManager.has_pandas()
+HAS_DEPS = DependencyManager.pandas.has()
 
 
 @pytest.mark.skipif(not HAS_DEPS, reason="optional dependencies not installed")
diff --git a/tests/_plugins/ui/_impl/tables/test_polars_table.py b/tests/_plugins/ui/_impl/tables/test_polars_table.py
index 6f15d29d724..88006792e99 100644
--- a/tests/_plugins/ui/_impl/tables/test_polars_table.py
+++ b/tests/_plugins/ui/_impl/tables/test_polars_table.py
@@ -15,7 +15,7 @@
 from marimo._plugins.ui._impl.tables.table_manager import TableManager
 from tests.mocks import snapshotter
 
-HAS_DEPS = DependencyManager.has_polars()
+HAS_DEPS = DependencyManager.polars.has()
 
 snapshot = snapshotter(__file__)
 
diff --git a/tests/_plugins/ui/_impl/tables/test_pyarrow.py b/tests/_plugins/ui/_impl/tables/test_pyarrow.py
index f6ade0e3ed7..47f0449179a 100644
--- a/tests/_plugins/ui/_impl/tables/test_pyarrow.py
+++ b/tests/_plugins/ui/_impl/tables/test_pyarrow.py
@@ -11,9 +11,9 @@
     PyArrowTableManagerFactory,
 )
 
-HAS_DEPS_TABLE = DependencyManager.has_pyarrow()
+HAS_DEPS_TABLE = DependencyManager.pyarrow.has()
 HAS_DEPS_RECORD_BATCH = (
-    DependencyManager.has_pandas() and DependencyManager.has_pyarrow()
+    DependencyManager.pandas.has() and DependencyManager.pyarrow.has()
 )
 
 
diff --git a/tests/_plugins/ui/_impl/test_altair_chart.py b/tests/_plugins/ui/_impl/test_altair_chart.py
index 5c2bc07c325..e0434a4bd25 100644
--- a/tests/_plugins/ui/_impl/test_altair_chart.py
+++ b/tests/_plugins/ui/_impl/test_altair_chart.py
@@ -12,7 +12,7 @@
 from marimo._runtime.runtime import Kernel
 from tests.conftest import ExecReqProvider
 
-HAS_DEPS = DependencyManager.has_pandas() and DependencyManager.has_altair()
+HAS_DEPS = DependencyManager.pandas.has() and DependencyManager.altair.has()
 
 if HAS_DEPS:
     import pandas as pd
diff --git a/tests/_plugins/ui/_impl/test_anywidget.py b/tests/_plugins/ui/_impl/test_anywidget.py
index 5602fafa887..341db3de7e7 100644
--- a/tests/_plugins/ui/_impl/test_anywidget.py
+++ b/tests/_plugins/ui/_impl/test_anywidget.py
@@ -8,7 +8,7 @@
 from marimo._runtime.runtime import Kernel
 from tests.conftest import ExecReqProvider
 
-HAS_DEPS = DependencyManager.has_anywidget()
+HAS_DEPS = DependencyManager.anywidget.has()
 
 if HAS_DEPS:
     import anywidget as _anywidget
diff --git a/tests/_plugins/ui/_impl/test_data_explorer.py b/tests/_plugins/ui/_impl/test_data_explorer.py
index 79d0f4fb8c7..f9dde16414f 100644
--- a/tests/_plugins/ui/_impl/test_data_explorer.py
+++ b/tests/_plugins/ui/_impl/test_data_explorer.py
@@ -7,7 +7,7 @@
 from marimo._plugins.ui._impl import data_explorer
 from marimo._runtime.runtime import Kernel
 
-HAS_DEPS = DependencyManager.has_pandas()
+HAS_DEPS = DependencyManager.pandas.has()
 
 
 @pytest.mark.skipif(not HAS_DEPS, reason="optional dependencies not installed")
diff --git a/tests/_plugins/ui/_impl/test_input.py b/tests/_plugins/ui/_impl/test_input.py
index 3c370c05d58..be2d1da27a8 100644
--- a/tests/_plugins/ui/_impl/test_input.py
+++ b/tests/_plugins/ui/_impl/test_input.py
@@ -9,7 +9,7 @@
 from marimo._dependencies.dependencies import DependencyManager
 from marimo._plugins import ui
 
-HAS_PANDAS = DependencyManager.has_pandas()
+HAS_PANDAS = DependencyManager.pandas.has()
 
 
 def test_number_init() -> None:
diff --git a/tests/_plugins/ui/_impl/utils/test_dataframe_utils.py b/tests/_plugins/ui/_impl/utils/test_dataframe_utils.py
index 1c727e1a8dd..d257e79496a 100644
--- a/tests/_plugins/ui/_impl/utils/test_dataframe_utils.py
+++ b/tests/_plugins/ui/_impl/utils/test_dataframe_utils.py
@@ -8,7 +8,7 @@
 from marimo._dependencies.dependencies import DependencyManager
 from marimo._plugins.ui._impl.tables.utils import get_table_manager
 
-HAS_PANDAS = DependencyManager.has_pandas()
+HAS_PANDAS = DependencyManager.pandas.has()
 
 
 def _get_row_headers(
@@ -55,3 +55,23 @@ def test_get_row_headers_pandas() -> None:
 def test_get_row_headers_list() -> None:
     # Test with non-DataFrame input
     assert _get_row_headers([1, 2, 3]) == []
+
+
+@pytest.mark.skipif(
+    not HAS_PANDAS, reason="optional dependencies not installed"
+)
+def test_get_table_manager() -> None:
+    import narwhals as nw
+    import pandas as pd
+    import polars as pl
+    import pyarrow as pa
+
+    df = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]})
+    assert get_table_manager(df) is not None
+    assert get_table_manager(pl.from_pandas(df)) is not None
+    assert get_table_manager(pa.table(df)) is not None
+
+    # Test with narwhals DataFrame
+    assert get_table_manager(nw.from_native(df)) is not None
+    assert get_table_manager(nw.from_native(pa.table(df))) is not None
+    assert get_table_manager(nw.from_native(pl.from_pandas(df))) is not None
diff --git a/tests/_runtime/reload/test_module_watcher.py b/tests/_runtime/reload/test_module_watcher.py
index f05f0f35992..3d9d0e6e72f 100644
--- a/tests/_runtime/reload/test_module_watcher.py
+++ b/tests/_runtime/reload/test_module_watcher.py
@@ -346,7 +346,7 @@ async def test_reload_package(
 
 
 @pytest.mark.skipif(
-    not DependencyManager.has_numpy(), reason="NumPy not installed"
+    not DependencyManager.numpy.has(), reason="NumPy not installed"
 )
 async def test_reload_third_party(
     tmp_path: pathlib.Path,
diff --git a/tests/_server/api/endpoints/test_ai.py b/tests/_server/api/endpoints/test_ai.py
index 44bd4c70e2b..12d1856faf8 100644
--- a/tests/_server/api/endpoints/test_ai.py
+++ b/tests/_server/api/endpoints/test_ai.py
@@ -24,7 +24,7 @@
     **token_header("fake-token"),
 }
 
-HAS_DEPS = DependencyManager.has_openai()
+HAS_DEPS = DependencyManager.openai.has()
 
 
 @dataclass
diff --git a/tests/_smoke_tests/test_run_as_script.py b/tests/_smoke_tests/test_run_as_script.py
index 40d18374eb4..b20f5e0414c 100644
--- a/tests/_smoke_tests/test_run_as_script.py
+++ b/tests/_smoke_tests/test_run_as_script.py
@@ -90,7 +90,7 @@ def test_run_layout_tutorial(self, tmp_path: pathlib.Path) -> None:
         self.assert_not_errored(p)
 
     @pytest.mark.skipif(
-        condition=not DependencyManager.has_matplotlib(),
+        condition=not DependencyManager.matplotlib.has(),
         reason="requires matplotlib",
     )
     @pytest.mark.skipif(
diff --git a/tests/_sql/test_sql.py b/tests/_sql/test_sql.py
index 0ea7e407175..02d3bdda022 100644
--- a/tests/_sql/test_sql.py
+++ b/tests/_sql/test_sql.py
@@ -9,7 +9,7 @@
 from marimo._plugins import ui
 from marimo._sql.sql import _query_includes_limit, sql
 
-HAS_DEPS = DependencyManager.has_duckdb() and DependencyManager.has_polars()
+HAS_DEPS = DependencyManager.duckdb.has() and DependencyManager.polars.has()
 
 
 @pytest.mark.skipif(not HAS_DEPS, reason="pandas or polars is required")


Code review comments:
diff_hunk: @@ -365,7 +365,7 @@ def visit_Call(self, node: ast.Call) -> None:
             elif isinstance(first_arg, ast.JoinedStr):
                 sql = normalize_sql_f_string(first_arg)
 
-            if isinstance(sql, str) and DependencyManager.has_duckdb() and sql:
+            if isinstance(sql, str) and DependencyManager.duckdb.has() and sql:
path: marimo/_ast/visitor.py
text: @user1:
Should this be `has_at_version`? `extract_statements` isn't available in some earlier versions of `duckdb`.

@author:
yea i can add it here too