duckdb-sqlalchemy 0.19.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,251 @@
1
+ import decimal
2
+ import json
3
+ import warnings
4
+ from typing import Any, Callable, Dict, Type, cast
5
+ from uuid import uuid4
6
+
7
+ from packaging.version import Version
8
+ from pytest import importorskip, mark
9
+ from pytest_snapshot.plugin import Snapshot
10
+ from sqlalchemy import (
11
+ Column,
12
+ Integer,
13
+ Interval,
14
+ MetaData,
15
+ Sequence,
16
+ String,
17
+ Table,
18
+ inspect,
19
+ schema,
20
+ select,
21
+ text,
22
+ )
23
+ from sqlalchemy.dialects.postgresql import UUID
24
+ from sqlalchemy.engine import Engine, create_engine
25
+ from sqlalchemy.ext.declarative import declarative_base
26
+ from sqlalchemy.orm import Session
27
+ from sqlalchemy.sql import sqltypes
28
+ from sqlalchemy.types import FLOAT, JSON
29
+
30
+ from .._supports import duckdb_version, has_uhugeint_support
31
+ from ..datatypes import Map, Struct, types
32
+
33
+
34
+ @mark.parametrize("coltype", types)
35
+ @mark.skipif(not has_uhugeint_support, reason="duckdb version too old")
36
+ def test_unsigned_integer_type(
37
+ engine: Engine, session: Session, coltype: Type[Integer]
38
+ ) -> None:
39
+ Base = declarative_base()
40
+
41
+ tname = "table"
42
+ table = type(
43
+ "Table",
44
+ (Base,),
45
+ {
46
+ "__tablename__": tname,
47
+ "id": Column(Integer, primary_key=True, default=0),
48
+ "a": Column(coltype),
49
+ },
50
+ )
51
+ Base.metadata.create_all(engine)
52
+
53
+ has_table = cast(
54
+ Callable[[str], bool],
55
+ engine.has_table if hasattr(engine, "has_table") else inspect(engine).has_table,
56
+ )
57
+
58
+ assert has_table(tname)
59
+
60
+ session.add(table(a=1))
61
+ session.commit()
62
+
63
+ assert session.query(table).one()
64
+
65
+
66
+ @mark.remote_data()
67
+ def test_raw_json(engine: Engine) -> None:
68
+ importorskip("duckdb", "0.9.3.dev4040")
69
+
70
+ with engine.connect() as conn:
71
+ assert conn.execute(text("load json"))
72
+
73
+ assert conn.execute(text("select {'Hello': 'world'}::JSON")).fetchone() == (
74
+ {"Hello": "world"},
75
+ )
76
+
77
+
78
+ @mark.remote_data()
79
+ def test_custom_json_serializer() -> None:
80
+ def default(o: Any) -> Any:
81
+ if isinstance(o, decimal.Decimal):
82
+ return {"__tag": "decimal", "value": str(o)}
83
+
84
+ def object_hook(pairs: Dict[str, Any]) -> Any:
85
+ if pairs.get("__tag", None) == "decimal":
86
+ return decimal.Decimal(pairs["value"])
87
+ else:
88
+ return pairs
89
+
90
+ engine = create_engine(
91
+ "duckdb://",
92
+ json_serializer=json.JSONEncoder(default=default).encode,
93
+ json_deserializer=json.JSONDecoder(object_hook=object_hook).decode,
94
+ )
95
+
96
+ Base = declarative_base()
97
+
98
+ class Entry(Base):
99
+ __tablename__ = "test_json"
100
+ id = Column(Integer, Sequence("id_seq"), primary_key=True)
101
+ data = Column(JSON, nullable=False)
102
+
103
+ Base.metadata.create_all(engine)
104
+
105
+ with engine.connect() as conn:
106
+ session = Session(bind=conn)
107
+
108
+ data = {"hello": decimal.Decimal("42")}
109
+
110
+ session.add(Entry(data=data)) # type: ignore[call-arg]
111
+ session.commit()
112
+
113
+ (res,) = session.execute(select(Entry)).one()
114
+
115
+ assert res.data == data
116
+
117
+
118
+ def test_json(engine: Engine, session: Session) -> None:
119
+ base = declarative_base()
120
+
121
+ class Entry(base):
122
+ __tablename__ = "test_json"
123
+
124
+ id = Column(Integer, primary_key=True, default=0)
125
+ meta = Column(JSON, nullable=False)
126
+
127
+ base.metadata.create_all(bind=engine)
128
+
129
+ session.add(Entry(meta={"hello": "world"})) # type: ignore[call-arg]
130
+ session.commit()
131
+
132
+ result = session.query(Entry).one()
133
+
134
+ assert result.meta == {"hello": "world"}
135
+
136
+
137
+ def test_uuid(engine: Engine, session: Session) -> None:
138
+ importorskip("duckdb", "0.7.1")
139
+ base = declarative_base()
140
+
141
+ class Entry(base):
142
+ __tablename__ = "test_uuid"
143
+
144
+ id = Column(UUID, primary_key=True, default=0)
145
+
146
+ base.metadata.create_all(bind=engine)
147
+
148
+ ident = uuid4()
149
+
150
+ session.add(Entry(id=ident)) # type: ignore[call-arg]
151
+ session.commit()
152
+
153
+ result = session.query(Entry).one()
154
+
155
+ assert result.id == ident
156
+
157
+
158
+ def test_double_in_sqla_v2(engine: Engine) -> None:
159
+ with engine.begin() as con:
160
+ con.execute(text("CREATE TABLE t (x DOUBLE)"))
161
+ con.execute(text("INSERT INTO t VALUES (1.0), (2.0), (3.0)"))
162
+
163
+ md = MetaData()
164
+
165
+ t = Table("t", md, autoload_with=engine)
166
+
167
+ with engine.begin() as con:
168
+ con.execute(t.select())
169
+
170
+
171
+ def test_all_types_reflection(engine: Engine) -> None:
172
+ importorskip("sqlalchemy", "1.4.0")
173
+ importorskip("duckdb", "0.5.1")
174
+
175
+ with warnings.catch_warnings() as capture, engine.connect() as conn:
176
+ conn.execute(text("create table t2 as select * from test_all_types()"))
177
+ table = Table("t2", MetaData(), autoload_with=conn)
178
+ for col in table.columns:
179
+ name = col.name
180
+ if name.endswith("_enum") and duckdb_version < Version("0.7.1"):
181
+ continue
182
+ if "array" in name or "struct" in name or "map" in name or "union" in name:
183
+ assert col.type == sqltypes.NULLTYPE, name
184
+ else:
185
+ assert col.type != sqltypes.NULLTYPE, name
186
+ assert not capture
187
+
188
+
189
+ def test_nested_types(engine: Engine, session: Session) -> None:
190
+ importorskip("duckdb", "0.5.0") # nested types require at least duckdb 0.5.0
191
+ base = declarative_base()
192
+
193
+ class Entry(base):
194
+ __tablename__ = "test_struct"
195
+
196
+ id = Column(Integer, primary_key=True, default=0)
197
+ struct = Column(Struct(fields={"name": String}))
198
+ map = Column(Map(String, Integer))
199
+ # union = Column(Union(fields={"name": String, "age": Integer}))
200
+
201
+ base.metadata.create_all(bind=engine)
202
+
203
+ struct_data = {"name": "Edgar"}
204
+ map_data = {"one": 1, "two": 2}
205
+
206
+ session.add(Entry(struct=struct_data, map=map_data)) # type: ignore[call-arg]
207
+ session.commit()
208
+
209
+ result = session.query(Entry).one()
210
+
211
+ assert result.struct == struct_data
212
+ assert result.map == map_data
213
+
214
+
215
+ def test_double_nested_types(engine: Engine, session: Session) -> None:
216
+ """Test for https://github.com/leonardovida/duckdb-sqlalchemy/issues/1138"""
217
+ importorskip("duckdb", "0.5.0") # nested types require at least duckdb 0.5.0
218
+ base = declarative_base()
219
+
220
+ class Entry(base):
221
+ __tablename__ = "test_struct"
222
+
223
+ id = Column(Integer, primary_key=True, default=0)
224
+ outer = Column(Struct({"inner": Struct({"val": Integer})}))
225
+
226
+ base.metadata.create_all(bind=engine)
227
+
228
+ outer = {"inner": {"val": 42}}
229
+
230
+ session.add(Entry(outer=outer)) # type: ignore[call-arg]
231
+ session.commit()
232
+
233
+ result = session.query(Entry).one()
234
+
235
+ assert result.outer == outer
236
+
237
+
238
+ def test_interval(engine: Engine, snapshot: Snapshot) -> None:
239
+ test_table = Table("test_table", MetaData(), Column("duration", Interval))
240
+ create_sql = f"{str(schema.CreateTable(test_table).compile(engine)).strip()}\n"
241
+ snapshot.assert_match(create_sql, "schema.sql")
242
+
243
+
244
+ def test_div_is_floordiv(engine: Engine) -> None:
245
+ test_table = Table(
246
+ "test_table", MetaData(), Column("value", FLOAT), Column("eur2usd_rate", FLOAT)
247
+ )
248
+
249
+ stmt = test_table.c.value / test_table.c.eur2usd_rate
250
+
251
+ assert str(stmt.compile(engine)) == "test_table.value / test_table.eur2usd_rate"
@@ -0,0 +1,32 @@
1
+ import importlib.util
2
+
3
+ import pytest
4
+ from sqlalchemy import Column, Integer, MetaData, String, Table, create_engine, select
5
+
6
+
7
+ def test_bulk_insert_register_path() -> None:
8
+ if (
9
+ importlib.util.find_spec("pandas") is None
10
+ and importlib.util.find_spec("pyarrow") is None
11
+ ):
12
+ pytest.skip("pandas or pyarrow is required for bulk insert fast path")
13
+
14
+ engine = create_engine("duckdb:///:memory:")
15
+ md = MetaData()
16
+ t = Table(
17
+ "bulk_insert",
18
+ md,
19
+ Column("id", Integer),
20
+ Column("name", String),
21
+ )
22
+ md.create_all(engine)
23
+
24
+ rows = [{"id": 1, "name": "Ada"}, {"id": 2, "name": "Grace"}]
25
+
26
+ with engine.begin() as conn:
27
+ conn = conn.execution_options(duckdb_copy_threshold=1)
28
+ conn.execute(t.insert(), rows)
29
+
30
+ with engine.connect() as conn:
31
+ result = conn.execute(select(t.c.id, t.c.name).order_by(t.c.id)).fetchall()
32
+ assert result == [(1, "Ada"), (2, "Grace")]
@@ -0,0 +1,71 @@
1
+ from urllib.parse import parse_qs
2
+
3
+ import sqlalchemy
4
+ from packaging.version import Version
5
+ from pytest import raises
6
+ from sqlalchemy import select
7
+
8
+ from duckdb_sqlalchemy import URL, Dialect, make_url, read_parquet
9
+ from duckdb_sqlalchemy.config import get_core_config
10
+
11
+
12
+ def test_url_helper_round_trip() -> None:
13
+ url = URL(database=":memory:", read_only=True)
14
+ assert url.drivername == "duckdb"
15
+ assert url.database == ":memory:"
16
+ assert str(url.query["read_only"]).lower() == "true"
17
+
18
+ base = make_url(database=":memory:")
19
+ rendered = base.render_as_string(hide_password=False)
20
+ assert rendered.startswith("duckdb:///")
21
+
22
+
23
+ def test_read_parquet_helper() -> None:
24
+ if Version(sqlalchemy.__version__) < Version("1.4.0"):
25
+ with raises(NotImplementedError):
26
+ read_parquet("data/events.parquet", columns=["event_id"])
27
+ return
28
+
29
+ parquet = read_parquet("data/events.parquet", columns=["event_id"])
30
+ stmt = select(parquet.c.event_id).select_from(parquet)
31
+ sql = str(stmt.compile(dialect=Dialect(), compile_kwargs={"literal_binds": True}))
32
+ assert "read_parquet" in sql
33
+
34
+
35
+ def test_motherduck_config_env_and_ttl(monkeypatch) -> None:
36
+ get_core_config()
37
+
38
+ captured = {}
39
+
40
+ class DummyConn:
41
+ def execute(self, *args, **kwargs):
42
+ return self
43
+
44
+ def register_filesystem(self, filesystem):
45
+ return None
46
+
47
+ def close(self):
48
+ return None
49
+
50
+ def fake_connect(*cargs, **cparams):
51
+ captured.update(cparams)
52
+ return DummyConn()
53
+
54
+ import duckdb_sqlalchemy
55
+
56
+ monkeypatch.setenv("motherduck_token", "token123")
57
+ monkeypatch.delenv("MOTHERDUCK_TOKEN", raising=False)
58
+ monkeypatch.setattr(duckdb_sqlalchemy.duckdb, "connect", fake_connect)
59
+
60
+ dialect = Dialect()
61
+ dialect.connect(
62
+ database="md:my_db",
63
+ url_config={"dbinstance_inactivity_ttl": "1h"},
64
+ config={},
65
+ )
66
+
67
+ assert captured["config"]["motherduck_token"] == "token123"
68
+ database, query = captured["database"].split("?", 1)
69
+ assert database == "md:my_db"
70
+ assert parse_qs(query)["dbinstance_inactivity_ttl"] == ["1h"]
71
+ assert "motherduck_dbinstance_inactivity_ttl" not in captured["config"]
@@ -0,0 +1,46 @@
1
+ import duckdb
2
+ import pandas as pd
3
+ from pytest import mark, raises
4
+ from sqlalchemy import __version__, text
5
+ from sqlalchemy.engine import create_engine
6
+ from sqlalchemy.engine.base import Connection
7
+ from sqlalchemy.exc import ProgrammingError
8
+
9
+ df = pd.DataFrame([{"a": 1}])
10
+
11
+
12
+ @mark.skipif(not hasattr(Connection, "exec_driver_sql"), reason="Needs exec_driver_sql")
13
+ def test_register_driver(conn: Connection) -> None:
14
+ conn.exec_driver_sql("register", ("test_df_driver", df)) # type: ignore[arg-type]
15
+ conn.execute(text("select * from test_df_driver"))
16
+
17
+
18
+ def test_plain_register(conn: Connection) -> None:
19
+ if __version__.startswith("1.3"):
20
+ conn.execute(text("register"), {"name": "test_df", "df": df})
21
+ else:
22
+ conn.execute(text("register(:name, :df)"), {"name": "test_df", "df": df})
23
+ conn.execute(text("select * from test_df"))
24
+
25
+
26
+ duckdb_version = duckdb.__version__
27
+
28
+
29
+ @mark.remote_data
30
+ @mark.skipif(
31
+ "dev" in duckdb_version, reason="md extension not available for dev builds"
32
+ )
33
+ @mark.skipif(
34
+ duckdb_version != "1.1.1", reason="md extension not available for this version"
35
+ )
36
+ def test_motherduck() -> None:
37
+ engine = create_engine(
38
+ "duckdb:///md:motherdb",
39
+ connect_args={"config": {"motherduck_token": "motherduckdb_token"}},
40
+ )
41
+
42
+ with raises(
43
+ ProgrammingError,
44
+ match="Jwt is not in the form of Header.Payload.Signature with two dots and 3 sections",
45
+ ):
46
+ engine.connect()
@@ -0,0 +1,146 @@
1
+ #! /usr/bin/env python3
2
+ # vim:fenc=utf-8
3
+
4
+ """
5
+ Test pandas functionality.
6
+ """
7
+
8
+ import pathlib
9
+ import random
10
+ from collections import OrderedDict
11
+ from datetime import datetime, timezone
12
+ from itertools import product
13
+ from typing import Dict, List, Optional, Tuple, Union, cast
14
+
15
+ import pandas as pd
16
+ from pandas.testing import assert_frame_equal
17
+ from pytest import importorskip, mark
18
+ from sqlalchemy import create_engine
19
+
20
+ from .util import sqlalchemy_1_only
21
+
22
+ pytestmark = sqlalchemy_1_only
23
+
24
+ _possible_args = OrderedDict(
25
+ {
26
+ "chunksize": [None, 1, 10, 100],
27
+ "if_exists": ["fail", "replace", "append"],
28
+ "method": [
29
+ None,
30
+ "multi",
31
+ ], # TODO Implement a callable insert method?
32
+ }
33
+ )
34
+
35
+ args = {
36
+ "to_sql": ["chunksize", "if_exists", "method"],
37
+ "read_sql": ["chunksize"],
38
+ }
39
+ params = {
40
+ k: list(
41
+ product(*(_v for _k, _v in _possible_args.items() if _k in args[k])) # type: ignore
42
+ )
43
+ for k in args
44
+ }
45
+ params_strings = {k: (",".join([str(_k) for _k in args[k]])) for k in args}
46
+
47
+ # Generate a DataFrame of 100 rows.
48
+ sample_data: Dict[str, List[Union[datetime, str, int, float]]] = {
49
+ "datetime": [],
50
+ "int": [],
51
+ "str": [],
52
+ "float": [],
53
+ }
54
+ sample_rowcount = max(
55
+ cs
56
+ for cs in cast(List[Optional[int]], _possible_args["chunksize"])
57
+ if cs is not None
58
+ )
59
+ for i in range(sample_rowcount):
60
+ sample_data["datetime"].append(datetime.now(timezone.utc))
61
+ sample_data["int"].append(random.randint(0, 100))
62
+ sample_data["float"].append(round(random.random(), 5))
63
+ sample_data["str"].append("foo")
64
+
65
+ sample_df: pd.DataFrame = pd.DataFrame(sample_data)
66
+
67
+
68
+ @mark.parametrize(params_strings["to_sql"], params["to_sql"])
69
+ def test_to_sql(
70
+ chunksize: Optional[int],
71
+ if_exists: str,
72
+ method: Optional[str],
73
+ index: bool = False,
74
+ ) -> None:
75
+ eng = create_engine("duckdb:///:memory:")
76
+ try:
77
+ sample_df.to_sql(
78
+ name="foo",
79
+ con=eng,
80
+ if_exists=if_exists,
81
+ chunksize=chunksize,
82
+ index=index,
83
+ )
84
+ except ValueError as e:
85
+ if if_exists != "fail":
86
+ raise e
87
+
88
+
89
+ table_name = "test_read"
90
+
91
+
92
+ # Perform the test twice:
93
+ # Once for reading the table name (testing reflection),
94
+ @mark.parametrize(params_strings["read_sql"], params["read_sql"])
95
+ def test_read_sql_reflection(
96
+ chunksize: Tuple[Optional[int]],
97
+ ) -> None:
98
+ run_query(table_name, chunksize[0])
99
+
100
+
101
+ # and once for directly executing a SQL query.
102
+ @mark.parametrize(params_strings["read_sql"], params["read_sql"])
103
+ def test_read_sql(
104
+ chunksize: Tuple[Optional[int]],
105
+ ) -> None:
106
+ run_query(f"SELECT * FROM {table_name}", chunksize[0])
107
+
108
+
109
+ def run_query(query: str, chunksize: Optional[int]) -> None:
110
+ eng = create_engine("duckdb:///:memory:")
111
+
112
+ sample_df.to_sql(name=table_name, con=eng, if_exists="replace")
113
+
114
+ result = pd.read_sql(query, eng, chunksize=chunksize)
115
+ chunks = [result] if chunksize is None else list(result)
116
+ if chunksize is None:
117
+ df = cast(pd.DataFrame, chunks[0])
118
+ assert len(df) == sample_rowcount
119
+ else:
120
+ importorskip("duckdb", "0.5.0")
121
+
122
+ # Assert that the chunks are the size specified.
123
+ df = cast(pd.DataFrame, chunks[0])
124
+ assert len(df) == chunksize
125
+ # Assert that the expected number of chunks was returned.
126
+ assert (sample_rowcount / chunksize) == len(chunks)
127
+
128
+
129
+ def test_read_sql_duckdb_table(tmp_path: pathlib.Path) -> None:
130
+ import duckdb
131
+
132
+ db = str(tmp_path / "test_db.duckdb")
133
+ con = duckdb.connect(database=db, read_only=False)
134
+ df = pd.DataFrame(
135
+ {"a": [1, 2, 3, 4], "b": [0.1, 0.2, 0.3, 0.4], "c": ["a", "b", "c", "d"]}
136
+ )
137
+ con.register("df_view", df)
138
+ con.execute("CREATE TABLE test_data AS SELECT * FROM df_view;")
139
+ con.close()
140
+
141
+ engine = create_engine(f"duckdb:///{db}")
142
+
143
+ result = pd.read_sql("SELECT * FROM test_data", engine)
144
+ assert_frame_equal(result, df)
145
+ result = pd.read_sql("test_data", engine)
146
+ assert_frame_equal(result, df)
@@ -0,0 +1,61 @@
1
+ from pyarrow import RecordBatch, RecordBatchReader
2
+ from pyarrow import Table as ArrowTable
3
+ from sqlalchemy import MetaData, Table, create_engine, text
4
+
5
+
6
+ def test_fetch_arrow() -> None:
7
+ engine = create_engine("duckdb:///:memory:")
8
+ with engine.begin() as con:
9
+ con.execute(text("CREATE TABLE tbl (label VARCHAR, value DOUBLE)"))
10
+ con.execute(
11
+ text(
12
+ "INSERT INTO tbl VALUES ('xx',-1.0), ('ww',-4.5), ('zz',6.0), ('yy',2.5)"
13
+ )
14
+ )
15
+
16
+ md = MetaData()
17
+ t = Table("tbl", md, autoload_with=engine)
18
+ stmt = t.select().where(t.c.value > -4.0).order_by(t.c.label)
19
+
20
+ # rows
21
+ with engine.begin() as con:
22
+ res = con.execute(stmt).cursor.fetchall()
23
+ assert res == [("xx", -1.0), ("yy", 2.5), ("zz", 6.0)]
24
+
25
+ # arrow table
26
+ with engine.begin() as con:
27
+ res = con.execute(stmt).cursor.fetch_arrow_table()
28
+ assert isinstance(res, ArrowTable)
29
+ assert res == ArrowTable.from_pydict(
30
+ {"label": ["xx", "yy", "zz"], "value": [-1.0, 2.5, 6.0]}
31
+ )
32
+
33
+ # arrow batches
34
+ with engine.begin() as con:
35
+ res = con.execute(stmt).cursor.fetch_record_batch()
36
+ assert isinstance(res, RecordBatchReader)
37
+ assert res.read_all() == ArrowTable.from_pydict(
38
+ {"label": ["xx", "yy", "zz"], "value": [-1.0, 2.5, 6.0]}
39
+ )
40
+ res = con.execute(stmt).cursor.fetch_record_batch(rows_per_batch=2)
41
+ assert res.read_next_batch() == RecordBatch.from_pydict(
42
+ {"label": ["xx", "yy"], "value": [-1.0, 2.5]}
43
+ )
44
+ assert res.read_next_batch() == RecordBatch.from_pydict(
45
+ {"label": ["zz"], "value": [6.0]}
46
+ )
47
+
48
+
49
+ def test_arrow_execution_option() -> None:
50
+ engine = create_engine("duckdb:///:memory:")
51
+ with engine.begin() as con:
52
+ con.execute(text("CREATE TABLE tbl (label VARCHAR, value DOUBLE)"))
53
+ con.execute(text("INSERT INTO tbl VALUES ('aa', 1.0), ('bb', 2.0)"))
54
+
55
+ with engine.connect().execution_options(duckdb_arrow=True) as con:
56
+ result = con.execute(text("SELECT * FROM tbl ORDER BY label"))
57
+ table = result.arrow
58
+ assert isinstance(table, ArrowTable)
59
+ assert table == ArrowTable.from_pydict(
60
+ {"label": ["aa", "bb"], "value": [1.0, 2.0]}
61
+ )
@@ -0,0 +1,9 @@
1
+ import sqlalchemy
2
+ from packaging.version import Version
3
+ from pytest import mark
4
+
5
+ is_sqlalchemy_1 = Version(sqlalchemy.__version__).major == 1
6
+ sqlalchemy_1_only = mark.skipif(
7
+ not is_sqlalchemy_1,
8
+ reason="Pandas doesn't yet support sqlalchemy 2+",
9
+ )
@@ -0,0 +1,59 @@
1
+ from typing import Any, Mapping, Optional, Sequence
2
+
3
+ from sqlalchemy.engine import URL as SAURL
4
+
5
+ __all__ = ["URL", "make_url"]
6
+
7
+
8
+ def URL(
9
+ *,
10
+ database: str = ":memory:",
11
+ query: Optional[Mapping[str, Any]] = None,
12
+ **kwargs: Any,
13
+ ) -> SAURL:
14
+ """
15
+ Build a SQLAlchemy URL for duckdb-sqlalchemy.
16
+
17
+ All keyword arguments are treated as DuckDB config options (URL query params).
18
+ """
19
+
20
+ def _stringify(value: Any) -> str:
21
+ if isinstance(value, bool):
22
+ return "true" if value else "false"
23
+ return str(value)
24
+
25
+ def _coerce(value: Any) -> Any:
26
+ if value is None:
27
+ return None
28
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
29
+ return tuple(_stringify(v) for v in value)
30
+ return _stringify(value)
31
+
32
+ query_dict: dict[str, str | tuple[str, ...]] = {}
33
+ if query:
34
+ query_dict.update(
35
+ {
36
+ k: v
37
+ for k, v in ((k, _coerce(v)) for k, v in query.items())
38
+ if v is not None
39
+ }
40
+ )
41
+ if kwargs:
42
+ query_dict.update(
43
+ {
44
+ k: v
45
+ for k, v in ((k, _coerce(v)) for k, v in kwargs.items())
46
+ if v is not None
47
+ }
48
+ )
49
+
50
+ return SAURL.create("duckdb", database=database, query=query_dict)
51
+
52
+
53
+ def make_url(
54
+ *,
55
+ database: str = ":memory:",
56
+ query: Optional[Mapping[str, Any]] = None,
57
+ **kwargs: Any,
58
+ ) -> SAURL:
59
+ return URL(database=database, query=query, **kwargs)