Skip to content

Commit 89fc340

Browse files
cpsievertclaude
andcommitted
feat(python): add Reader protocol for custom reader contract
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b602c39 commit 89fc340

2 files changed

Lines changed: 66 additions & 2 deletions

File tree

ggsql-python/python/ggsql/__init__.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from __future__ import annotations
22

33
import json
4-
from typing import Any, Union
4+
from typing import Any, Protocol, Union, runtime_checkable
55

66
import altair
77
import narwhals as nw
88
from narwhals.typing import IntoFrame
9+
import polars as pl
910

1011
from ggsql._ggsql import (
1112
DuckDBReader,
@@ -22,12 +23,13 @@
2223
"VegaLiteWriter",
2324
"Validated",
2425
"Spec",
26+
"Reader",
2527
# Functions
2628
"validate",
2729
"execute",
2830
"render_altair",
2931
]
30-
__version__ = "0.1.0"
32+
__version__ = "0.1.4"
3133

3234
# Type alias for any Altair chart type
3335
AltairChart = Union[
@@ -41,6 +43,29 @@
4143
]
4244

4345

46+
@runtime_checkable
47+
class Reader(Protocol):
48+
"""Protocol for ggsql database readers.
49+
50+
Any object implementing these methods can be used as a reader with
51+
``ggsql.execute()``. Native readers like ``DuckDBReader`` satisfy
52+
this protocol automatically.
53+
54+
Required methods
55+
----------------
56+
execute_sql(sql: str) -> polars.DataFrame
57+
Execute a SQL query and return results as a polars DataFrame.
58+
register(name: str, df: polars.DataFrame, replace: bool = False) -> None
59+
Register a DataFrame as a named table for SQL queries.
60+
"""
61+
62+
def execute_sql(self, sql: str) -> pl.DataFrame: ...
63+
64+
def register(
65+
self, name: str, df: pl.DataFrame, replace: bool = False
66+
) -> None: ...
67+
68+
4469
def render_altair(
4570
df: IntoFrame,
4671
viz: str,

ggsql-python/tests/test_ggsql.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,3 +530,42 @@ def unregister(self, name: str) -> None:
530530
writer = ggsql.VegaLiteWriter()
531531
json_output = writer.render(spec)
532532
assert "point" in json_output
533+
534+
535+
class TestReaderProtocol:
536+
"""Tests for Reader protocol."""
537+
538+
def test_duckdb_reader_is_reader(self):
539+
"""Native DuckDBReader satisfies the Reader protocol."""
540+
reader = ggsql.DuckDBReader("duckdb://memory")
541+
assert isinstance(reader, ggsql.Reader)
542+
543+
def test_custom_reader_is_reader(self):
544+
"""Custom reader with correct methods satisfies the Reader protocol."""
545+
546+
class MyReader:
547+
def execute_sql(self, sql: str) -> pl.DataFrame:
548+
return pl.DataFrame({"x": [1]})
549+
550+
def register(
551+
self, name: str, df: pl.DataFrame, replace: bool = False
552+
) -> None:
553+
pass
554+
555+
reader = MyReader()
556+
assert isinstance(reader, ggsql.Reader)
557+
558+
def test_incomplete_reader_is_not_reader(self):
559+
"""Object missing required methods is not a Reader."""
560+
561+
class NotAReader:
562+
def execute_sql(self, sql: str) -> pl.DataFrame:
563+
return pl.DataFrame({"x": [1]})
564+
# Missing register()
565+
566+
obj = NotAReader()
567+
assert not isinstance(obj, ggsql.Reader)
568+
569+
def test_reader_is_exported(self):
570+
"""Reader is accessible from ggsql module."""
571+
assert hasattr(ggsql, "Reader")

0 commit comments

Comments
 (0)