Repository: ibis-project/ibis

Issue:
feat: support empty URL for local backends in ibis.connect()
### Is your feature request related to a problem?

often when testing against multiple backends, myself or a user will do something like:

```python

backend = "polars"

if backend == "polars":
    con = ibis.polars.connect()
...
```

Ibis supports a cleaner `ibis.connect()` that takes a backend-specific URI. those don't make sense for many of the local backends and are currently NotImplemented, but it would be nice for consistency and ease of use to support empty URIs for all of:

- pandas
- polars
- dask
- datafusion

and any others not supported now. should also support #8435 and #7710 once those are in (and any other future local backends)

### Describe the solution you'd like

```python

backend = "polars" # "duckdb" "pandas" "datafusion" "dask"

import ibis

con = ibis.connect(f"{backend}://")
```

adding anything to the URIs could warn or error 

### What version of ibis are you running?

main

### What backend(s) are you using, if any?

duckdb/pandas/datafusion/polars/dask/pyspark

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct

Discussion:

Patch:
diff --git a/ibis/backends/__init__.py b/ibis/backends/__init__.py
index e5c9798b2..6a443516c 100644
--- a/ibis/backends/__init__.py
+++ b/ibis/backends/__init__.py
@@ -1458,5 +1458,21 @@ class NoUrl:
 
     name: str
 
-    def _from_url(self, url: str, **_) -> ir.Table:
-        raise NotImplementedError(self.name)
+    def _from_url(self, url: str, **kwargs) -> BaseBackend:
+        """Connect to the backend with empty url.
+
+        Parameters
+        ----------
+        url : str
+            The URL with which to connect to the backend. This parameter is not used
+            in this method but is kept for consistency.
+        kwargs
+            Additional keyword arguments.
+
+        Returns
+        -------
+        BaseBackend
+            A backend instance
+
+        """
+        return self.connect(**kwargs)
diff --git a/ibis/backends/duckdb/__init__.py b/ibis/backends/duckdb/__init__.py
index 55f9aa1f2..d691d483d 100644
--- a/ibis/backends/duckdb/__init__.py
+++ b/ibis/backends/duckdb/__init__.py
@@ -987,7 +987,7 @@ class Backend(SQLBackend, CanCreateDatabase, CanCreateSchema, UrlFromPath):
         return self._filter_with_like(out[col].to_pylist(), like)
 
     def read_postgres(
-        self, uri: str, table_name: str | None = None, schema: str = "public"
+        self, uri: str, *, table_name: str | None = None, database: str = "public"
     ) -> ir.Table:
         """Register a table from a postgres instance into a DuckDB table.
 
@@ -997,8 +997,8 @@ class Backend(SQLBackend, CanCreateDatabase, CanCreateSchema, UrlFromPath):
             A postgres URI of the form `postgres://user:password@host:port`
         table_name
             The table to read
-        schema
-            PostgreSQL schema where `table_name` resides
+        database
+            PostgreSQL database (schema) where `table_name` resides
 
         Returns
         -------
@@ -1015,7 +1015,7 @@ class Backend(SQLBackend, CanCreateDatabase, CanCreateSchema, UrlFromPath):
         self._create_temp_view(
             table_name,
             sg.select(STAR).from_(
-                self.compiler.f.postgres_scan_pushdown(uri, schema, table_name)
+                self.compiler.f.postgres_scan_pushdown(uri, database, table_name)
             ),
         )
 
@@ -1023,8 +1023,8 @@ class Backend(SQLBackend, CanCreateDatabase, CanCreateSchema, UrlFromPath):
 
     def read_mysql(
         self,
-        *,
         uri: str,
+        *,
         catalog: str,
         table_name: str | None = None,
     ) -> ir.Table:
@@ -1060,9 +1060,11 @@ class Backend(SQLBackend, CanCreateDatabase, CanCreateSchema, UrlFromPath):
         with self._safe_raw_sql(query_con):
             pass
 
-        return self.table(table_name, database=(database, catalog))
+        return self.table(table_name, database=(catalog, database))
 
-    def read_sqlite(self, path: str | Path, table_name: str | None = None) -> ir.Table:
+    def read_sqlite(
+        self, path: str | Path, *, table_name: str | None = None
+    ) -> ir.Table:
         """Register a table from a SQLite database into a DuckDB table.
 
         Parameters
@@ -1090,7 +1092,7 @@ class Backend(SQLBackend, CanCreateDatabase, CanCreateSchema, UrlFromPath):
         ...     )  # doctest: +ELLIPSIS
         <...>
         >>> con = ibis.connect("duckdb://")
-        >>> t = con.read_sqlite("/tmp/sqlite.db", table_name="t")
+        >>> t = con.read_sqlite(path="/tmp/sqlite.db", table_name="t")
         >>> t
         ┏━━━━━━━┳━━━━━━━━┓
         ┃ a     ┃ b      ┃
diff --git a/ibis/common/graph.py b/ibis/common/graph.py
index c2aaf5d7e..f2f810077 100644
--- a/ibis/common/graph.py
+++ b/ibis/common/graph.py
@@ -11,7 +11,7 @@ from ibis.common.bases import Hashable
 from ibis.common.collections import frozendict
 from ibis.common.patterns import NoMatch, Pattern
 from ibis.common.typing import _ClassInfo
-from ibis.util import experimental
+from ibis.util import experimental, promote_list
 
 if TYPE_CHECKING:
     from typing_extensions import Self
@@ -340,9 +340,39 @@ class Node(Hashable):
         determined by a breadth-first search.
 
         """
-        nodes = Graph.from_bfs(self, filter=filter, context=context).nodes()
+        graph = Graph.from_bfs(self, filter=filter, context=context)
         finder = _coerce_finder(finder, context)
-        return [node for node in nodes if finder(node)]
+        return [node for node in graph.nodes() if finder(node)]
+
+    @experimental
+    def find_below(
+        self,
+        finder: FinderLike,
+        filter: Optional[FinderLike] = None,
+        context: Optional[dict] = None,
+    ) -> list[Node]:
+        """Find all nodes below the current node matching a given pattern in the graph.
+
+        A variant of find() that only returns nodes below the current node in the graph.
+
+        Parameters
+        ----------
+        finder
+            A type, tuple of types, a pattern or a callable to match upon.
+        filter
+            A type, tuple of types, a pattern or a callable to filter out nodes
+            from the traversal. The traversal will only visit nodes that match
+            the given filter and stop otherwise.
+        context
+            Optional context to use if `finder` or `filter` is a pattern.
+
+        Returns
+        -------
+        The list of nodes matching the given pattern.
+        """
+        graph = Graph.from_bfs(self.__children__, filter=filter, context=context)
+        finder = _coerce_finder(finder, context)
+        return [node for node in graph.nodes() if finder(node)]
 
     @experimental
     def find_topmost(
@@ -620,10 +650,8 @@ def bfs(root: Node) -> Graph:
     """
     # fast path for the default no filter case, according to benchmarks
     # this is gives a 10% speedup compared to the filtered version
-    if not isinstance(root, Node):
-        raise TypeError("node must be an instance of ibis.common.graph.Node")
-
-    queue = deque([root])
+    nodes = _flatten_collections(promote_list(root))
+    queue = deque(nodes)
     graph = Graph()
 
     while queue:
@@ -651,15 +679,10 @@ def bfs_while(root: Node, filter: Finder) -> Graph:
     A graph constructed from the root node.
 
     """
-    if not isinstance(root, Node):
-        raise TypeError("node must be an instance of ibis.common.graph.Node")
-
-    queue = deque()
+    nodes = _flatten_collections(promote_list(root))
+    queue = deque(node for node in nodes if filter(node))
     graph = Graph()
 
-    if filter(root):
-        queue.append(root)
-
     while queue:
         if (node := queue.popleft()) not in graph:
             children = tuple(child for child in node.__children__ if filter(child))
@@ -684,10 +707,8 @@ def dfs(root: Node) -> Graph:
     """
     # fast path for the default no filter case, according to benchmarks
     # this is gives a 10% speedup compared to the filtered version
-    if not isinstance(root, Node):
-        raise TypeError("node must be an instance of ibis.common.graph.Node")
-
-    stack = deque([root])
+    nodes = _flatten_collections(promote_list(root))
+    stack = deque(nodes)
     graph = {}
 
     while stack:
@@ -715,15 +736,10 @@ def dfs_while(root: Node, filter: Finder) -> Graph:
     A graph constructed from the root node.
 
     """
-    if not isinstance(root, Node):
-        raise TypeError("node must be an instance of ibis.common.graph.Node")
-
-    stack = deque()
+    nodes = _flatten_collections(promote_list(root))
+    stack = deque(node for node in nodes if filter(node))
     graph = {}
 
-    if filter(root):
-        stack.append(root)
-
     while stack:
         if (node := stack.pop()) not in graph:
             children = tuple(child for child in node.__children__ if filter(child))


Test patch:
diff --git a/ibis/backends/duckdb/tests/test_register.py b/ibis/backends/duckdb/tests/test_register.py
index e242d8bf9..3f4356a1b 100644
--- a/ibis/backends/duckdb/tests/test_register.py
+++ b/ibis/backends/duckdb/tests/test_register.py
@@ -165,12 +165,15 @@ def test_temp_directory(tmp_path):
 
 @pytest.fixture(scope="session")
 def pgurl():  # pragma: no cover
-    pgcon = ibis.postgres.connect(user="postgres", password="postgres")
+    pgcon = ibis.postgres.connect(
+        user="postgres", password="postgres", host="localhost"
+    )
+
     df = pd.DataFrame({"x": [1.0, 2.0, 3.0, 1.0], "y": ["a", "b", "c", "a"]})
-    s = ibis.schema(dict(x="float64", y="str"))
 
-    pgcon.create_table("duckdb_test", df, s, force=True)
-    yield pgcon.con.url
+    pgcon.create_table("duckdb_test", df, overwrite=True)
+    yield pgcon.con.info
+
     pgcon.drop_table("duckdb_test", force=True)
 
 
@@ -182,7 +185,7 @@ def test_read_postgres(con, pgurl):  # pragma: no cover
     # container up just for this test.  To run locally set env variable to True
     # and once a postgres container is up run the test.
     table = con.read_postgres(
-        f"postgres://{pgurl.username}:{pgurl.password}@{pgurl.host}:{pgurl.port}",
+        f"postgres://{pgurl.user}:{pgurl.password}@{pgurl.host}:{pgurl.port}",
         table_name="duckdb_test",
     )
     assert table.count().execute()
@@ -204,6 +207,7 @@ def mysqlurl():  # pragma: no cover
     os.environ.get("DUCKDB_MYSQL") is None, reason="avoiding CI shenanigans"
 )
 def test_read_mysql(con, mysqlurl):  # pragma: no cover
+    # to run this test run first the mysql test suit to get the ibis-testing
     # we don't run this test in CI, only locally, to avoid bringing a mysql
     # container up just for this test.  To run locally set env variable to True
     # and once a mysql container is up run the test.
@@ -213,7 +217,7 @@ def test_read_mysql(con, mysqlurl):  # pragma: no cover
     hostname = "127.0.0.1"
 
     table = con.read_mysql(
-        uri=f"mysql://{mysqlurl.user.decode()}:{mysqlurl.password.decode()}@{hostname}:{mysqlurl.port}/ibis_testing",
+        f"mysql://{mysqlurl.user.decode()}:{mysqlurl.password.decode()}@{hostname}:{mysqlurl.port}/ibis_testing",
         catalog="mysqldb",
         table_name="duckdb_test",
     )
@@ -234,9 +238,6 @@ def test_read_sqlite(con, tmp_path):
     ft = con.read_sqlite(path, table_name="t")
     assert ft.count().execute()
 
-    with pytest.raises(ValueError):
-        con.read_sqlite(path)
-
 
 def test_read_sqlite_no_table_name(con, tmp_path):
     path = tmp_path / "test.db"
diff --git a/ibis/backends/tests/test_client.py b/ibis/backends/tests/test_client.py
index cfb69d3f0..361d216c0 100644
--- a/ibis/backends/tests/test_client.py
+++ b/ibis/backends/tests/test_client.py
@@ -711,12 +711,12 @@ def test_unsigned_integer_type(con, temp_table):
         ),
         param(
             "dask://",
-            marks=[mark.dask, mark.xfail(raises=NotImplementedError)],
+            marks=mark.dask,
             id="dask",
         ),
         param(
             "datafusion://",
-            marks=[mark.datafusion, mark.xfail(raises=NotImplementedError)],
+            marks=mark.datafusion,
             id="datafusion",
         ),
         param(
@@ -731,9 +731,14 @@ def test_unsigned_integer_type(con, temp_table):
         ),
         param(
             "pandas://",
-            marks=[mark.pandas, mark.xfail(raises=NotImplementedError)],
+            marks=mark.pandas,
             id="pandas",
         ),
+        param(
+            "polars://",
+            marks=mark.polars,
+            id="polars",
+        ),
         param(
             "postgres://postgres:postgres@localhost:5432",
             marks=mark.postgres,
diff --git a/ibis/common/tests/test_graph.py b/ibis/common/tests/test_graph.py
index 677f9f0f2..b0da3bdf2 100644
--- a/ibis/common/tests/test_graph.py
+++ b/ibis/common/tests/test_graph.py
@@ -59,11 +59,8 @@ A = MyNode(name="A", children=[B, C])
 
 def test_bfs():
     assert list(bfs(A).keys()) == [A, B, C, D, E]
-
-    with pytest.raises(
-        TypeError, match="must be an instance of ibis.common.graph.Node"
-    ):
-        bfs(1)
+    assert list(bfs([D, E, B])) == [D, E, B]
+    assert bfs(1) == {}
 
 
 def test_construction():
@@ -82,11 +79,8 @@ def test_graph_repr():
 
 def test_dfs():
     assert list(dfs(A).keys()) == [D, E, B, C, A]
-
-    with pytest.raises(
-        TypeError, match="must be an instance of ibis.common.graph.Node"
-    ):
-        dfs(1)
+    assert list(dfs([D, E, B])) == [D, E, B]
+    assert dfs(1) == {}
 
 
 def test_invert():
@@ -393,6 +387,16 @@ def test_node_find_using_pattern():
     assert result == [A, B]
 
 
+def test_node_find_below():
+    lowercase = MyNode(name="lowercase", children=[])
+    root = MyNode(name="root", children=[A, B, lowercase])
+    result = root.find_below(MyNode)
+    assert result == [A, B, lowercase, C, D, E]
+
+    result = root.find_below(lambda x: x.name.islower(), filter=lambda x: x != root)
+    assert result == [lowercase]
+
+
 def test_node_find_topmost_using_type():
     class FooNode(MyNode):
         pass