diff --git a/CHANGELOG.md b/CHANGELOG.md index 90899a8..461ebd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - Add an `encoding` kwarg on `MetricReader.open` and `MetricWriter.open` (#43) - Transparent gzip/bz2/xz support via `xopen` (#44) - Infer the delimiter from the file extension in `MetricReader.open`/`MetricWriter.open`; pass `delimiter=` to override (#61) +- Read metrics from a SQL query via `MetricReader.from_sql`/`Metric.from_sql`, backed by the optional `duckdb` extra; queries any source DuckDB can read (Parquet/Arrow/JSON/CSV/SQLite/Postgres/S3) (#52) ### Changed diff --git a/README.md b/README.md index 9d78e75..53d4a8d 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,32 @@ with MetricWriter.open(AlignmentMetric, "output.tsv.bz2") as writer: writer.writeall(metrics) ``` +### Reading from SQL (Parquet, Arrow, databases) + +With the optional `duckdb` extra (`pip install 'fgmetric[duckdb]'`), `MetricReader.from_sql` validates the rows of a SQL query as metrics. The query names its own source, so any backend [DuckDB](https://duckdb.org) can read works — Parquet, Arrow, JSON, CSV, SQLite, Postgres, and S3-hosted files — with no TSV intermediary: + +```python +# A Parquet file (a transient in-memory connection is opened and closed for you): +for metric in MetricReader.from_sql(AlignmentMetric, "SELECT * FROM 'metrics.parquet'"): + ... + +# Align a source's columns to your metric's fields with SQL `AS`: +metrics = AlignmentMetric.from_sql("SELECT read AS read_name, mapq AS mapping_quality FROM tbl") +``` + +Unlike `open`, `from_sql` is not a context manager — it owns no file handle and returns a reader directly. `Metric.from_sql(query)` is the eager `list` form, mirroring `Metric.read(path)`. + +For backends that need engine setup (Postgres, custom secrets, `ATTACH`), pass a pre-configured connection; it is used as-is and never closed: + +```python +import duckdb + +conn = duckdb.connect() +conn.execute("INSTALL postgres; LOAD postgres") +conn.execute("ATTACH 'dbname=metrics host=db' AS pg (TYPE postgres)") +metrics = AlignmentMetric.from_sql("SELECT * FROM pg.alignment", connection=conn) +``` + ### List Fields Fields typed as `list[T]` are automatically parsed from and serialized to delimited strings: diff --git a/fgmetric/_duckdb.py b/fgmetric/_duckdb.py new file mode 100644 index 0000000..6cac93c --- /dev/null +++ b/fgmetric/_duckdb.py @@ -0,0 +1,48 @@ +from typing import TYPE_CHECKING +from typing import Any + +if TYPE_CHECKING: + from duckdb import DuckDBPyConnection + +_MISSING_DUCKDB_MESSAGE = ( + "DuckDB is required to read metrics from SQL. Install it with: pip install 'fgmetric[duckdb]'" +) + + +def query_rows( + query: str, + connection: "DuckDBPyConnection | None" = None, +) -> list[dict[str, Any]]: + """ + Run a DuckDB SQL query and return its rows as column-keyed dicts. + + When `connection` is omitted, a transient in-memory DuckDB connection is opened for the + query and closed before returning. A supplied connection is used as-is and never closed, + so the caller may pre-configure extensions, secrets, or `ATTACH` and reuse it across calls. + + Args: + query: A DuckDB SQL query. The query names its own source, e.g. + `SELECT * FROM 'data.parquet'`. + connection: An open DuckDB connection to run the query against. When `None`, a + transient in-memory connection is created and closed internally. + + Returns: + One dict per result row, keyed by the query's output column names. Values are DuckDB's + native Python types (e.g. `int`, `float`, `Decimal`, `bool`, `None` for SQL NULL). + + Raises: + ImportError: If the optional `duckdb` dependency is not installed. + """ + try: + import duckdb + except ImportError as error: + raise ImportError(_MISSING_DUCKDB_MESSAGE) from error + + conn = connection if connection is not None else duckdb.connect() + try: + relation = conn.sql(query) + columns = relation.columns + return [dict(zip(columns, row, strict=True)) for row in relation.fetchall()] + finally: + if connection is None: + conn.close() diff --git a/fgmetric/metric.py b/fgmetric/metric.py index 15b66cc..0cc1e0f 100644 --- a/fgmetric/metric.py +++ b/fgmetric/metric.py @@ -1,6 +1,7 @@ from abc import ABC from collections.abc import Sequence from pathlib import Path +from typing import TYPE_CHECKING from typing import Any from typing import Self @@ -12,6 +13,9 @@ from fgmetric.collections import DelimitedList from fgmetric.metric_reader import MetricReader +if TYPE_CHECKING: + from duckdb import DuckDBPyConnection + class Metric( DelimitedList, @@ -116,6 +120,47 @@ def read( ) as reader: return list(reader) + @classmethod + def from_sql( + cls, + query: str, + *, + connection: "DuckDBPyConnection | None" = None, + ) -> list[Self]: + """ + Read all Metric instances from the rows of a DuckDB SQL query. + + Eager wrapper around `MetricReader.from_sql()`: the query runs and every row is + validated and collected into a list before this method returns. This is to + `from_sql` what `read` is to `MetricReader.open` — use `MetricReader.from_sql` + directly to iterate without materializing the full list. + + The query names its own source, so any backend DuckDB can read works (Parquet, Arrow, + JSON, CSV, SQLite, Postgres, S3). See `MetricReader.from_sql()` for the connection and + column-naming behavior shared by both APIs. Requires the optional `duckdb` dependency: + `pip install 'fgmetric[duckdb]'`. + + Args: + query: A DuckDB SQL query whose output columns match this metric class's fields. + connection: An open DuckDB connection. When `None`, a transient in-memory + connection is opened and closed internally. A supplied connection is never + closed. + + Returns: + A list of instances of the calling Metric subclass, one per result row. + + Raises: + ImportError: If the optional `duckdb` dependency is not installed. + ValidationError: If a row fails `Metric` validation. + + Example: + ```python + metrics = AlignmentMetric.from_sql("SELECT * FROM 'metrics.parquet'") + print(f"read {len(metrics)} rows") + ``` + """ + return list(MetricReader.from_sql(cls, query, connection=connection)) + # NB: "Before" validators (mode="before") run before field validators such as # `DelimitedList._split_lists()`. Empty strings in Optional fields will always be converted to # `None` before any field validators. diff --git a/fgmetric/metric_reader.py b/fgmetric/metric_reader.py index e807074..146e1ff 100644 --- a/fgmetric/metric_reader.py +++ b/fgmetric/metric_reader.py @@ -1,34 +1,42 @@ from collections.abc import Iterable from collections.abc import Iterator +from collections.abc import Mapping from collections.abc import Sequence from contextlib import contextmanager from csv import DictReader from itertools import chain from pathlib import Path from typing import TYPE_CHECKING +from typing import Any from typing import Self from xopen import xopen from fgmetric._delimiter import infer_delimiter +from fgmetric._duckdb import query_rows from fgmetric._paths import path_read_error if TYPE_CHECKING: + from duckdb import DuckDBPyConnection + from fgmetric.metric import Metric class MetricReader[T: Metric]: """ - Iterate `Metric` instances from a text IO source. + Iterate `Metric` instances from a parsed source. Constructed with any iterable of strings (file handle, StringIO, list of lines). The reader does not own the source; callers manage its lifecycle. Use the `open` classmethod to open and read a file in one step; unlike direct construction, `open` owns the file it opens and closes it on context exit. + Use `from_sql` to read from a DuckDB SQL query. """ _metric_class: type[T] - _records: Iterator[dict[str, str | None]] + # Widened to Mapping[str, Any]: the SQL path yields already-typed values (int, float, + # Decimal, bool, None), not the strings the text/DictReader path produces. + _records: Iterator[Mapping[str, Any]] def __init__( self, @@ -129,6 +137,78 @@ def open( with xopen(path, mode="rt", encoding=encoding) as handle: yield cls(metric_class, handle, delimiter, fieldnames) + @classmethod + def _from_records( + cls, + metric_class: type[T], + records: Iterator[Mapping[str, Any]], + ) -> Self: + """ + Construct a reader directly from an iterator of column-keyed record dicts. + + Shared tail for the text path (`__init__`, via `csv.DictReader`) and the SQL path + (`from_sql`). Bypasses `__init__` because the records are already dicts; there is no + text source to parse or header to detect. + + Args: + metric_class: Metric class. + records: An iterator of column-keyed record dicts to validate on iteration. + + Returns: + A `MetricReader` over the given records. + """ + reader = cls.__new__(cls) + reader._metric_class = metric_class + reader._records = records + return reader + + @classmethod + def from_sql( + cls, + metric_class: type[T], + query: str, + *, + connection: "DuckDBPyConnection | None" = None, + ) -> Self: + """ + Read metrics from the rows returned by a DuckDB SQL query. + + Unlike `open`, this is not a context manager: it owns no file handle and returns a + reader directly. The query names its own source, so any backend DuckDB can read works + — Parquet, Arrow, JSON, CSV, SQLite, Postgres, and S3-hosted files. Use SQL `AS` to + align a source's column names to the metric class's field names (or aliases). + + Rows are fetched eagerly from DuckDB (so a transient connection's lifecycle is fully + contained), then validated lazily, one row per iteration, by `model_validate`. + + Requires the optional `duckdb` dependency: `pip install 'fgmetric[duckdb]'`. + + Args: + metric_class: Metric class. + query: A DuckDB SQL query whose output columns match the metric class's fields. + connection: An open DuckDB connection to run the query against. When `None` (the + default), a transient in-memory connection is opened and closed internally, + which covers local files and remote sources reachable with ambient + credentials. Pass a pre-configured connection to use extensions, secrets, or + `ATTACH`ed databases (e.g. Postgres); a supplied connection is never closed. + + Returns: + A `MetricReader` over the query's rows. + + Raises: + ImportError: If the optional `duckdb` dependency is not installed. + ValidationError: If a row fails `Metric` validation, e.g. a missing required + column or a value of the wrong type. Raised during iteration. + + Example: + ```python + for metric in MetricReader.from_sql(AlignmentMetric, "SELECT * FROM 'm.parquet'"): + ... + ``` + """ + rows = query_rows(query, connection) + return cls._from_records(metric_class, iter(rows)) + def __iter__(self) -> Self: return self diff --git a/pyproject.toml b/pyproject.toml index 3971281..5798031 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dev = [ "pytest-mock ~=3.14", "poethepoet ~=0.34", "pre-commit ~=4.0", + "duckdb >=1.0", ] ipython = [ "ipython ~=9.2", @@ -53,6 +54,9 @@ benchmark = [ "pytest-benchmark ~=5.1.0", "fgpyo ~=1.2.0", ] +duckdb = [ + "duckdb >=1.0", +] [tool.poe.tasks] fix-format = "ruff format" diff --git a/tests/test_from_sql.py b/tests/test_from_sql.py new file mode 100644 index 0000000..00acbcb --- /dev/null +++ b/tests/test_from_sql.py @@ -0,0 +1,161 @@ +import sys +from pathlib import Path +from typing import assert_type + +import duckdb +import pytest +from pydantic import Field +from pydantic import ValidationError + +from fgmetric import Metric +from fgmetric._duckdb import query_rows +from fgmetric.metric_reader import MetricReader + + +def test_query_rows_returns_column_keyed_dicts() -> None: + rows = query_rows("SELECT 'alice' AS name, 1 AS value") + assert rows == [{"name": "alice", "value": 1}] + + +def test_query_rows_uses_transient_connection_when_none() -> None: + # No connection passed: query_rows opens and closes its own in-memory connection. + rows = query_rows("SELECT * FROM (VALUES (1), (2)) AS v(n) ORDER BY n") + assert rows == [{"n": 1}, {"n": 2}] + + +def test_query_rows_does_not_close_caller_connection() -> None: + conn = duckdb.connect() + query_rows("SELECT 1 AS x", conn) + # Still usable -> query_rows did not close a connection it does not own. + assert conn.sql("SELECT 2 AS y").fetchall() == [(2,)] + conn.close() + + +def test_query_rows_returns_empty_list_for_no_rows() -> None: + rows = query_rows("SELECT 1 AS x WHERE false") + assert rows == [] + + +def test_query_rows_raises_friendly_error_when_duckdb_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Setting the module to None makes `import duckdb` raise ImportError. + monkeypatch.setitem(sys.modules, "duckdb", None) + with pytest.raises(ImportError, match=r"fgmetric\[duckdb\]"): + query_rows("SELECT 1") + + +class ExampleMetric(Metric): + """Two required fields of distinct scalar types.""" + + name: str + value: int + + +class TypedMetric(Metric): + """Exercises native float/bool validation without string coercion.""" + + name: str + rate: float + flag: bool + + +class OptionalMetric(Metric): + """Optional field to exercise SQL NULL -> None.""" + + name: str + note: str | None = None + + +class AliasedMetric(Metric): + """Field whose serialized column name differs from the attribute name.""" + + name: str + mapping_quality: int = Field(alias="mapq") + + +def test_from_sql_validates_query_rows() -> None: + query = "SELECT * FROM (VALUES ('alice', 1), ('bob', 2)) AS v(name, value) ORDER BY value" + reader = MetricReader.from_sql(ExampleMetric, query) + assert_type(reader, MetricReader[ExampleMetric]) + metrics = list(reader) + assert metrics == [ + ExampleMetric(name="alice", value=1), + ExampleMetric(name="bob", value=2), + ] + + +def test_from_sql_validates_native_typed_values() -> None: + metrics = list( + MetricReader.from_sql(TypedMetric, "SELECT 'a' AS name, 0.5 AS rate, true AS flag") + ) + assert metrics == [TypedMetric(name="a", rate=0.5, flag=True)] + + +def test_from_sql_renames_columns_to_fields_with_as() -> None: + query = "SELECT label AS name, score AS value FROM (SELECT 'x' AS label, 5 AS score)" + metrics = list(MetricReader.from_sql(ExampleMetric, query)) + assert metrics == [ExampleMetric(name="x", value=5)] + + +def test_from_sql_null_becomes_none() -> None: + metrics = list(MetricReader.from_sql(OptionalMetric, "SELECT 'a' AS name, NULL AS note")) + assert metrics == [OptionalMetric(name="a", note=None)] + + +def test_from_sql_resolves_field_alias() -> None: + metrics = list(MetricReader.from_sql(AliasedMetric, "SELECT 'a' AS name, 60 AS mapq")) + assert metrics == [AliasedMetric(name="a", mapq=60)] + + +def test_from_sql_missing_required_column_raises() -> None: + # The `value` column is absent, so validation of the required field fails. + with pytest.raises(ValidationError): + list(MetricReader.from_sql(ExampleMetric, "SELECT 'a' AS name")) + + +def test_from_sql_returns_empty_for_no_rows() -> None: + query = "SELECT 'a' AS name, 1 AS value WHERE false" + metrics = list(MetricReader.from_sql(ExampleMetric, query)) + assert metrics == [] + + +def test_from_sql_does_not_close_caller_connection() -> None: + conn = duckdb.connect() + conn.execute("CREATE TABLE t AS SELECT 'a' AS name, 1 AS value") + metrics = list(MetricReader.from_sql(ExampleMetric, "SELECT * FROM t", connection=conn)) + assert metrics == [ExampleMetric(name="a", value=1)] + # Connection still usable -> from_sql did not close a borrowed connection. + assert conn.sql("SELECT 1 AS x").fetchall() == [(1,)] + conn.close() + + +def test_from_sql_reads_parquet_file(tmp_path: Path) -> None: + parquet = tmp_path / "metrics.parquet" + writer = duckdb.connect() + writer.execute( + f"COPY (SELECT 'a' AS name, 1 AS value UNION ALL SELECT 'b', 2) " + f"TO '{parquet}' (FORMAT parquet)" + ) + writer.close() + metrics = list( + MetricReader.from_sql(ExampleMetric, f"SELECT * FROM '{parquet}' ORDER BY value") + ) + assert metrics == [ + ExampleMetric(name="a", value=1), + ExampleMetric(name="b", value=2), + ] + + +def test_metric_from_sql_returns_list() -> None: + metrics = ExampleMetric.from_sql("SELECT 'a' AS name, 1 AS value") + assert_type(metrics, list[ExampleMetric]) + assert metrics == [ExampleMetric(name="a", value=1)] + + +def test_metric_from_sql_passes_connection_through() -> None: + conn = duckdb.connect() + conn.execute("CREATE TABLE t AS SELECT 'a' AS name, 1 AS value") + metrics = ExampleMetric.from_sql("SELECT * FROM t", connection=conn) + assert metrics == [ExampleMetric(name="a", value=1)] + conn.close() diff --git a/uv.lock b/uv.lock index f18d597..b4083e2 100644 --- a/uv.lock +++ b/uv.lock @@ -139,6 +139,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "duckdb" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/00/d579dcb2a536b6ea3a2563cdad6844f77d81a9b2d4b22a858097f2468acf/duckdb-1.5.3.tar.gz", hash = "sha256:df39428eb130faa35ae96fd35245bdeae6ecf43936250b116b5fead568eb9f16", size = 18026640, upload-time = "2026-05-20T11:55:31.901Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c4/2e34929b16c8d544ef664fad8f7f3a2a9db05746aae1e7c8c4ee3a8b23e4/duckdb-1.5.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ff11a457258148337ef9a392148a8cdbd1069b6c27c21958816c7b67fe6c542d", size = 32626494, upload-time = "2026-05-20T11:54:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/3a/53/3af681793d03771365ae3e2215331151c196a3ac8193f613344840694671/duckdb-1.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fd25f533cb1b6b2c84cc767a9a9bab7769bb1aa44571a2a0bfc91ac3e4a38ac", size = 17301121, upload-time = "2026-05-20T11:54:36.928Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/c80af1eac2ab5d35fc2c372ef0a84668842e549fbbf7799277b3fccf3e39/duckdb-1.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10960400ed60cdf0fe05bab2086fa8eb733889cb0ceca18d07ff9a00c0e0be7b", size = 15449283, upload-time = "2026-05-20T11:54:39.777Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/c63af233c9f761bf5178a5210437e1bc6bcb30fa8a9073de6398cfb12c03/duckdb-1.5.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5f18e7561403054433706c187589e86629a7af09a7efc23a06a8b308e6acc68", size = 19332762, upload-time = "2026-05-20T11:54:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/21/cc/2d77af4fff86012f334ef82e6d54a995a86c8745e58074f1218ed7d25171/duckdb-1.5.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9fb7516255a8764545e30f7efacea408cc847764a3027b3b0b3e7d1a7bebbc5c", size = 21453290, upload-time = "2026-05-20T11:54:45.272Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5e/9bc4817a98feb4dab83e56f2245cd3a30d00ee646d4dec7926464e2b3f28/duckdb-1.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:8001eccbc28be244dfd04d708526f34ddd6460b47a8aeb5d0e39d6f7f9e3fe15", size = 13118308, upload-time = "2026-05-20T11:54:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/e3f32e4e53e2450ddb1db8312a17d1ce455d60cc4941b6ad2cfc908794b0/duckdb-1.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:6d2835e39bb6af73891f73c0f8d4324f98afe00d0b00c6d34b2a582c2256cbb0", size = 13927187, upload-time = "2026-05-20T11:54:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/a528eb09d8be51954c485864bd06753e616939a080cbc3dd4417e8c94a57/duckdb-1.5.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e75a6122c12579a99848517f6f00a4e342aebda3590c30fe9b5cc5f39d5e6afc", size = 32626254, upload-time = "2026-05-20T11:54:53.65Z" }, + { url = "https://files.pythonhosted.org/packages/ec/3c/1534c0a6db347c05eb7d0f6ecfb7aefbe74cbff398e4892a8fd1903a20e8/duckdb-1.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fd3963c1cb9d9567777f4a898a9dbe388a2fe9724681801b1e7d6d93eecf1b76", size = 17300917, upload-time = "2026-05-20T11:54:56.628Z" }, + { url = "https://files.pythonhosted.org/packages/23/fa/beafb91e6e152d2161c4a9cbc472334c87607eb61ad7104b5a7fa8d8d7b1/duckdb-1.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3d5db8c0b55e072cf437948ebb5d7e23d7b9d03d905fa5f9145583e65aa447f7", size = 15449411, upload-time = "2026-05-20T11:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/50/0a/49b6fe04e2fcd63729eb607dadd44818dde77342a4f5ce086c6c92f1dd4d/duckdb-1.5.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ce80aed7a538422129a57eaca9141e3afb51f8bf562b1908b1576c9725b5b22", size = 19333120, upload-time = "2026-05-20T11:55:01.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/4c/0907c3f76adb9dd90e67610b31e0304a35814e65c4c41a354a262c09b885/duckdb-1.5.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787df63824f07bf18022dbc3b8ca4b2bfab0ebe616464f55c6e8cd0f59ea762e", size = 21453266, upload-time = "2026-05-20T11:55:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9c/d2f23a7803ddbbd9413f7572ecf66a15120ed5ced7ce5c73e698c1406b76/duckdb-1.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:bb5bb5dcdd09d62ee60f0ddbbef918e71cce304ffe28428b1131949d39ffaabf", size = 13118640, upload-time = "2026-05-20T11:55:07.389Z" }, + { url = "https://files.pythonhosted.org/packages/27/d5/7ba2316415bcdab6edd765bbbe35c2ca8a3800f2fe695cd70e3cdb997f09/duckdb-1.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:2fa17ecdd5d3db122836cb71bb93601c2106a3be883c17dffddc02fbf3fa7888", size = 13926409, upload-time = "2026-05-20T11:55:10.166Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c2/d4b6f8a5e4d3bc25773be6da76a99d9661ebbf3552c007c460d2dd59dbf8/duckdb-1.5.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4bfa9a4dadf71e83e2c4eaca2f9421c82a54defecc1b0b4c0be95e2389dec4fe", size = 32636685, upload-time = "2026-05-20T11:55:13.158Z" }, + { url = "https://files.pythonhosted.org/packages/42/58/e835c8298979d29db7a62cb5acc29e9b57aeaca7cdde2fcd3ac980f5cb18/duckdb-1.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aea7baf67ad7e1829ac76f67d7dcbd7fb1f57c3eb179d55ac30952df4709ae30", size = 17308134, upload-time = "2026-05-20T11:55:16.194Z" }, + { url = "https://files.pythonhosted.org/packages/c9/46/617b51363f5613418c8b224b3cce16b58e6dde80904566bec232579c1d4e/duckdb-1.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b0b4f088a65d77e1217ce5d7eff889e63fedc44281200d899ff47c84d8ff836", size = 15449891, upload-time = "2026-05-20T11:55:18.687Z" }, + { url = "https://files.pythonhosted.org/packages/b3/72/354146656e8d9ba3853d3a5ee80a481b8c5f70edfc3d5ae80a8c4479c967/duckdb-1.5.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe8d0c1f6a120aa03fa6e0d03897c71a1842e6cf7afd31d181348391f7108fe1", size = 19338499, upload-time = "2026-05-20T11:55:21.34Z" }, + { url = "https://files.pythonhosted.org/packages/56/8f/65fc623b51448f2bfba1a9ec6ab3debb4664c0876c0113a5e782600b53ac/duckdb-1.5.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0405eae18ec6e8210a471c97dbfe87a7e4d605274b7fe572a1f276e92158f13", size = 21455828, upload-time = "2026-05-20T11:55:23.847Z" }, + { url = "https://files.pythonhosted.org/packages/2b/db/d0274cbe9f5fe219f77c0bdf900ac77103569e83c102a4225ce04cbc607d/duckdb-1.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:33ae08b3e818d7613d8936744b67718c2062c2f530376895bfd89efb51b81538", size = 13640011, upload-time = "2026-05-20T11:55:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/07/5d/8f1899b8bef291caf953992fcd6c24df9f29387a35645e58c2504a5ca473/duckdb-1.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:746433e49bbc667b4df283153415fbe37e9083e0eff6c3cd6e54de7536869cd4", size = 14411554, upload-time = "2026-05-20T11:55:29.037Z" }, +] + [[package]] name = "executing" version = "2.2.1" @@ -162,9 +191,13 @@ benchmark = [ { name = "fgpyo" }, { name = "pytest-benchmark" }, ] +duckdb = [ + { name = "duckdb" }, +] [package.dev-dependencies] dev = [ + { name = "duckdb" }, { name = "mypy" }, { name = "poethepoet" }, { name = "pre-commit" }, @@ -179,15 +212,17 @@ ipython = [ [package.metadata] requires-dist = [ + { name = "duckdb", marker = "extra == 'duckdb'", specifier = ">=1.0" }, { name = "fgpyo", marker = "extra == 'benchmark'", specifier = "~=1.2.0" }, { name = "pydantic", specifier = ">=2.11.4" }, { name = "pytest-benchmark", marker = "extra == 'benchmark'", specifier = "~=5.1.0" }, { name = "xopen", specifier = ">=2.0.0" }, ] -provides-extras = ["benchmark"] +provides-extras = ["benchmark", "duckdb"] [package.metadata.requires-dev] dev = [ + { name = "duckdb", specifier = ">=1.0" }, { name = "mypy", specifier = "==1.18.2" }, { name = "poethepoet", specifier = "~=0.34" }, { name = "pre-commit", specifier = "~=4.0" },