From 814f1c8d760a1fb16ceb0369f070fa053b8c959e Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 3 Feb 2026 15:32:26 -0600 Subject: [PATCH 01/37] feat(py): Allow deferred chat client initialization (#205) When using Posit Connect managed OAuth credentials, chat client connections need access to HTTP headers in the Shiny session object, requiring creation inside the server function. Changes: - Defer client initialization when data_source=None and client=None - Add chat_client property getter/setter for setting client after init - Add client parameter to server() method for deferred pattern - Add _require_client() method for runtime checks - Update methods (client, console, generate_greeting) to require client Closes #205 Co-Authored-By: Claude Opus 4.5 --- pkg-py/src/querychat/_querychat_base.py | 52 ++++++- pkg-py/src/querychat/_shiny.py | 10 ++ pkg-py/tests/test_deferred_client.py | 190 +++++++++++++++++++++++ pkg-py/tests/test_deferred_datasource.py | 9 +- 4 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 pkg-py/tests/test_deferred_client.py diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index e8a7c7f15..87d96ff77 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -81,12 +81,18 @@ def __init__( self._extra_instructions = extra_instructions self._categorical_threshold = categorical_threshold - # Normalize and initialize client (doesn't need data_source) - client = normalize_client(client) - self._client = copy.deepcopy(client) - self._client.set_turns([]) - + # Initialize client + # When data_source is None (deferred pattern), also defer client initialization + # unless an explicit client is provided self._client_console = None + if data_source is None and client is None: + # Deferred pattern: don't try to create a default client + self._client: chatlas.Chat | None = None + else: + # Normalize and initialize client + normalized_client = normalize_client(client) + self._client = copy.deepcopy(normalized_client) + self._client.set_turns([]) # Initialize data source (may be None for deferred pattern) if data_source is not None: @@ -114,7 +120,9 @@ def _build_system_prompt(self) -> None: extra_instructions=self._extra_instructions, categorical_threshold=self._categorical_threshold, ) - self._client.system_prompt = self._system_prompt.render(self.tools) + # Only set system_prompt on client if client is available + if self._client is not None: + self._client.system_prompt = self._system_prompt.render(self.tools) def _require_data_source(self, method_name: str) -> DataSource[IntoFrameT]: """Raise if data_source is not set, otherwise return it for type narrowing.""" @@ -126,6 +134,16 @@ def _require_data_source(self, method_name: str) -> DataSource[IntoFrameT]: ) return self._data_source + def _require_client(self, method_name: str) -> chatlas.Chat: + """Raise if client is not set, otherwise return it for type narrowing.""" + if self._client is None: + raise RuntimeError( + f"client must be set before calling {method_name}(). " + "Either pass client to __init__(), set the chat_client property, " + "or pass client to server()." + ) + return self._client + def client( self, *, @@ -152,11 +170,12 @@ def client( """ data_source = self._require_data_source("client") + base_client = self._require_client("client") if self._system_prompt is None: raise RuntimeError("System prompt not initialized") tools = normalize_tools(tools, default=self.tools) - chat = copy.deepcopy(self._client) + chat = copy.deepcopy(base_client) chat.set_turns([]) chat.system_prompt = self._system_prompt.render(tools) @@ -177,7 +196,8 @@ def client( def generate_greeting(self, *, echo: Literal["none", "output"] = "none") -> str: """Generate a welcome greeting for the chat.""" self._require_data_source("generate_greeting") - client = copy.deepcopy(self._client) + base_client = self._require_client("generate_greeting") + client = copy.deepcopy(base_client) client.set_turns([]) return str(client.chat(GREETING_PROMPT, echo=echo)) @@ -190,6 +210,7 @@ def console( ) -> None: """Launch an interactive console chat with the data.""" self._require_data_source("console") + self._require_client("console") tools = normalize_tools(tools, default=("query",)) if new or self._client_console is None: @@ -216,6 +237,21 @@ def data_source(self, value: IntoFrame | sqlalchemy.Engine) -> None: self._data_source = normalize_data_source(value, self._table_name) self._build_system_prompt() + @property + def chat_client(self) -> chatlas.Chat | None: + """Get the current chat client.""" + return self._client + + @chat_client.setter + def chat_client(self, value: str | chatlas.Chat) -> None: + """Set the chat client, normalizing and updating system prompt if needed.""" + normalized_client = normalize_client(value) + self._client = copy.deepcopy(normalized_client) + self._client.set_turns([]) + # Update system prompt on client if data_source is already set + if self._data_source is not None and self._system_prompt is not None: + self._client.system_prompt = self._system_prompt.render(self.tools) + def cleanup(self) -> None: """Clean up resources associated with the data source.""" if self._data_source is not None: diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index c1dcc9a19..844edd78d 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -405,6 +405,7 @@ def server( self, *, data_source: Optional[IntoFrame | sqlalchemy.Engine | ibis.Table] = None, + client: Optional[str | chatlas.Chat] = None, enable_bookmarking: bool = False, id: Optional[str] = None, ) -> ServerValues[IntoFrameT]: @@ -422,6 +423,11 @@ def server( Optional data source to use. If provided, sets the data_source property before initializing server logic. This is useful for the deferred pattern where data_source is not known at initialization time. + client + Optional chat client to use. If provided, sets the chat_client property + before initializing server logic. This is useful for the deferred pattern + where the client cannot be created at initialization time (e.g., when + using Posit Connect managed OAuth credentials that require session access). enable_bookmarking Whether to enable bookmarking for the querychat module. id @@ -485,7 +491,11 @@ def title(): if data_source is not None: self.data_source = data_source + if client is not None: + self.chat_client = client + resolved_data_source = self._require_data_source("server") + self._require_client("server") return mod_server( id or self.id, diff --git a/pkg-py/tests/test_deferred_client.py b/pkg-py/tests/test_deferred_client.py new file mode 100644 index 000000000..9940cf4f1 --- /dev/null +++ b/pkg-py/tests/test_deferred_client.py @@ -0,0 +1,190 @@ +"""Tests for deferred chat client initialization.""" + +import pandas as pd +import pytest +from chatlas import ChatOpenAI +from querychat._querychat_base import QueryChatBase + + +@pytest.fixture +def sample_df(): + """Create a sample pandas DataFrame for testing.""" + return pd.DataFrame( + { + "id": [1, 2, 3], + "name": ["Alice", "Bob", "Charlie"], + "age": [25, 30, 35], + }, + ) + + +class TestDeferredClientInit: + """Tests for initializing QueryChatBase with deferred client.""" + + def test_init_with_none_data_source_defers_client(self): + """When data_source is None and client is not provided, client should be None.""" + qc = QueryChatBase(None, "users") + assert qc._client is None + assert qc.chat_client is None + + def test_init_with_explicit_client_and_none_data_source(self, monkeypatch): + """When data_source is None but client is provided, client should be initialized.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") + qc = QueryChatBase(None, "users", client="openai") + assert qc._client is not None + assert qc.chat_client is not None + + def test_init_with_data_source_initializes_client(self, sample_df, monkeypatch): + """When data_source is provided, client should be initialized with default.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") + qc = QueryChatBase(sample_df, "users") + assert qc._client is not None + assert qc.chat_client is not None + + +class TestChatClientProperty: + """Tests for the chat_client property setter.""" + + def test_chat_client_setter(self, monkeypatch): + """Setting chat_client should normalize and store the client.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") + qc = QueryChatBase(None, "users") + assert qc.chat_client is None + + qc.chat_client = "openai" + assert qc.chat_client is not None + + def test_chat_client_setter_with_chat_object(self, monkeypatch): + """Setting chat_client with a Chat object should work.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") + qc = QueryChatBase(None, "users") + assert qc.chat_client is None + + chat = ChatOpenAI() + qc.chat_client = chat + assert qc.chat_client is not None + + def test_chat_client_setter_updates_system_prompt(self, sample_df, monkeypatch): + """Setting chat_client should update system_prompt if data_source is set.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") + # Start with data_source but deferred client + qc = QueryChatBase(None, "users") + qc.data_source = sample_df + + # Now set the client - it should get the system prompt + qc.chat_client = "openai" + assert qc._client is not None + # The system prompt should have been set on the client + assert qc._client.system_prompt is not None + + def test_chat_client_getter_returns_none_when_not_set(self): + """chat_client property returns None when not set.""" + qc = QueryChatBase(None, "users") + assert qc.chat_client is None + + +class TestClientMethodRequirements: + """Tests that methods properly require client to be set.""" + + def test_client_method_requires_client(self, sample_df, monkeypatch): + """client() should raise if client not set.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") + # Initialize with data_source but no client + qc = QueryChatBase(None, "users") + qc.data_source = sample_df + + with pytest.raises(RuntimeError, match="client must be set"): + qc.client() + + def test_console_requires_client(self, sample_df, monkeypatch): + """console() should raise if client not set.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") + qc = QueryChatBase(None, "users") + qc.data_source = sample_df + + with pytest.raises(RuntimeError, match="client must be set"): + qc.console() + + def test_generate_greeting_requires_client(self, sample_df, monkeypatch): + """generate_greeting() should raise if client not set.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") + qc = QueryChatBase(None, "users") + qc.data_source = sample_df + + with pytest.raises(RuntimeError, match="client must be set"): + qc.generate_greeting() + + +class TestDeferredClientIntegration: + """Integration tests for the full deferred client workflow.""" + + def test_deferred_data_source_and_client(self, sample_df, monkeypatch): + """Test setting both data_source and client after init.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") + + # Create with both deferred + qc = QueryChatBase(None, "users") + assert qc.data_source is None + assert qc.chat_client is None + + # Set data_source first + qc.data_source = sample_df + assert qc.data_source is not None + + # Set client second + qc.chat_client = "openai" + assert qc.chat_client is not None + + # Now methods should work + client = qc.client() + assert client is not None + assert "users" in qc.system_prompt + + def test_deferred_client_then_data_source(self, sample_df, monkeypatch): + """Test setting client before data_source.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") + + # Create with both deferred + qc = QueryChatBase(None, "users") + + # Set client first + qc.chat_client = "openai" + assert qc.chat_client is not None + + # Set data_source second + qc.data_source = sample_df + assert qc.data_source is not None + + # Now methods should work + client = qc.client() + assert client is not None + + def test_no_openai_key_error_when_deferred(self, monkeypatch): + """When data_source is None, no OpenAI API key error should occur.""" + # Remove OpenAI API key if set + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("QUERYCHAT_CLIENT", raising=False) + + # This should NOT raise an error about missing API key + qc = QueryChatBase(None, "users") + assert qc._client is None + assert qc.chat_client is None + + +class TestBackwardCompatibility: + """Tests that existing patterns continue to work.""" + + def test_immediate_pattern_unchanged(self, sample_df, monkeypatch): + """Existing code with data_source continues to work.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") + qc = QueryChatBase(sample_df, "test_table") + + assert qc.data_source is not None + assert qc.chat_client is not None + + # All methods should work immediately + client = qc.client() + assert client is not None + + prompt = qc.system_prompt + assert "test_table" in prompt diff --git a/pkg-py/tests/test_deferred_datasource.py b/pkg-py/tests/test_deferred_datasource.py index af46b0d4b..ffd97addb 100644 --- a/pkg-py/tests/test_deferred_datasource.py +++ b/pkg-py/tests/test_deferred_datasource.py @@ -139,14 +139,19 @@ class TestDeferredPatternIntegration: def test_deferred_then_set_property(self, sample_df): """Test setting data_source via property after init.""" - # Create with None + # Create with None - both data_source and client are deferred qc = QueryChatBase(None, "users") assert qc.data_source is None + assert qc.chat_client is None - # Set via property + # Set data_source via property qc.data_source = sample_df assert qc.data_source is not None + # Set client via property (required now that we defer both) + qc.chat_client = "openai" + assert qc.chat_client is not None + # Now methods should work client = qc.client() assert client is not None From 77547c2e5b39ac38357e6e1c4db0a648e56bf2d9 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 15 Apr 2026 17:55:20 -0500 Subject: [PATCH 02/37] fix(py): require client before Shiny app setup --- pkg-py/src/querychat/_shiny.py | 4 +++- pkg-py/tests/test_deferred_shiny.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index 844edd78d..1a63f4ede 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -263,6 +263,7 @@ def app( """ data_source = self._require_data_source("app") + self._require_client("app") enable_bookmarking = bookmark_store != "disable" table_name = data_source.table_name @@ -734,11 +735,12 @@ def __init__( else: enable = enable_bookmarking + client = self._require_client("__init__") self._vals = mod_server( self.id, data_source=self._data_source, greeting=self.greeting, - client=self._client, + client=client, enable_bookmarking=enable, ) diff --git a/pkg-py/tests/test_deferred_shiny.py b/pkg-py/tests/test_deferred_shiny.py index 541e94acd..3a3ebd41a 100644 --- a/pkg-py/tests/test_deferred_shiny.py +++ b/pkg-py/tests/test_deferred_shiny.py @@ -5,6 +5,9 @@ import pandas as pd import pytest from querychat import QueryChat +from querychat.express import QueryChat as ExpressQueryChat +from shiny.express._stub_session import ExpressStubSession +from shiny.session import session_context @pytest.fixture(autouse=True) @@ -61,3 +64,17 @@ def test_app_requires_data_source(self): qc = QueryChat(None, "users") with pytest.raises(RuntimeError, match="data_source must be set"): qc.app() + + def test_app_requires_client_when_deferred(self, sample_df): + """app() should raise a clear error when the client is still deferred.""" + qc = QueryChat(None, "users") + qc.data_source = sample_df + + with pytest.raises(RuntimeError, match="client must be set"): + qc.app() + + def test_express_requires_client_when_deferred(self): + """Express should fail with a clear error when the client is still deferred.""" + with session_context(ExpressStubSession()): + with pytest.raises(RuntimeError, match="client must be set"): + ExpressQueryChat(None, "users") From f9ec2355a988badac53b2a34a06b6e0736575000 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 15 Apr 2026 18:44:49 -0500 Subject: [PATCH 03/37] refactor(py): Defer client initialization and support lazy defaults Client is no longer eagerly normalized at __init__ when not explicitly provided. Instead, _require_client(default=) resolves the client lazily: self._client takes priority, then the caller-provided default, then raises. Methods like .app() and .client() pass default=None (global default), while .server() passes the user's explicit arg (MISSING if omitted, which errors). Co-Authored-By: Claude Opus 4.6 --- pkg-py/docs/build.qmd | 2 + pkg-py/src/querychat/_querychat_base.py | 44 +++++++++++------- pkg-py/src/querychat/_shiny.py | 18 ++++---- pkg-py/tests/test_deferred_client.py | 57 ++++++++---------------- pkg-py/tests/test_deferred_datasource.py | 7 --- pkg-py/tests/test_deferred_shiny.py | 14 ++---- 6 files changed, 59 insertions(+), 83 deletions(-) diff --git a/pkg-py/docs/build.qmd b/pkg-py/docs/build.qmd index 3e4cb6de0..009f6cfd0 100644 --- a/pkg-py/docs/build.qmd +++ b/pkg-py/docs/build.qmd @@ -154,6 +154,8 @@ app = App(app_ui, server) +If your chat client also depends on session-scoped credentials, you can defer that too by passing it to `qc.server(client=...)` alongside the `data_source`. + ::: ::: diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index 87d96ff77..f34b3edb2 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -81,18 +81,13 @@ def __init__( self._extra_instructions = extra_instructions self._categorical_threshold = categorical_threshold - # Initialize client - # When data_source is None (deferred pattern), also defer client initialization - # unless an explicit client is provided self._client_console = None - if data_source is None and client is None: - # Deferred pattern: don't try to create a default client - self._client: chatlas.Chat | None = None - else: - # Normalize and initialize client + if client is not None: normalized_client = normalize_client(client) - self._client = copy.deepcopy(normalized_client) + self._client: chatlas.Chat | None = copy.deepcopy(normalized_client) self._client.set_turns([]) + else: + self._client = None # Initialize data source (may be None for deferred pattern) if data_source is not None: @@ -120,7 +115,6 @@ def _build_system_prompt(self) -> None: extra_instructions=self._extra_instructions, categorical_threshold=self._categorical_threshold, ) - # Only set system_prompt on client if client is available if self._client is not None: self._client.system_prompt = self._system_prompt.render(self.tools) @@ -134,11 +128,28 @@ def _require_data_source(self, method_name: str) -> DataSource[IntoFrameT]: ) return self._data_source - def _require_client(self, method_name: str) -> chatlas.Chat: - """Raise if client is not set, otherwise return it for type narrowing.""" + def _require_client( + self, + default: str | chatlas.Chat | None | MISSING_TYPE = MISSING, + ) -> chatlas.Chat: + """ + Return the chat client, raising if none is available. + + Resolution order: ``self._client`` (if set at init) > + ``default`` (if provided) > ``RuntimeError``. When ``default`` + resolves the lookup, it is stored on ``self._client`` for + subsequent use. + """ + if self._client is None and not isinstance(default, MISSING_TYPE): + normalized = normalize_client(default) + self._client = copy.deepcopy(normalized) + self._client.set_turns([]) + if self._system_prompt is not None: + self._client.system_prompt = self._system_prompt.render(self.tools) + if self._client is None: raise RuntimeError( - f"client must be set before calling {method_name}(). " + "client must be set. " "Either pass client to __init__(), set the chat_client property, " "or pass client to server()." ) @@ -170,7 +181,7 @@ def client( """ data_source = self._require_data_source("client") - base_client = self._require_client("client") + base_client = self._require_client(default=None) if self._system_prompt is None: raise RuntimeError("System prompt not initialized") tools = normalize_tools(tools, default=self.tools) @@ -196,7 +207,7 @@ def client( def generate_greeting(self, *, echo: Literal["none", "output"] = "none") -> str: """Generate a welcome greeting for the chat.""" self._require_data_source("generate_greeting") - base_client = self._require_client("generate_greeting") + base_client = self._require_client(default=None) client = copy.deepcopy(base_client) client.set_turns([]) return str(client.chat(GREETING_PROMPT, echo=echo)) @@ -210,7 +221,7 @@ def console( ) -> None: """Launch an interactive console chat with the data.""" self._require_data_source("console") - self._require_client("console") + self._require_client(default=None) tools = normalize_tools(tools, default=("query",)) if new or self._client_console is None: @@ -248,7 +259,6 @@ def chat_client(self, value: str | chatlas.Chat) -> None: normalized_client = normalize_client(value) self._client = copy.deepcopy(normalized_client) self._client.set_turns([]) - # Update system prompt on client if data_source is already set if self._data_source is not None and self._system_prompt is not None: self._client.system_prompt = self._system_prompt.render(self.tools) diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index 1a63f4ede..c2640a850 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -12,7 +12,7 @@ from ._icons import bs_icon from ._querychat_base import TOOL_GROUPS, QueryChatBase from ._shiny_module import ServerValues, mod_server, mod_ui -from ._utils import as_narwhals +from ._utils import MISSING, MISSING_TYPE, as_narwhals if TYPE_CHECKING: from pathlib import Path @@ -263,7 +263,7 @@ def app( """ data_source = self._require_data_source("app") - self._require_client("app") + self._require_client(default=None) enable_bookmarking = bookmark_store != "disable" table_name = data_source.table_name @@ -300,7 +300,7 @@ def app_server(input: Inputs, output: Outputs, session: Session): self.id, data_source=data_source, greeting=self.greeting, - client=self._client, + client=self.client, enable_bookmarking=enable_bookmarking, ) @@ -406,7 +406,7 @@ def server( self, *, data_source: Optional[IntoFrame | sqlalchemy.Engine | ibis.Table] = None, - client: Optional[str | chatlas.Chat] = None, + client: str | chatlas.Chat | MISSING_TYPE = MISSING, enable_bookmarking: bool = False, id: Optional[str] = None, ) -> ServerValues[IntoFrameT]: @@ -492,11 +492,8 @@ def title(): if data_source is not None: self.data_source = data_source - if client is not None: - self.chat_client = client - resolved_data_source = self._require_data_source("server") - self._require_client("server") + self._require_client(default=client) return mod_server( id or self.id, @@ -735,12 +732,13 @@ def __init__( else: enable = enable_bookmarking - client = self._require_client("__init__") + self._require_client(default=None) + self._vals = mod_server( self.id, data_source=self._data_source, greeting=self.greeting, - client=client, + client=self.client, enable_bookmarking=enable, ) diff --git a/pkg-py/tests/test_deferred_client.py b/pkg-py/tests/test_deferred_client.py index 9940cf4f1..bb6d5bf30 100644 --- a/pkg-py/tests/test_deferred_client.py +++ b/pkg-py/tests/test_deferred_client.py @@ -34,12 +34,11 @@ def test_init_with_explicit_client_and_none_data_source(self, monkeypatch): assert qc._client is not None assert qc.chat_client is not None - def test_init_with_data_source_initializes_client(self, sample_df, monkeypatch): - """When data_source is provided, client should be initialized with default.""" - monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") + def test_init_with_data_source_defers_client(self, sample_df): + """When data_source is provided without client, client should be lazily initialized.""" qc = QueryChatBase(sample_df, "users") - assert qc._client is not None - assert qc.chat_client is not None + assert qc._client is None + assert qc.chat_client is None class TestChatClientProperty: @@ -67,14 +66,11 @@ def test_chat_client_setter_with_chat_object(self, monkeypatch): def test_chat_client_setter_updates_system_prompt(self, sample_df, monkeypatch): """Setting chat_client should update system_prompt if data_source is set.""" monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") - # Start with data_source but deferred client qc = QueryChatBase(None, "users") qc.data_source = sample_df - # Now set the client - it should get the system prompt qc.chat_client = "openai" assert qc._client is not None - # The system prompt should have been set on the client assert qc._client.system_prompt is not None def test_chat_client_getter_returns_none_when_not_set(self): @@ -84,34 +80,27 @@ def test_chat_client_getter_returns_none_when_not_set(self): class TestClientMethodRequirements: - """Tests that methods properly require client to be set.""" + """Tests that methods properly require client or data_source to be set.""" - def test_client_method_requires_client(self, sample_df, monkeypatch): - """client() should raise if client not set.""" - monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") - # Initialize with data_source but no client + def test_client_method_requires_data_source(self): + """client() should raise if neither client nor data_source is set.""" qc = QueryChatBase(None, "users") - qc.data_source = sample_df - with pytest.raises(RuntimeError, match="client must be set"): + with pytest.raises(RuntimeError, match="data_source must be set"): qc.client() - def test_console_requires_client(self, sample_df, monkeypatch): - """console() should raise if client not set.""" - monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") + def test_console_requires_data_source(self): + """console() should raise if neither client nor data_source is set.""" qc = QueryChatBase(None, "users") - qc.data_source = sample_df - with pytest.raises(RuntimeError, match="client must be set"): + with pytest.raises(RuntimeError, match="data_source must be set"): qc.console() - def test_generate_greeting_requires_client(self, sample_df, monkeypatch): - """generate_greeting() should raise if client not set.""" - monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") + def test_generate_greeting_requires_data_source(self): + """generate_greeting() should raise if neither client nor data_source is set.""" qc = QueryChatBase(None, "users") - qc.data_source = sample_df - with pytest.raises(RuntimeError, match="client must be set"): + with pytest.raises(RuntimeError, match="data_source must be set"): qc.generate_greeting() @@ -122,20 +111,16 @@ def test_deferred_data_source_and_client(self, sample_df, monkeypatch): """Test setting both data_source and client after init.""" monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") - # Create with both deferred qc = QueryChatBase(None, "users") assert qc.data_source is None assert qc.chat_client is None - # Set data_source first qc.data_source = sample_df assert qc.data_source is not None - # Set client second qc.chat_client = "openai" assert qc.chat_client is not None - # Now methods should work client = qc.client() assert client is not None assert "users" in qc.system_prompt @@ -144,28 +129,22 @@ def test_deferred_client_then_data_source(self, sample_df, monkeypatch): """Test setting client before data_source.""" monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") - # Create with both deferred qc = QueryChatBase(None, "users") - # Set client first qc.chat_client = "openai" assert qc.chat_client is not None - # Set data_source second qc.data_source = sample_df assert qc.data_source is not None - # Now methods should work client = qc.client() assert client is not None def test_no_openai_key_error_when_deferred(self, monkeypatch): """When data_source is None, no OpenAI API key error should occur.""" - # Remove OpenAI API key if set monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.delenv("QUERYCHAT_CLIENT", raising=False) - # This should NOT raise an error about missing API key qc = QueryChatBase(None, "users") assert qc._client is None assert qc.chat_client is None @@ -175,16 +154,18 @@ class TestBackwardCompatibility: """Tests that existing patterns continue to work.""" def test_immediate_pattern_unchanged(self, sample_df, monkeypatch): - """Existing code with data_source continues to work.""" + """Existing code with data_source continues to work (client lazily initialized).""" monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") qc = QueryChatBase(sample_df, "test_table") assert qc.data_source is not None - assert qc.chat_client is not None + # Client is lazily initialized, so chat_client is None until first use + assert qc.chat_client is None - # All methods should work immediately client = qc.client() assert client is not None + # After first use, client is initialized + assert qc.chat_client is not None prompt = qc.system_prompt assert "test_table" in prompt diff --git a/pkg-py/tests/test_deferred_datasource.py b/pkg-py/tests/test_deferred_datasource.py index ffd97addb..4d44081e2 100644 --- a/pkg-py/tests/test_deferred_datasource.py +++ b/pkg-py/tests/test_deferred_datasource.py @@ -139,20 +139,16 @@ class TestDeferredPatternIntegration: def test_deferred_then_set_property(self, sample_df): """Test setting data_source via property after init.""" - # Create with None - both data_source and client are deferred qc = QueryChatBase(None, "users") assert qc.data_source is None assert qc.chat_client is None - # Set data_source via property qc.data_source = sample_df assert qc.data_source is not None - # Set client via property (required now that we defer both) qc.chat_client = "openai" assert qc.chat_client is not None - # Now methods should work client = qc.client() assert client is not None assert "users" in qc.system_prompt @@ -162,13 +158,10 @@ def test_data_source_change_rebuilds_prompt(self, sample_df): qc = QueryChatBase(sample_df, "original") original_prompt = qc.system_prompt - # Change data source (same table name) new_df = pd.DataFrame({"different": [1, 2], "columns": [3, 4]}) qc.data_source = new_df new_prompt = qc.system_prompt - # Prompt should be different (different schema) assert original_prompt != new_prompt - # But table name should be preserved assert "original" in new_prompt diff --git a/pkg-py/tests/test_deferred_shiny.py b/pkg-py/tests/test_deferred_shiny.py index 3a3ebd41a..eff8ab517 100644 --- a/pkg-py/tests/test_deferred_shiny.py +++ b/pkg-py/tests/test_deferred_shiny.py @@ -65,16 +65,8 @@ def test_app_requires_data_source(self): with pytest.raises(RuntimeError, match="data_source must be set"): qc.app() - def test_app_requires_client_when_deferred(self, sample_df): - """app() should raise a clear error when the client is still deferred.""" - qc = QueryChat(None, "users") - qc.data_source = sample_df - - with pytest.raises(RuntimeError, match="client must be set"): - qc.app() - - def test_express_requires_client_when_deferred(self): - """Express should fail with a clear error when the client is still deferred.""" + def test_express_requires_data_source_when_deferred(self): + """Express should fail with a clear error when data_source is still deferred.""" with session_context(ExpressStubSession()): - with pytest.raises(RuntimeError, match="client must be set"): + with pytest.raises(RuntimeError, match="data_source must be set"): ExpressQueryChat(None, "users") From 65c8415396eab140f19830d6e428fcac6e6b978e Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 15 Apr 2026 18:51:25 -0500 Subject: [PATCH 04/37] refactor(py): Make normalize_client always return a fresh Chat normalize_client now deepcopies and clears turns internally, so callers no longer need to repeat the copy.deepcopy + set_turns([]) sequence. Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/_querychat_base.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index f34b3edb2..4c4973895 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -83,9 +83,7 @@ def __init__( self._client_console = None if client is not None: - normalized_client = normalize_client(client) - self._client: chatlas.Chat | None = copy.deepcopy(normalized_client) - self._client.set_turns([]) + self._client: chatlas.Chat | None = normalize_client(client) else: self._client = None @@ -141,9 +139,7 @@ def _require_client( subsequent use. """ if self._client is None and not isinstance(default, MISSING_TYPE): - normalized = normalize_client(default) - self._client = copy.deepcopy(normalized) - self._client.set_turns([]) + self._client = normalize_client(default) if self._system_prompt is not None: self._client.system_prompt = self._system_prompt.render(self.tools) @@ -256,9 +252,7 @@ def chat_client(self) -> chatlas.Chat | None: @chat_client.setter def chat_client(self, value: str | chatlas.Chat) -> None: """Set the chat client, normalizing and updating system prompt if needed.""" - normalized_client = normalize_client(value) - self._client = copy.deepcopy(normalized_client) - self._client.set_turns([]) + self._client = normalize_client(value) if self._data_source is not None and self._system_prompt is not None: self._client.system_prompt = self._system_prompt.render(self.tools) @@ -309,6 +303,7 @@ def normalize_data_source( def normalize_client(client: str | chatlas.Chat | None) -> chatlas.Chat: + """Resolve a client spec into a fresh Chat with no conversation history.""" if client is None: client = os.getenv("QUERYCHAT_CLIENT", None) @@ -316,9 +311,12 @@ def normalize_client(client: str | chatlas.Chat | None) -> chatlas.Chat: client = "openai" if isinstance(client, chatlas.Chat): - return client + chat = copy.deepcopy(client) + else: + chat = chatlas.ChatAuto(provider_model=client) - return chatlas.ChatAuto(provider_model=client) + chat.set_turns([]) + return chat def normalize_tools( From a2ca0a34195153cf189f81545e2e85d4764c5a48 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 15 Apr 2026 18:53:39 -0500 Subject: [PATCH 05/37] refactor(py): Rename normalize_client to create_client Better reflects that the function always returns a new, independent Chat instance (deepcopied, with turns cleared) rather than just coercing a value into canonical form. Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/_querychat_base.py | 8 ++++---- pkg-py/tests/test_base.py | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index 4c4973895..8238988f6 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -83,7 +83,7 @@ def __init__( self._client_console = None if client is not None: - self._client: chatlas.Chat | None = normalize_client(client) + self._client: chatlas.Chat | None = create_client(client) else: self._client = None @@ -139,7 +139,7 @@ def _require_client( subsequent use. """ if self._client is None and not isinstance(default, MISSING_TYPE): - self._client = normalize_client(default) + self._client = create_client(default) if self._system_prompt is not None: self._client.system_prompt = self._system_prompt.render(self.tools) @@ -252,7 +252,7 @@ def chat_client(self) -> chatlas.Chat | None: @chat_client.setter def chat_client(self, value: str | chatlas.Chat) -> None: """Set the chat client, normalizing and updating system prompt if needed.""" - self._client = normalize_client(value) + self._client = create_client(value) if self._data_source is not None and self._system_prompt is not None: self._client.system_prompt = self._system_prompt.render(self.tools) @@ -302,7 +302,7 @@ def normalize_data_source( ) -def normalize_client(client: str | chatlas.Chat | None) -> chatlas.Chat: +def create_client(client: str | chatlas.Chat | None) -> chatlas.Chat: """Resolve a client spec into a fresh Chat with no conversation history.""" if client is None: client = os.getenv("QUERYCHAT_CLIENT", None) diff --git a/pkg-py/tests/test_base.py b/pkg-py/tests/test_base.py index ead9001ed..037b1f3bd 100644 --- a/pkg-py/tests/test_base.py +++ b/pkg-py/tests/test_base.py @@ -10,7 +10,7 @@ from querychat._datasource import DataFrameSource, SQLAlchemySource from querychat._querychat_base import ( QueryChatBase, - normalize_client, + create_client, normalize_data_source, normalize_tools, ) @@ -110,26 +110,26 @@ def test_with_special_column_names(self): class TestNormalizeClient: def test_with_none_uses_default(self): - result = normalize_client(None) + result = create_client(None) assert isinstance(result, chatlas.Chat) def test_with_string_provider(self): - result = normalize_client("openai") + result = create_client("openai") assert isinstance(result, chatlas.Chat) def test_with_chat_instance(self): chat = chatlas.ChatOpenAI() - result = normalize_client(chat) + result = create_client(chat) assert isinstance(result, chatlas.Chat) def test_respects_env_variable(self, monkeypatch): monkeypatch.setenv("QUERYCHAT_CLIENT", "openai") - result = normalize_client(None) + result = create_client(None) assert isinstance(result, chatlas.Chat) def test_with_invalid_provider_raises(self): with pytest.raises(ValueError, match="is not a known chatlas provider"): - normalize_client("not_a_real_provider_xyz123") + create_client("not_a_real_provider_xyz123") class TestNormalizeTools: From b0ebb19f9d20ea8c9dbbd87defd7708b10080074 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 15 Apr 2026 18:55:57 -0500 Subject: [PATCH 06/37] refactor(py): Rename _require_client to _ensure_client Better reflects that the method resolves and stores the client (side effect) rather than just validating it exists. Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/_querychat_base.py | 8 ++++---- pkg-py/src/querychat/_shiny.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index 8238988f6..4414a6dde 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -126,7 +126,7 @@ def _require_data_source(self, method_name: str) -> DataSource[IntoFrameT]: ) return self._data_source - def _require_client( + def _ensure_client( self, default: str | chatlas.Chat | None | MISSING_TYPE = MISSING, ) -> chatlas.Chat: @@ -177,7 +177,7 @@ def client( """ data_source = self._require_data_source("client") - base_client = self._require_client(default=None) + base_client = self._ensure_client(default=None) if self._system_prompt is None: raise RuntimeError("System prompt not initialized") tools = normalize_tools(tools, default=self.tools) @@ -203,7 +203,7 @@ def client( def generate_greeting(self, *, echo: Literal["none", "output"] = "none") -> str: """Generate a welcome greeting for the chat.""" self._require_data_source("generate_greeting") - base_client = self._require_client(default=None) + base_client = self._ensure_client(default=None) client = copy.deepcopy(base_client) client.set_turns([]) return str(client.chat(GREETING_PROMPT, echo=echo)) @@ -217,7 +217,7 @@ def console( ) -> None: """Launch an interactive console chat with the data.""" self._require_data_source("console") - self._require_client(default=None) + self._ensure_client(default=None) tools = normalize_tools(tools, default=("query",)) if new or self._client_console is None: diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index c2640a850..6cb594ace 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -263,7 +263,7 @@ def app( """ data_source = self._require_data_source("app") - self._require_client(default=None) + self._ensure_client(default=None) enable_bookmarking = bookmark_store != "disable" table_name = data_source.table_name @@ -493,7 +493,7 @@ def title(): self.data_source = data_source resolved_data_source = self._require_data_source("server") - self._require_client(default=client) + self._ensure_client(default=client) return mod_server( id or self.id, @@ -732,7 +732,7 @@ def __init__( else: enable = enable_bookmarking - self._require_client(default=None) + self._ensure_client(default=None) self._vals = mod_server( self.id, From 95d62b169cbcfdc33b448235e87a13728f932107 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 15 Apr 2026 18:57:12 -0500 Subject: [PATCH 07/37] refactor(py): Use create_client for forking in client() and generate_greeting() Eliminates remaining inline copy.deepcopy + set_turns([]) sequences by reusing create_client, which already handles that. Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/_querychat_base.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index 4414a6dde..d94e918a8 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -182,8 +182,7 @@ def client( raise RuntimeError("System prompt not initialized") tools = normalize_tools(tools, default=self.tools) - chat = copy.deepcopy(base_client) - chat.set_turns([]) + chat = create_client(base_client) chat.system_prompt = self._system_prompt.render(tools) if tools is None: @@ -204,8 +203,7 @@ def generate_greeting(self, *, echo: Literal["none", "output"] = "none") -> str: """Generate a welcome greeting for the chat.""" self._require_data_source("generate_greeting") base_client = self._ensure_client(default=None) - client = copy.deepcopy(base_client) - client.set_turns([]) + client = create_client(base_client) return str(client.chat(GREETING_PROMPT, echo=echo)) def console( From 506c4dfa561e507ae3dd1fd6f456da1d29c41928 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 09:38:16 -0500 Subject: [PATCH 08/37] refactor(py): Replace _client with _client_spec, eliminate state matrix Store the client specification without eager resolution. A single _create_session_client() method resolves spec -> fresh Chat -> system prompt -> tools at point-of-use. - Replace dual-role self._client with self._client_spec (just stores spec) - Delete _ensure_client, replace with _create_session_client - Rename chat_client property to client_spec (no naming collision with client()) - System prompt assigned in exactly one place (_create_session_client) - Remove MISSING sentinel from server() signature - Pass bound method to mod_server instead of lambda/bound-method-as-callable Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/_querychat_base.py | 106 +++++++++-------------- pkg-py/src/querychat/_shiny.py | 18 ++-- pkg-py/tests/test_deferred_client.py | 103 +++++++++------------- pkg-py/tests/test_deferred_datasource.py | 6 +- pkg-py/tests/test_querychat.py | 5 -- 5 files changed, 93 insertions(+), 145 deletions(-) diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index d94e918a8..b32ab4abb 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -81,11 +81,8 @@ def __init__( self._extra_instructions = extra_instructions self._categorical_threshold = categorical_threshold + self._client_spec: str | chatlas.Chat | None = client self._client_console = None - if client is not None: - self._client: chatlas.Chat | None = create_client(client) - else: - self._client = None # Initialize data source (may be None for deferred pattern) if data_source is not None: @@ -113,8 +110,6 @@ def _build_system_prompt(self) -> None: extra_instructions=self._extra_instructions, categorical_threshold=self._categorical_threshold, ) - if self._client is not None: - self._client.system_prompt = self._system_prompt.render(self.tools) def _require_data_source(self, method_name: str) -> DataSource[IntoFrameT]: """Raise if data_source is not set, otherwise return it for type narrowing.""" @@ -126,30 +121,35 @@ def _require_data_source(self, method_name: str) -> DataSource[IntoFrameT]: ) return self._data_source - def _ensure_client( + def _create_session_client( self, - default: str | chatlas.Chat | None | MISSING_TYPE = MISSING, + *, + tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None | MISSING_TYPE = MISSING, + update_dashboard: Callable[[UpdateDashboardData], None] | None = None, + reset_dashboard: Callable[[], None] | None = None, ) -> chatlas.Chat: - """ - Return the chat client, raising if none is available. + """Create a fresh, fully-configured Chat from the current spec.""" + data_source = self._require_data_source("_create_session_client") + chat = create_client(self._client_spec) - Resolution order: ``self._client`` (if set at init) > - ``default`` (if provided) > ``RuntimeError``. When ``default`` - resolves the lookup, it is stored on ``self._client`` for - subsequent use. - """ - if self._client is None and not isinstance(default, MISSING_TYPE): - self._client = create_client(default) - if self._system_prompt is not None: - self._client.system_prompt = self._system_prompt.render(self.tools) + resolved_tools = normalize_tools(tools, default=self.tools) - if self._client is None: - raise RuntimeError( - "client must be set. " - "Either pass client to __init__(), set the chat_client property, " - "or pass client to server()." - ) - return self._client + if self._system_prompt is not None: + chat.system_prompt = self._system_prompt.render(resolved_tools) + + if resolved_tools is None: + return chat + + if "update" in resolved_tools: + update_fn = update_dashboard or (lambda _: None) + reset_fn = reset_dashboard or (lambda: None) + chat.register_tool(tool_update_dashboard(data_source, update_fn)) + chat.register_tool(tool_reset_dashboard(reset_fn)) + + if "query" in resolved_tools: + chat.register_tool(tool_query(data_source)) + + return chat def client( self, @@ -176,35 +176,17 @@ def client( A configured chat client. """ - data_source = self._require_data_source("client") - base_client = self._ensure_client(default=None) - if self._system_prompt is None: - raise RuntimeError("System prompt not initialized") - tools = normalize_tools(tools, default=self.tools) - - chat = create_client(base_client) - chat.system_prompt = self._system_prompt.render(tools) - - if tools is None: - return chat - - if "update" in tools: - update_fn = update_dashboard or (lambda _: None) - reset_fn = reset_dashboard or (lambda: None) - chat.register_tool(tool_update_dashboard(data_source, update_fn)) - chat.register_tool(tool_reset_dashboard(reset_fn)) - - if "query" in tools: - chat.register_tool(tool_query(data_source)) - - return chat + return self._create_session_client( + tools=tools, + update_dashboard=update_dashboard, + reset_dashboard=reset_dashboard, + ) def generate_greeting(self, *, echo: Literal["none", "output"] = "none") -> str: """Generate a welcome greeting for the chat.""" self._require_data_source("generate_greeting") - base_client = self._ensure_client(default=None) - client = create_client(base_client) - return str(client.chat(GREETING_PROMPT, echo=echo)) + chat = create_client(self._client_spec) + return str(chat.chat(GREETING_PROMPT, echo=echo)) def console( self, @@ -214,10 +196,6 @@ def console( **kwargs, ) -> None: """Launch an interactive console chat with the data.""" - self._require_data_source("console") - self._ensure_client(default=None) - tools = normalize_tools(tools, default=("query",)) - if new or self._client_console is None: self._client_console = self.client(tools=tools, **kwargs) @@ -243,16 +221,14 @@ def data_source(self, value: IntoFrame | sqlalchemy.Engine) -> None: self._build_system_prompt() @property - def chat_client(self) -> chatlas.Chat | None: - """Get the current chat client.""" - return self._client - - @chat_client.setter - def chat_client(self, value: str | chatlas.Chat) -> None: - """Set the chat client, normalizing and updating system prompt if needed.""" - self._client = create_client(value) - if self._data_source is not None and self._system_prompt is not None: - self._client.system_prompt = self._system_prompt.render(self.tools) + def client_spec(self) -> str | chatlas.Chat | None: + """Get the current client specification.""" + return self._client_spec + + @client_spec.setter + def client_spec(self, value: str | chatlas.Chat | None) -> None: + """Set the client specification.""" + self._client_spec = value def cleanup(self) -> None: """Clean up resources associated with the data source.""" diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index 6cb594ace..294e0ea12 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -12,7 +12,7 @@ from ._icons import bs_icon from ._querychat_base import TOOL_GROUPS, QueryChatBase from ._shiny_module import ServerValues, mod_server, mod_ui -from ._utils import MISSING, MISSING_TYPE, as_narwhals +from ._utils import as_narwhals if TYPE_CHECKING: from pathlib import Path @@ -263,7 +263,6 @@ def app( """ data_source = self._require_data_source("app") - self._ensure_client(default=None) enable_bookmarking = bookmark_store != "disable" table_name = data_source.table_name @@ -300,7 +299,7 @@ def app_server(input: Inputs, output: Outputs, session: Session): self.id, data_source=data_source, greeting=self.greeting, - client=self.client, + client=self._create_session_client, enable_bookmarking=enable_bookmarking, ) @@ -406,7 +405,7 @@ def server( self, *, data_source: Optional[IntoFrame | sqlalchemy.Engine | ibis.Table] = None, - client: str | chatlas.Chat | MISSING_TYPE = MISSING, + client: str | chatlas.Chat | None = None, enable_bookmarking: bool = False, id: Optional[str] = None, ) -> ServerValues[IntoFrameT]: @@ -425,7 +424,7 @@ def server( before initializing server logic. This is useful for the deferred pattern where data_source is not known at initialization time. client - Optional chat client to use. If provided, sets the chat_client property + Optional chat client to use. If provided, sets the client_spec property before initializing server logic. This is useful for the deferred pattern where the client cannot be created at initialization time (e.g., when using Posit Connect managed OAuth credentials that require session access). @@ -491,15 +490,16 @@ def title(): if data_source is not None: self.data_source = data_source + if client is not None: + self.client_spec = client resolved_data_source = self._require_data_source("server") - self._ensure_client(default=client) return mod_server( id or self.id, data_source=resolved_data_source, greeting=self.greeting, - client=self.client, + client=self._create_session_client, enable_bookmarking=enable_bookmarking, ) @@ -732,13 +732,11 @@ def __init__( else: enable = enable_bookmarking - self._ensure_client(default=None) - self._vals = mod_server( self.id, data_source=self._data_source, greeting=self.greeting, - client=self.client, + client=self._create_session_client, enable_bookmarking=enable, ) diff --git a/pkg-py/tests/test_deferred_client.py b/pkg-py/tests/test_deferred_client.py index bb6d5bf30..25e64feb8 100644 --- a/pkg-py/tests/test_deferred_client.py +++ b/pkg-py/tests/test_deferred_client.py @@ -22,82 +22,71 @@ class TestDeferredClientInit: """Tests for initializing QueryChatBase with deferred client.""" def test_init_with_none_data_source_defers_client(self): - """When data_source is None and client is not provided, client should be None.""" + """When data_source is None and client is not provided, client_spec should be None.""" qc = QueryChatBase(None, "users") - assert qc._client is None - assert qc.chat_client is None + assert qc.client_spec is None - def test_init_with_explicit_client_and_none_data_source(self, monkeypatch): - """When data_source is None but client is provided, client should be initialized.""" - monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") + def test_init_with_explicit_client_and_none_data_source(self): + """When data_source is None but client is provided, client_spec should be stored.""" qc = QueryChatBase(None, "users", client="openai") - assert qc._client is not None - assert qc.chat_client is not None + assert qc.client_spec == "openai" - def test_init_with_data_source_defers_client(self, sample_df): - """When data_source is provided without client, client should be lazily initialized.""" + def test_init_with_chat_object_stores_spec(self, monkeypatch): + """When a Chat object is passed, it should be stored as-is.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") + chat = ChatOpenAI() + qc = QueryChatBase(None, "users", client=chat) + assert qc.client_spec is chat + + def test_init_with_data_source_no_client(self, sample_df): + """When data_source is provided without client, client_spec should be None.""" qc = QueryChatBase(sample_df, "users") - assert qc._client is None - assert qc.chat_client is None + assert qc.client_spec is None -class TestChatClientProperty: - """Tests for the chat_client property setter.""" +class TestClientSpecProperty: + """Tests for the client_spec property setter.""" - def test_chat_client_setter(self, monkeypatch): - """Setting chat_client should normalize and store the client.""" - monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") + def test_client_spec_setter_string(self): + """Setting client_spec with a string should store it.""" qc = QueryChatBase(None, "users") - assert qc.chat_client is None + qc.client_spec = "openai" + assert qc.client_spec == "openai" - qc.chat_client = "openai" - assert qc.chat_client is not None - - def test_chat_client_setter_with_chat_object(self, monkeypatch): - """Setting chat_client with a Chat object should work.""" + def test_client_spec_setter_with_chat_object(self, monkeypatch): + """Setting client_spec with a Chat object should store it.""" monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") - qc = QueryChatBase(None, "users") - assert qc.chat_client is None - chat = ChatOpenAI() - qc.chat_client = chat - assert qc.chat_client is not None - - def test_chat_client_setter_updates_system_prompt(self, sample_df, monkeypatch): - """Setting chat_client should update system_prompt if data_source is set.""" - monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") qc = QueryChatBase(None, "users") - qc.data_source = sample_df - - qc.chat_client = "openai" - assert qc._client is not None - assert qc._client.system_prompt is not None + qc.client_spec = chat + assert qc.client_spec is chat - def test_chat_client_getter_returns_none_when_not_set(self): - """chat_client property returns None when not set.""" - qc = QueryChatBase(None, "users") - assert qc.chat_client is None + def test_client_spec_setter_none(self): + """Setting client_spec to None should reset to default behavior.""" + qc = QueryChatBase(None, "users", client="openai") + qc.client_spec = None + assert qc.client_spec is None class TestClientMethodRequirements: - """Tests that methods properly require client or data_source to be set.""" + """Tests that methods properly require data_source to be set.""" def test_client_method_requires_data_source(self): - """client() should raise if neither client nor data_source is set.""" + """client() should raise if data_source is not set.""" qc = QueryChatBase(None, "users") with pytest.raises(RuntimeError, match="data_source must be set"): qc.client() def test_console_requires_data_source(self): - """console() should raise if neither client nor data_source is set.""" + """console() should raise if data_source is not set.""" qc = QueryChatBase(None, "users") with pytest.raises(RuntimeError, match="data_source must be set"): qc.console() def test_generate_greeting_requires_data_source(self): - """generate_greeting() should raise if neither client nor data_source is set.""" + """generate_greeting() should raise if data_source is not set.""" qc = QueryChatBase(None, "users") with pytest.raises(RuntimeError, match="data_source must be set"): @@ -113,13 +102,10 @@ def test_deferred_data_source_and_client(self, sample_df, monkeypatch): qc = QueryChatBase(None, "users") assert qc.data_source is None - assert qc.chat_client is None + assert qc.client_spec is None qc.data_source = sample_df - assert qc.data_source is not None - - qc.chat_client = "openai" - assert qc.chat_client is not None + qc.client_spec = "openai" client = qc.client() assert client is not None @@ -130,12 +116,8 @@ def test_deferred_client_then_data_source(self, sample_df, monkeypatch): monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") qc = QueryChatBase(None, "users") - - qc.chat_client = "openai" - assert qc.chat_client is not None - + qc.client_spec = "openai" qc.data_source = sample_df - assert qc.data_source is not None client = qc.client() assert client is not None @@ -146,26 +128,23 @@ def test_no_openai_key_error_when_deferred(self, monkeypatch): monkeypatch.delenv("QUERYCHAT_CLIENT", raising=False) qc = QueryChatBase(None, "users") - assert qc._client is None - assert qc.chat_client is None + assert qc.client_spec is None class TestBackwardCompatibility: """Tests that existing patterns continue to work.""" def test_immediate_pattern_unchanged(self, sample_df, monkeypatch): - """Existing code with data_source continues to work (client lazily initialized).""" + """Existing code with data_source continues to work.""" monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") qc = QueryChatBase(sample_df, "test_table") assert qc.data_source is not None - # Client is lazily initialized, so chat_client is None until first use - assert qc.chat_client is None + # client_spec is None (will use env default at resolution time) + assert qc.client_spec is None client = qc.client() assert client is not None - # After first use, client is initialized - assert qc.chat_client is not None prompt = qc.system_prompt assert "test_table" in prompt diff --git a/pkg-py/tests/test_deferred_datasource.py b/pkg-py/tests/test_deferred_datasource.py index 4d44081e2..dc45151aa 100644 --- a/pkg-py/tests/test_deferred_datasource.py +++ b/pkg-py/tests/test_deferred_datasource.py @@ -141,13 +141,13 @@ def test_deferred_then_set_property(self, sample_df): """Test setting data_source via property after init.""" qc = QueryChatBase(None, "users") assert qc.data_source is None - assert qc.chat_client is None + assert qc.client_spec is None qc.data_source = sample_df assert qc.data_source is not None - qc.chat_client = "openai" - assert qc.chat_client is not None + qc.client_spec = "openai" + assert qc.client_spec == "openai" client = qc.client() assert client is not None diff --git a/pkg-py/tests/test_querychat.py b/pkg-py/tests/test_querychat.py index 243423e1e..a7cc9af16 100644 --- a/pkg-py/tests/test_querychat.py +++ b/pkg-py/tests/test_querychat.py @@ -86,11 +86,6 @@ def test_querychat_client_has_system_prompt(sample_df): # The system_prompt should contain the table name since it includes schema info assert "test_table" in client.system_prompt - # The internal _client should also have the system prompt set - # (needed for methods like generate_greeting() that use _client directly) - assert qc._client.system_prompt is not None - assert "test_table" in qc._client.system_prompt - def test_querychat_with_polars_lazyframe(): """Test that QueryChat accepts a Polars LazyFrame.""" From 48b31689cfe178e91b46be76b0c348a2d0472eae Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 09:44:22 -0500 Subject: [PATCH 09/37] fix(py): Move _require_data_source check to public methods Users calling client() now see "data_source must be set before calling client()" instead of a reference to the internal _create_session_client. Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/_querychat_base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index b32ab4abb..43fd25bbb 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -129,7 +129,6 @@ def _create_session_client( reset_dashboard: Callable[[], None] | None = None, ) -> chatlas.Chat: """Create a fresh, fully-configured Chat from the current spec.""" - data_source = self._require_data_source("_create_session_client") chat = create_client(self._client_spec) resolved_tools = normalize_tools(tools, default=self.tools) @@ -140,6 +139,8 @@ def _create_session_client( if resolved_tools is None: return chat + data_source = self._require_data_source("_create_session_client") + if "update" in resolved_tools: update_fn = update_dashboard or (lambda _: None) reset_fn = reset_dashboard or (lambda: None) @@ -176,6 +177,7 @@ def client( A configured chat client. """ + self._require_data_source("client") return self._create_session_client( tools=tools, update_dashboard=update_dashboard, From 4148df23e853d4755b028c599f31f53ec14442d9 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 09:53:23 -0500 Subject: [PATCH 10/37] docs(py): Add caller-guard comment to _create_session_client Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/_querychat_base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index 43fd25bbb..4c0f4e0d4 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -139,6 +139,9 @@ def _create_session_client( if resolved_tools is None: return chat + # Safety net — public callers (client(), server(), app()) should call + # _require_data_source() first so users see a message referencing the + # method they actually called. data_source = self._require_data_source("_create_session_client") if "update" in resolved_tools: From 8a3949dbe57cbe8ea7f889c7931b84c1a5f03955 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 10:09:53 -0500 Subject: [PATCH 11/37] fix(py): restore deferred client behavior --- pkg-py/src/querychat/_querychat_base.py | 12 +---- pkg-py/src/querychat/_shiny.py | 16 ++++--- pkg-py/tests/test_deferred_client.py | 59 +++++++----------------- pkg-py/tests/test_deferred_datasource.py | 5 +- pkg-py/tests/test_deferred_shiny.py | 10 ++++ pkg-py/tests/test_querychat.py | 22 +++++++++ 6 files changed, 59 insertions(+), 65 deletions(-) diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index 4c0f4e0d4..8f94ab088 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -190,7 +190,7 @@ def client( def generate_greeting(self, *, echo: Literal["none", "output"] = "none") -> str: """Generate a welcome greeting for the chat.""" self._require_data_source("generate_greeting") - chat = create_client(self._client_spec) + chat = self._create_session_client() return str(chat.chat(GREETING_PROMPT, echo=echo)) def console( @@ -225,16 +225,6 @@ def data_source(self, value: IntoFrame | sqlalchemy.Engine) -> None: self._data_source = normalize_data_source(value, self._table_name) self._build_system_prompt() - @property - def client_spec(self) -> str | chatlas.Chat | None: - """Get the current client specification.""" - return self._client_spec - - @client_spec.setter - def client_spec(self, value: str | chatlas.Chat | None) -> None: - """Set the client specification.""" - self._client_spec = value - def cleanup(self) -> None: """Clean up resources associated with the data source.""" if self._data_source is not None: diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index 294e0ea12..5c65c262f 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -12,6 +12,7 @@ from ._icons import bs_icon from ._querychat_base import TOOL_GROUPS, QueryChatBase from ._shiny_module import ServerValues, mod_server, mod_ui +from ._utils import MISSING, MISSING_TYPE from ._utils import as_narwhals if TYPE_CHECKING: @@ -405,7 +406,7 @@ def server( self, *, data_source: Optional[IntoFrame | sqlalchemy.Engine | ibis.Table] = None, - client: str | chatlas.Chat | None = None, + client: str | chatlas.Chat | None | MISSING_TYPE = MISSING, enable_bookmarking: bool = False, id: Optional[str] = None, ) -> ServerValues[IntoFrameT]: @@ -424,10 +425,11 @@ def server( before initializing server logic. This is useful for the deferred pattern where data_source is not known at initialization time. client - Optional chat client to use. If provided, sets the client_spec property - before initializing server logic. This is useful for the deferred pattern - where the client cannot be created at initialization time (e.g., when - using Posit Connect managed OAuth credentials that require session access). + Optional chat client to use. If provided, updates the deferred client + configuration before initializing server logic. This is useful for the + deferred pattern where the client cannot be created at initialization + time (e.g., when using Posit Connect managed OAuth credentials that + require session access). enable_bookmarking Whether to enable bookmarking for the querychat module. id @@ -490,8 +492,8 @@ def title(): if data_source is not None: self.data_source = data_source - if client is not None: - self.client_spec = client + if not isinstance(client, MISSING_TYPE): + self._client_spec = client resolved_data_source = self._require_data_source("server") diff --git a/pkg-py/tests/test_deferred_client.py b/pkg-py/tests/test_deferred_client.py index 25e64feb8..61e9c222d 100644 --- a/pkg-py/tests/test_deferred_client.py +++ b/pkg-py/tests/test_deferred_client.py @@ -22,51 +22,26 @@ class TestDeferredClientInit: """Tests for initializing QueryChatBase with deferred client.""" def test_init_with_none_data_source_defers_client(self): - """When data_source is None and client is not provided, client_spec should be None.""" + """When data_source is None and client is not provided, _client_spec should be None.""" qc = QueryChatBase(None, "users") - assert qc.client_spec is None + assert qc._client_spec is None def test_init_with_explicit_client_and_none_data_source(self): - """When data_source is None but client is provided, client_spec should be stored.""" + """When data_source is None but client is provided, _client_spec should be stored.""" qc = QueryChatBase(None, "users", client="openai") - assert qc.client_spec == "openai" + assert qc._client_spec == "openai" def test_init_with_chat_object_stores_spec(self, monkeypatch): """When a Chat object is passed, it should be stored as-is.""" monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") chat = ChatOpenAI() qc = QueryChatBase(None, "users", client=chat) - assert qc.client_spec is chat + assert qc._client_spec is chat def test_init_with_data_source_no_client(self, sample_df): - """When data_source is provided without client, client_spec should be None.""" + """When data_source is provided without client, _client_spec should be None.""" qc = QueryChatBase(sample_df, "users") - assert qc.client_spec is None - - -class TestClientSpecProperty: - """Tests for the client_spec property setter.""" - - def test_client_spec_setter_string(self): - """Setting client_spec with a string should store it.""" - qc = QueryChatBase(None, "users") - qc.client_spec = "openai" - assert qc.client_spec == "openai" - - def test_client_spec_setter_with_chat_object(self, monkeypatch): - """Setting client_spec with a Chat object should store it.""" - monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") - chat = ChatOpenAI() - qc = QueryChatBase(None, "users") - qc.client_spec = chat - assert qc.client_spec is chat - - def test_client_spec_setter_none(self): - """Setting client_spec to None should reset to default behavior.""" - qc = QueryChatBase(None, "users", client="openai") - qc.client_spec = None - assert qc.client_spec is None - + assert qc._client_spec is None class TestClientMethodRequirements: """Tests that methods properly require data_source to be set.""" @@ -96,27 +71,25 @@ def test_generate_greeting_requires_data_source(self): class TestDeferredClientIntegration: """Integration tests for the full deferred client workflow.""" - def test_deferred_data_source_and_client(self, sample_df, monkeypatch): - """Test setting both data_source and client after init.""" + def test_deferred_data_source_uses_default_client(self, sample_df, monkeypatch): + """Test setting the data source later still resolves the default client.""" monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") qc = QueryChatBase(None, "users") assert qc.data_source is None - assert qc.client_spec is None + assert qc._client_spec is None qc.data_source = sample_df - qc.client_spec = "openai" client = qc.client() assert client is not None assert "users" in qc.system_prompt - def test_deferred_client_then_data_source(self, sample_df, monkeypatch): - """Test setting client before data_source.""" + def test_deferred_explicit_client_at_init_then_data_source(self, sample_df, monkeypatch): + """Test an explicit deferred client passed at init still works later.""" monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") - qc = QueryChatBase(None, "users") - qc.client_spec = "openai" + qc = QueryChatBase(None, "users", client="openai") qc.data_source = sample_df client = qc.client() @@ -128,7 +101,7 @@ def test_no_openai_key_error_when_deferred(self, monkeypatch): monkeypatch.delenv("QUERYCHAT_CLIENT", raising=False) qc = QueryChatBase(None, "users") - assert qc.client_spec is None + assert qc._client_spec is None class TestBackwardCompatibility: @@ -140,8 +113,8 @@ def test_immediate_pattern_unchanged(self, sample_df, monkeypatch): qc = QueryChatBase(sample_df, "test_table") assert qc.data_source is not None - # client_spec is None (will use env default at resolution time) - assert qc.client_spec is None + # _client_spec is None (will use env default at resolution time) + assert qc._client_spec is None client = qc.client() assert client is not None diff --git a/pkg-py/tests/test_deferred_datasource.py b/pkg-py/tests/test_deferred_datasource.py index dc45151aa..522c98ec9 100644 --- a/pkg-py/tests/test_deferred_datasource.py +++ b/pkg-py/tests/test_deferred_datasource.py @@ -141,14 +141,11 @@ def test_deferred_then_set_property(self, sample_df): """Test setting data_source via property after init.""" qc = QueryChatBase(None, "users") assert qc.data_source is None - assert qc.client_spec is None + assert qc._client_spec is None qc.data_source = sample_df assert qc.data_source is not None - qc.client_spec = "openai" - assert qc.client_spec == "openai" - client = qc.client() assert client is not None assert "users" in qc.system_prompt diff --git a/pkg-py/tests/test_deferred_shiny.py b/pkg-py/tests/test_deferred_shiny.py index eff8ab517..84894825a 100644 --- a/pkg-py/tests/test_deferred_shiny.py +++ b/pkg-py/tests/test_deferred_shiny.py @@ -4,6 +4,7 @@ import pandas as pd import pytest +from chatlas import ChatOpenAI from querychat import QueryChat from querychat.express import QueryChat as ExpressQueryChat from shiny.express._stub_session import ExpressStubSession @@ -70,3 +71,12 @@ def test_express_requires_data_source_when_deferred(self): with session_context(ExpressStubSession()): with pytest.raises(RuntimeError, match="data_source must be set"): ExpressQueryChat(None, "users") + + def test_server_can_clear_client_with_none(self, sample_df): + """server(client=None) should clear a previously configured client.""" + qc = QueryChat(None, "users", client=ChatOpenAI()) + + with session_context(ExpressStubSession()): + qc.server(data_source=sample_df, client=None) + + assert qc._client_spec is None diff --git a/pkg-py/tests/test_querychat.py b/pkg-py/tests/test_querychat.py index a7cc9af16..fbd89c8d3 100644 --- a/pkg-py/tests/test_querychat.py +++ b/pkg-py/tests/test_querychat.py @@ -1,4 +1,5 @@ import os +from unittest.mock import patch import pandas as pd import polars as pl @@ -87,6 +88,27 @@ def test_querychat_client_has_system_prompt(sample_df): assert "test_table" in client.system_prompt +def test_generate_greeting_uses_querychat_system_prompt(sample_df): + """generate_greeting() should use the dataset-aware querychat system prompt.""" + qc = QueryChat( + data_source=sample_df, + table_name="test_table", + greeting="Hello!", + ) + seen: dict[str, str | None] = {} + + def fake_chat(self, *args, **kwargs): + seen["system_prompt"] = self.system_prompt + return "Hello from querychat" + + with patch("chatlas.Chat.chat", fake_chat): + greeting = qc.generate_greeting() + + assert greeting == "Hello from querychat" + assert seen["system_prompt"] is not None + assert "test_table" in seen["system_prompt"] + + def test_querychat_with_polars_lazyframe(): """Test that QueryChat accepts a Polars LazyFrame.""" lf = pl.LazyFrame( From e30a39a00f20a2f6f29436ff209a8fe673190a62 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 10:25:38 -0500 Subject: [PATCH 12/37] test(py): lock down shiny session-local client semantics --- pkg-py/tests/test_deferred_shiny.py | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/pkg-py/tests/test_deferred_shiny.py b/pkg-py/tests/test_deferred_shiny.py index 84894825a..1130e6abd 100644 --- a/pkg-py/tests/test_deferred_shiny.py +++ b/pkg-py/tests/test_deferred_shiny.py @@ -80,3 +80,45 @@ def test_server_can_clear_client_with_none(self, sample_df): qc.server(data_source=sample_df, client=None) assert qc._client_spec is None + + def test_server_client_override_does_not_mutate_shared_client_spec(self, sample_df): + """server(client=...) should keep the override session-local.""" + init_client = ChatOpenAI(model="gpt-4.1") + override_client = ChatOpenAI(model="gpt-4.1-mini") + qc = QueryChat(None, "users", client=init_client) + + with session_context(ExpressStubSession()): + qc.server(data_source=sample_df, client=override_client) + + assert qc._client_spec is init_client + + def test_server_without_init_or_override_client_raises_early(self, sample_df): + """Deferred Shiny setup should fail from server() when no client is available.""" + qc = QueryChat(None, "users") + + with session_context(ExpressStubSession()): + with pytest.raises(RuntimeError, match="client must be set"): + qc.server(data_source=sample_df) + + def test_server_rejects_explicit_none_client(self, sample_df): + """server(client=None) is invalid because None is ambiguous in this API.""" + qc = QueryChat(None, "users", client=ChatOpenAI()) + + with session_context(ExpressStubSession()): + with pytest.raises(RuntimeError, match="client must be set"): + qc.server(data_source=sample_df, client=None) + + def test_multiple_server_overrides_do_not_leak_into_shared_state(self, sample_df): + """Sequential overrides should not overwrite the instance-level client spec.""" + init_client = ChatOpenAI(model="gpt-4.1") + first_override = ChatOpenAI(model="gpt-4.1-mini") + second_override = ChatOpenAI(model="gpt-4.1-nano") + qc = QueryChat(None, "users", client=init_client) + + with session_context(ExpressStubSession()): + qc.server(data_source=sample_df, client=first_override) + + with session_context(ExpressStubSession()): + qc.server(data_source=sample_df, client=second_override) + + assert qc._client_spec is init_client From 40e7d88c44546cd66374491f9ee010ca5c750cf3 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 10:29:09 -0500 Subject: [PATCH 13/37] test(py): refine deferred shiny client semantics tests --- pkg-py/tests/test_deferred_shiny.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/pkg-py/tests/test_deferred_shiny.py b/pkg-py/tests/test_deferred_shiny.py index 1130e6abd..c0ce44ad7 100644 --- a/pkg-py/tests/test_deferred_shiny.py +++ b/pkg-py/tests/test_deferred_shiny.py @@ -7,6 +7,7 @@ from chatlas import ChatOpenAI from querychat import QueryChat from querychat.express import QueryChat as ExpressQueryChat +from querychat._querychat_base import create_client as _create_client from shiny.express._stub_session import ExpressStubSession from shiny.session import session_context @@ -72,24 +73,29 @@ def test_express_requires_data_source_when_deferred(self): with pytest.raises(RuntimeError, match="data_source must be set"): ExpressQueryChat(None, "users") - def test_server_can_clear_client_with_none(self, sample_df): - """server(client=None) should clear a previously configured client.""" - qc = QueryChat(None, "users", client=ChatOpenAI()) - - with session_context(ExpressStubSession()): - qc.server(data_source=sample_df, client=None) - - assert qc._client_spec is None - - def test_server_client_override_does_not_mutate_shared_client_spec(self, sample_df): + def test_server_client_override_does_not_mutate_shared_client_spec( + self, sample_df, monkeypatch + ): """server(client=...) should keep the override session-local.""" init_client = ChatOpenAI(model="gpt-4.1") override_client = ChatOpenAI(model="gpt-4.1-mini") qc = QueryChat(None, "users", client=init_client) + recorded_specs = [] + real_create_client = _create_client + + def spy_create_client(client_spec): + recorded_specs.append(client_spec) + return real_create_client(client_spec) + + monkeypatch.setattr( + "querychat._querychat_base.create_client", spy_create_client + ) with session_context(ExpressStubSession()): qc.server(data_source=sample_df, client=override_client) + assert recorded_specs + assert recorded_specs[-1] is override_client assert qc._client_spec is init_client def test_server_without_init_or_override_client_raises_early(self, sample_df): From 39b4f9a7b1d6163bf3120ccbb47301bced4d7418 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 10:35:21 -0500 Subject: [PATCH 14/37] fix(py): keep shiny server client overrides session-local --- pkg-py/src/querychat/_querychat_base.py | 41 +++++++++++++++++++++++-- pkg-py/src/querychat/_shiny.py | 29 ++++++++++++----- 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index 8f94ab088..8788e9d39 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -121,15 +121,16 @@ def _require_data_source(self, method_name: str) -> DataSource[IntoFrameT]: ) return self._data_source - def _create_session_client( + def _create_session_client_from_spec( self, + client_spec: str | chatlas.Chat | None, *, tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None | MISSING_TYPE = MISSING, update_dashboard: Callable[[UpdateDashboardData], None] | None = None, reset_dashboard: Callable[[], None] | None = None, ) -> chatlas.Chat: - """Create a fresh, fully-configured Chat from the current spec.""" - chat = create_client(self._client_spec) + """Create a fresh, fully-configured Chat from an explicit spec.""" + chat = create_client(client_spec) resolved_tools = normalize_tools(tools, default=self.tools) @@ -155,6 +156,40 @@ def _create_session_client( return chat + def _create_session_client( + self, + *, + tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None | MISSING_TYPE = MISSING, + update_dashboard: Callable[[UpdateDashboardData], None] | None = None, + reset_dashboard: Callable[[], None] | None = None, + ) -> chatlas.Chat: + """Create a fresh, fully-configured Chat from the current spec.""" + return self._create_session_client_from_spec( + self._client_spec, + tools=tools, + update_dashboard=update_dashboard, + reset_dashboard=reset_dashboard, + ) + + def _require_client_spec( + self, + method_name: str, + client: str | chatlas.Chat | None | MISSING_TYPE, + ) -> str | chatlas.Chat: + """Resolve a usable client spec or raise with a method-specific message.""" + if isinstance(client, MISSING_TYPE): + resolved_client = self._client_spec + else: + resolved_client = client + + if resolved_client is None: + raise RuntimeError( + f"client must be set before calling {method_name}(). " + "Either pass client to __init__() or pass client to server()." + ) + + return resolved_client + def client( self, *, diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index 5c65c262f..a992aba04 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -425,11 +425,12 @@ def server( before initializing server logic. This is useful for the deferred pattern where data_source is not known at initialization time. client - Optional chat client to use. If provided, updates the deferred client - configuration before initializing server logic. This is useful for the - deferred pattern where the client cannot be created at initialization - time (e.g., when using Posit Connect managed OAuth credentials that - require session access). + Optional chat client to use for this session. If provided, overrides + any client set at initialization time for this call only. This is useful + for the deferred pattern where the client cannot be created at + initialization time (e.g., when using Posit Connect managed OAuth + credentials that require session access). Passing ``client=None`` is + not supported. enable_bookmarking Whether to enable bookmarking for the querychat module. id @@ -492,16 +493,28 @@ def title(): if data_source is not None: self.data_source = data_source - if not isinstance(client, MISSING_TYPE): - self._client_spec = client resolved_data_source = self._require_data_source("server") + resolved_client_spec = self._require_client_spec("server", client) + + def create_session_client( + *, + tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None | MISSING_TYPE = MISSING, + update_dashboard: Callable[[UpdateDashboardData], None] | None = None, + reset_dashboard: Callable[[], None] | None = None, + ) -> chatlas.Chat: + return self._create_session_client_from_spec( + resolved_client_spec, + tools=tools, + update_dashboard=update_dashboard, + reset_dashboard=reset_dashboard, + ) return mod_server( id or self.id, data_source=resolved_data_source, greeting=self.greeting, - client=self._create_session_client, + client=create_session_client, enable_bookmarking=enable_bookmarking, ) From e7805e7efd6a237c59b4afadd586cbae7f9dc2df Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 10:44:35 -0500 Subject: [PATCH 15/37] fix(py): clarify server client requirement --- pkg-py/docs/build.qmd | 10 +++++++--- pkg-py/examples/03-sidebar-core-app.py | 3 ++- pkg-py/src/querychat/_querychat_base.py | 7 +++++++ pkg-py/src/querychat/_shiny.py | 2 +- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/pkg-py/docs/build.qmd b/pkg-py/docs/build.qmd index 009f6cfd0..758f8cfa4 100644 --- a/pkg-py/docs/build.qmd +++ b/pkg-py/docs/build.qmd @@ -26,9 +26,10 @@ from querychat.express import QueryChat Once imported, initialize it with your [data source](data-sources.qmd): ```{.python} +from chatlas import ChatOpenAI from querychat.data import titanic -qc = QueryChat(titanic(), "titanic") +qc = QueryChat(titanic(), "titanic", client=ChatOpenAI()) ``` ::: {.callout-note collapse="true"} @@ -130,10 +131,11 @@ Some data sources, like database connections or reactive calculations, may need ```python from shiny import App, ui +from chatlas import ChatOpenAI from querychat import QueryChat # Global scope - create QueryChat without data source -qc = QueryChat(None, "users") +qc = QueryChat(None, "users", client=ChatOpenAI()) app_ui = ui.page_sidebar( qc.sidebar(), @@ -184,9 +186,10 @@ with ui.sidebar(): ```python from shiny import ui +from chatlas import ChatOpenAI from querychat import QueryChat -qc = QueryChat(data, "my_data") +qc = QueryChat(data, "my_data", client=ChatOpenAI()) app_ui = ui.page_sidebar( ui.sidebar( @@ -280,6 +283,7 @@ def _(): ```python ui.input_action_button("reset", "Reset Filters") +# Assumes qc was created with a client (e.g., client=ChatOpenAI()). qc_vals = qc.server() @reactive.effect diff --git a/pkg-py/examples/03-sidebar-core-app.py b/pkg-py/examples/03-sidebar-core-app.py index 0302b0a9e..f0387abf8 100644 --- a/pkg-py/examples/03-sidebar-core-app.py +++ b/pkg-py/examples/03-sidebar-core-app.py @@ -1,5 +1,6 @@ from pathlib import Path +from chatlas import ChatOpenAI from shiny import App, render, ui from querychat import QueryChat from querychat.data import titanic @@ -7,7 +8,7 @@ greeting = Path(__file__).parent / "greeting.md" # 1. Provide data source to QueryChat -qc = QueryChat(titanic(), "titanic", greeting=greeting) +qc = QueryChat(titanic(), "titanic", greeting=greeting, client=ChatOpenAI()) app_ui = ui.page_sidebar( # 2. Create sidebar chat control diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index 8788e9d39..00a7a8572 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -177,6 +177,13 @@ def _require_client_spec( client: str | chatlas.Chat | None | MISSING_TYPE, ) -> str | chatlas.Chat: """Resolve a usable client spec or raise with a method-specific message.""" + if not isinstance(client, MISSING_TYPE) and client is None: + raise RuntimeError( + f"client must be set before calling {method_name}(). " + "Passing client=None is not supported. " + "Either pass client to __init__() or pass client to server()." + ) + if isinstance(client, MISSING_TYPE): resolved_client = self._client_spec else: diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index a992aba04..302b0b889 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -406,7 +406,7 @@ def server( self, *, data_source: Optional[IntoFrame | sqlalchemy.Engine | ibis.Table] = None, - client: str | chatlas.Chat | None | MISSING_TYPE = MISSING, + client: str | chatlas.Chat | MISSING_TYPE = MISSING, enable_bookmarking: bool = False, id: Optional[str] = None, ) -> ServerValues[IntoFrameT]: From 1edad32a820881aa66b3e0cadd25d18802294ba9 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 10:51:28 -0500 Subject: [PATCH 16/37] fix(py): align server docs and types --- pkg-py/src/querychat/_shiny.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index 302b0b889..ce75c3892 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -16,6 +16,7 @@ from ._utils import as_narwhals if TYPE_CHECKING: + from collections.abc import Callable from pathlib import Path import chatlas @@ -24,6 +25,8 @@ import sqlalchemy from narwhals.stable.v1.typing import IntoFrame + from .tools import UpdateDashboardData + class QueryChat(QueryChatBase[IntoFrameT]): """ @@ -443,11 +446,12 @@ def server( ```python from shiny import App, render, ui from seaborn import load_dataset + from chatlas import ChatOpenAI from querychat import QueryChat titanic = load_dataset("titanic") - qc = QueryChat(titanic, "titanic") + qc = QueryChat(titanic, "titanic", client=ChatOpenAI()) def app_ui(request): From fbffc9aaa13d58dc286a8bdf01b7d4830c75239e Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 10:56:26 -0500 Subject: [PATCH 17/37] test(py): cover deferred shiny client resolution helpers --- pkg-py/tests/test_deferred_client.py | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/pkg-py/tests/test_deferred_client.py b/pkg-py/tests/test_deferred_client.py index 61e9c222d..cdf19ad5b 100644 --- a/pkg-py/tests/test_deferred_client.py +++ b/pkg-py/tests/test_deferred_client.py @@ -4,6 +4,7 @@ import pytest from chatlas import ChatOpenAI from querychat._querychat_base import QueryChatBase +from querychat._utils import MISSING @pytest.fixture @@ -121,3 +122,39 @@ def test_immediate_pattern_unchanged(self, sample_df, monkeypatch): prompt = qc.system_prompt assert "test_table" in prompt + + +class TestRequireClientSpec: + """Tests for the server client resolution helper.""" + + def test_require_client_spec_uses_init_client_when_server_client_missing( + self, + monkeypatch, + ): + """Init-time client should be reused when server client is not provided.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") + chat = ChatOpenAI() + qc = QueryChatBase(None, "users", client=chat) + + assert qc._require_client_spec("server", MISSING) is chat + + def test_require_client_spec_raises_when_no_client_available(self): + """Missing client spec should raise a method-specific RuntimeError.""" + qc = QueryChatBase(None, "users") + + with pytest.raises( + RuntimeError, + match=r"client must be set before calling server\(\)", + ): + qc._require_client_spec("server", MISSING) + + def test_require_client_spec_rejects_explicit_none(self, monkeypatch): + """Passing client=None is rejected even if an init client exists.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") + qc = QueryChatBase(None, "users", client=ChatOpenAI()) + + with pytest.raises( + RuntimeError, + match="client=None is not supported", + ): + qc._require_client_spec("server", None) From 0b7d9782f60f0d1c7096005dcfb1167a4ca80e3f Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 11:04:36 -0500 Subject: [PATCH 18/37] test(py): verify session-local shiny client fix From 73b5338cddf41eec6d8eeedd0b59683c2b00533f Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 11:08:56 -0500 Subject: [PATCH 19/37] style(py): satisfy ruff for deferred client fix --- pkg-py/src/querychat/_shiny.py | 3 +-- pkg-py/tests/test_base.py | 10 ++++++++-- pkg-py/tests/test_deferred_shiny.py | 26 ++++++++++++++++---------- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index ce75c3892..17c97d89f 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -12,8 +12,7 @@ from ._icons import bs_icon from ._querychat_base import TOOL_GROUPS, QueryChatBase from ._shiny_module import ServerValues, mod_server, mod_ui -from ._utils import MISSING, MISSING_TYPE -from ._utils import as_narwhals +from ._utils import MISSING, MISSING_TYPE, as_narwhals if TYPE_CHECKING: from collections.abc import Callable diff --git a/pkg-py/tests/test_base.py b/pkg-py/tests/test_base.py index 037b1f3bd..1aa75c562 100644 --- a/pkg-py/tests/test_base.py +++ b/pkg-py/tests/test_base.py @@ -207,9 +207,15 @@ def test_client_with_callbacks(self, sample_df): update_called = [] reset_called = [] + def update_dashboard(data): + update_called.append(data) + + def reset_dashboard(): + reset_called.append(True) + client = qc.client( - update_dashboard=lambda data: update_called.append(data), - reset_dashboard=lambda: reset_called.append(True), + update_dashboard=update_dashboard, + reset_dashboard=reset_dashboard, ) assert isinstance(client, chatlas.Chat) diff --git a/pkg-py/tests/test_deferred_shiny.py b/pkg-py/tests/test_deferred_shiny.py index c0ce44ad7..66ae1f18c 100644 --- a/pkg-py/tests/test_deferred_shiny.py +++ b/pkg-py/tests/test_deferred_shiny.py @@ -6,8 +6,8 @@ import pytest from chatlas import ChatOpenAI from querychat import QueryChat -from querychat.express import QueryChat as ExpressQueryChat from querychat._querychat_base import create_client as _create_client +from querychat.express import QueryChat as ExpressQueryChat from shiny.express._stub_session import ExpressStubSession from shiny.session import session_context @@ -69,9 +69,11 @@ def test_app_requires_data_source(self): def test_express_requires_data_source_when_deferred(self): """Express should fail with a clear error when data_source is still deferred.""" - with session_context(ExpressStubSession()): - with pytest.raises(RuntimeError, match="data_source must be set"): - ExpressQueryChat(None, "users") + with ( + session_context(ExpressStubSession()), + pytest.raises(RuntimeError, match="data_source must be set"), + ): + ExpressQueryChat(None, "users") def test_server_client_override_does_not_mutate_shared_client_spec( self, sample_df, monkeypatch @@ -102,17 +104,21 @@ def test_server_without_init_or_override_client_raises_early(self, sample_df): """Deferred Shiny setup should fail from server() when no client is available.""" qc = QueryChat(None, "users") - with session_context(ExpressStubSession()): - with pytest.raises(RuntimeError, match="client must be set"): - qc.server(data_source=sample_df) + with ( + session_context(ExpressStubSession()), + pytest.raises(RuntimeError, match="client must be set"), + ): + qc.server(data_source=sample_df) def test_server_rejects_explicit_none_client(self, sample_df): """server(client=None) is invalid because None is ambiguous in this API.""" qc = QueryChat(None, "users", client=ChatOpenAI()) - with session_context(ExpressStubSession()): - with pytest.raises(RuntimeError, match="client must be set"): - qc.server(data_source=sample_df, client=None) + with ( + session_context(ExpressStubSession()), + pytest.raises(RuntimeError, match="client must be set"), + ): + qc.server(data_source=sample_df, client=None) def test_multiple_server_overrides_do_not_leak_into_shared_state(self, sample_df): """Sequential overrides should not overwrite the instance-level client spec.""" From 95e7c341120f20376dfef9ae9cb86744f8336de0 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 12:15:01 -0500 Subject: [PATCH 20/37] refactor(py): remove _require_client_spec, allow None as valid default Co-Authored-By: Claude Sonnet 4.6 --- pkg-py/src/querychat/_querychat_base.py | 26 ------------------------- pkg-py/src/querychat/_shiny.py | 2 +- 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index 00a7a8572..03c54d700 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -171,32 +171,6 @@ def _create_session_client( reset_dashboard=reset_dashboard, ) - def _require_client_spec( - self, - method_name: str, - client: str | chatlas.Chat | None | MISSING_TYPE, - ) -> str | chatlas.Chat: - """Resolve a usable client spec or raise with a method-specific message.""" - if not isinstance(client, MISSING_TYPE) and client is None: - raise RuntimeError( - f"client must be set before calling {method_name}(). " - "Passing client=None is not supported. " - "Either pass client to __init__() or pass client to server()." - ) - - if isinstance(client, MISSING_TYPE): - resolved_client = self._client_spec - else: - resolved_client = client - - if resolved_client is None: - raise RuntimeError( - f"client must be set before calling {method_name}(). " - "Either pass client to __init__() or pass client to server()." - ) - - return resolved_client - def client( self, *, diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index 17c97d89f..0fbb6bfd8 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -498,7 +498,7 @@ def title(): self.data_source = data_source resolved_data_source = self._require_data_source("server") - resolved_client_spec = self._require_client_spec("server", client) + resolved_client_spec = client if not isinstance(client, MISSING_TYPE) else self._client_spec def create_session_client( *, From 6e16e4b83edd8177d8a7c174d7fb24574234f924 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 12:17:52 -0500 Subject: [PATCH 21/37] fix(py): remove stale docstring about client=None, improve readability Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/_shiny.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index 0fbb6bfd8..deee8ca57 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -431,8 +431,7 @@ def server( any client set at initialization time for this call only. This is useful for the deferred pattern where the client cannot be created at initialization time (e.g., when using Posit Connect managed OAuth - credentials that require session access). Passing ``client=None`` is - not supported. + credentials that require session access). enable_bookmarking Whether to enable bookmarking for the querychat module. id @@ -498,7 +497,7 @@ def title(): self.data_source = data_source resolved_data_source = self._require_data_source("server") - resolved_client_spec = client if not isinstance(client, MISSING_TYPE) else self._client_spec + resolved_client_spec = self._client_spec if isinstance(client, MISSING_TYPE) else client def create_session_client( *, From 0138127f43c7c954219929b1e0ff91c547e998e4 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 12:21:27 -0500 Subject: [PATCH 22/37] test(py): update tests for None-as-valid-default client behavior Remove TestRequireClientSpec class and two tests that asserted None is an invalid client argument, since _require_client_spec no longer exists and None now resolves to the QUERYCHAT_CLIENT env var or "openai". Co-Authored-By: Claude Sonnet 4.6 --- pkg-py/tests/test_deferred_client.py | 38 ---------------------------- pkg-py/tests/test_deferred_shiny.py | 20 --------------- 2 files changed, 58 deletions(-) diff --git a/pkg-py/tests/test_deferred_client.py b/pkg-py/tests/test_deferred_client.py index cdf19ad5b..5421dc13f 100644 --- a/pkg-py/tests/test_deferred_client.py +++ b/pkg-py/tests/test_deferred_client.py @@ -4,8 +4,6 @@ import pytest from chatlas import ChatOpenAI from querychat._querychat_base import QueryChatBase -from querychat._utils import MISSING - @pytest.fixture def sample_df(): @@ -122,39 +120,3 @@ def test_immediate_pattern_unchanged(self, sample_df, monkeypatch): prompt = qc.system_prompt assert "test_table" in prompt - - -class TestRequireClientSpec: - """Tests for the server client resolution helper.""" - - def test_require_client_spec_uses_init_client_when_server_client_missing( - self, - monkeypatch, - ): - """Init-time client should be reused when server client is not provided.""" - monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") - chat = ChatOpenAI() - qc = QueryChatBase(None, "users", client=chat) - - assert qc._require_client_spec("server", MISSING) is chat - - def test_require_client_spec_raises_when_no_client_available(self): - """Missing client spec should raise a method-specific RuntimeError.""" - qc = QueryChatBase(None, "users") - - with pytest.raises( - RuntimeError, - match=r"client must be set before calling server\(\)", - ): - qc._require_client_spec("server", MISSING) - - def test_require_client_spec_rejects_explicit_none(self, monkeypatch): - """Passing client=None is rejected even if an init client exists.""" - monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing") - qc = QueryChatBase(None, "users", client=ChatOpenAI()) - - with pytest.raises( - RuntimeError, - match="client=None is not supported", - ): - qc._require_client_spec("server", None) diff --git a/pkg-py/tests/test_deferred_shiny.py b/pkg-py/tests/test_deferred_shiny.py index 66ae1f18c..a7fda32b4 100644 --- a/pkg-py/tests/test_deferred_shiny.py +++ b/pkg-py/tests/test_deferred_shiny.py @@ -100,26 +100,6 @@ def spy_create_client(client_spec): assert recorded_specs[-1] is override_client assert qc._client_spec is init_client - def test_server_without_init_or_override_client_raises_early(self, sample_df): - """Deferred Shiny setup should fail from server() when no client is available.""" - qc = QueryChat(None, "users") - - with ( - session_context(ExpressStubSession()), - pytest.raises(RuntimeError, match="client must be set"), - ): - qc.server(data_source=sample_df) - - def test_server_rejects_explicit_none_client(self, sample_df): - """server(client=None) is invalid because None is ambiguous in this API.""" - qc = QueryChat(None, "users", client=ChatOpenAI()) - - with ( - session_context(ExpressStubSession()), - pytest.raises(RuntimeError, match="client must be set"), - ): - qc.server(data_source=sample_df, client=None) - def test_multiple_server_overrides_do_not_leak_into_shared_state(self, sample_df): """Sequential overrides should not overwrite the instance-level client spec.""" init_client = ChatOpenAI(model="gpt-4.1") From ceb0a0951edf92e96dc60f8d8166e9c8e6ee7dc3 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 12:28:43 -0500 Subject: [PATCH 23/37] docs(py): revert unnecessary explicit client= in examples and docs Co-Authored-By: Claude Sonnet 4.6 --- pkg-py/docs/build.qmd | 10 +++------- pkg-py/examples/03-sidebar-core-app.py | 3 +-- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/pkg-py/docs/build.qmd b/pkg-py/docs/build.qmd index 758f8cfa4..009f6cfd0 100644 --- a/pkg-py/docs/build.qmd +++ b/pkg-py/docs/build.qmd @@ -26,10 +26,9 @@ from querychat.express import QueryChat Once imported, initialize it with your [data source](data-sources.qmd): ```{.python} -from chatlas import ChatOpenAI from querychat.data import titanic -qc = QueryChat(titanic(), "titanic", client=ChatOpenAI()) +qc = QueryChat(titanic(), "titanic") ``` ::: {.callout-note collapse="true"} @@ -131,11 +130,10 @@ Some data sources, like database connections or reactive calculations, may need ```python from shiny import App, ui -from chatlas import ChatOpenAI from querychat import QueryChat # Global scope - create QueryChat without data source -qc = QueryChat(None, "users", client=ChatOpenAI()) +qc = QueryChat(None, "users") app_ui = ui.page_sidebar( qc.sidebar(), @@ -186,10 +184,9 @@ with ui.sidebar(): ```python from shiny import ui -from chatlas import ChatOpenAI from querychat import QueryChat -qc = QueryChat(data, "my_data", client=ChatOpenAI()) +qc = QueryChat(data, "my_data") app_ui = ui.page_sidebar( ui.sidebar( @@ -283,7 +280,6 @@ def _(): ```python ui.input_action_button("reset", "Reset Filters") -# Assumes qc was created with a client (e.g., client=ChatOpenAI()). qc_vals = qc.server() @reactive.effect diff --git a/pkg-py/examples/03-sidebar-core-app.py b/pkg-py/examples/03-sidebar-core-app.py index f0387abf8..0302b0a9e 100644 --- a/pkg-py/examples/03-sidebar-core-app.py +++ b/pkg-py/examples/03-sidebar-core-app.py @@ -1,6 +1,5 @@ from pathlib import Path -from chatlas import ChatOpenAI from shiny import App, render, ui from querychat import QueryChat from querychat.data import titanic @@ -8,7 +7,7 @@ greeting = Path(__file__).parent / "greeting.md" # 1. Provide data source to QueryChat -qc = QueryChat(titanic(), "titanic", greeting=greeting, client=ChatOpenAI()) +qc = QueryChat(titanic(), "titanic", greeting=greeting) app_ui = ui.page_sidebar( # 2. Create sidebar chat control From 716d543e0a7e104a66f349e661a3cef18b7f291d Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 12:30:45 -0500 Subject: [PATCH 24/37] style(py): fix ruff formatting in test_deferred_client Co-Authored-By: Claude Opus 4.6 --- pkg-py/tests/test_deferred_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg-py/tests/test_deferred_client.py b/pkg-py/tests/test_deferred_client.py index 5421dc13f..61e9c222d 100644 --- a/pkg-py/tests/test_deferred_client.py +++ b/pkg-py/tests/test_deferred_client.py @@ -5,6 +5,7 @@ from chatlas import ChatOpenAI from querychat._querychat_base import QueryChatBase + @pytest.fixture def sample_df(): """Create a sample pandas DataFrame for testing.""" From 3faee22350a743fcea232b49247a482d5ec79c42 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 12:38:38 -0500 Subject: [PATCH 25/37] refactor(py): collapse _create_session_client_from_spec into _create_session_client Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/_querychat_base.py | 27 +++++-------------------- pkg-py/src/querychat/_shiny.py | 17 +++------------- 2 files changed, 8 insertions(+), 36 deletions(-) diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index 03c54d700..153fb3c5a 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -121,16 +121,17 @@ def _require_data_source(self, method_name: str) -> DataSource[IntoFrameT]: ) return self._data_source - def _create_session_client_from_spec( + def _create_session_client( self, - client_spec: str | chatlas.Chat | None, *, + client_spec: str | chatlas.Chat | None | MISSING_TYPE = MISSING, tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None | MISSING_TYPE = MISSING, update_dashboard: Callable[[UpdateDashboardData], None] | None = None, reset_dashboard: Callable[[], None] | None = None, ) -> chatlas.Chat: - """Create a fresh, fully-configured Chat from an explicit spec.""" - chat = create_client(client_spec) + """Create a fresh, fully-configured Chat.""" + spec = self._client_spec if isinstance(client_spec, MISSING_TYPE) else client_spec + chat = create_client(spec) resolved_tools = normalize_tools(tools, default=self.tools) @@ -140,9 +141,6 @@ def _create_session_client_from_spec( if resolved_tools is None: return chat - # Safety net — public callers (client(), server(), app()) should call - # _require_data_source() first so users see a message referencing the - # method they actually called. data_source = self._require_data_source("_create_session_client") if "update" in resolved_tools: @@ -156,21 +154,6 @@ def _create_session_client_from_spec( return chat - def _create_session_client( - self, - *, - tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None | MISSING_TYPE = MISSING, - update_dashboard: Callable[[UpdateDashboardData], None] | None = None, - reset_dashboard: Callable[[], None] | None = None, - ) -> chatlas.Chat: - """Create a fresh, fully-configured Chat from the current spec.""" - return self._create_session_client_from_spec( - self._client_spec, - tools=tools, - update_dashboard=update_dashboard, - reset_dashboard=reset_dashboard, - ) - def client( self, *, diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index deee8ca57..3789d80d3 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -15,7 +15,6 @@ from ._utils import MISSING, MISSING_TYPE, as_narwhals if TYPE_CHECKING: - from collections.abc import Callable from pathlib import Path import chatlas @@ -24,8 +23,6 @@ import sqlalchemy from narwhals.stable.v1.typing import IntoFrame - from .tools import UpdateDashboardData - class QueryChat(QueryChatBase[IntoFrameT]): """ @@ -499,17 +496,9 @@ def title(): resolved_data_source = self._require_data_source("server") resolved_client_spec = self._client_spec if isinstance(client, MISSING_TYPE) else client - def create_session_client( - *, - tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None | MISSING_TYPE = MISSING, - update_dashboard: Callable[[UpdateDashboardData], None] | None = None, - reset_dashboard: Callable[[], None] | None = None, - ) -> chatlas.Chat: - return self._create_session_client_from_spec( - resolved_client_spec, - tools=tools, - update_dashboard=update_dashboard, - reset_dashboard=reset_dashboard, + def create_session_client(**kwargs) -> chatlas.Chat: + return self._create_session_client( + client_spec=resolved_client_spec, **kwargs ) return mod_server( From 881c04e0b3efd6c3a16f9420315e7b1b76f15080 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 12:45:44 -0500 Subject: [PATCH 26/37] fix(py): restore _require_data_source check in console() for error clarity Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/_querychat_base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index 153fb3c5a..fc6c7e9ec 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -200,6 +200,7 @@ def console( **kwargs, ) -> None: """Launch an interactive console chat with the data.""" + self._require_data_source("console") if new or self._client_console is None: self._client_console = self.client(tools=tools, **kwargs) From 63ff49db4ee17a2df74c9a925c3b51604b554ecf Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 12:46:23 -0500 Subject: [PATCH 27/37] docs(py): remove unnecessary client= from server() docstring example Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/_shiny.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index 3789d80d3..c25b923fc 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -441,12 +441,11 @@ def server( ```python from shiny import App, render, ui from seaborn import load_dataset - from chatlas import ChatOpenAI from querychat import QueryChat titanic = load_dataset("titanic") - qc = QueryChat(titanic, "titanic", client=ChatOpenAI()) + qc = QueryChat(titanic, "titanic") def app_ui(request): From 59769dcb1f8aa6ab2ea291289025ebe9454463e5 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 12:48:09 -0500 Subject: [PATCH 28/37] docs(py): add deferred client feature to changelog Co-Authored-By: Claude Opus 4.6 --- pkg-py/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index c3346cfde..a02fe5f68 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### New features + +* `QueryChat()` now supports deferred chat client initialization. Pass `client=` to `server()` to provide a session-scoped chat client, enabling use cases where API credentials are only available at session time (e.g., Posit Connect managed OAuth tokens). When no `client` is specified anywhere, querychat resolves a sensible default from the `QUERYCHAT_CLIENT` environment variable (or `"openai"`). (#205) + ### Improvements * When a custom `prompt_template` is provided that doesn't contain Mustache references to `{{schema}}`, the expensive `get_schema()` call is now skipped entirely. This allows users with large databases to avoid slow startup by providing their own prompt that includes schema information inline (or omits it). (#208) From fc9b5b75fcc3124aae8a7d30f6048c6cade33f60 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 13:07:07 -0500 Subject: [PATCH 29/37] refactor(r): defer client resolution, add create_session_client() Store the client spec (NULL/string/Chat) lazily instead of eagerly resolving it in initialize(). Add private create_session_client() as the single resolution point, and delegate $client() and $generate_greeting() to it. This enables constructing QueryChat with NULL data_source and no API key, deferring all resolution until the client is actually needed. Co-Authored-By: Claude Opus 4.6 --- pkg-r/R/QueryChat.R | 80 +++++++++++++++------------ pkg-r/tests/testthat/test-QueryChat.R | 58 +++++++++++++++++++ 2 files changed, 104 insertions(+), 34 deletions(-) diff --git a/pkg-r/R/QueryChat.R b/pkg-r/R/QueryChat.R index 20574442a..f90a6fe1c 100644 --- a/pkg-r/R/QueryChat.R +++ b/pkg-r/R/QueryChat.R @@ -92,7 +92,7 @@ QueryChat <- R6::R6Class( server_values = NULL, .data_source = NULL, .table_name = NULL, - .client = NULL, + .client_spec = NULL, .client_console = NULL, .system_prompt = NULL, # Store init parameters for deferred system prompt building @@ -132,6 +132,43 @@ QueryChat <- R6::R6Class( extra_instructions = private$.extra_instructions, categorical_threshold = private$.categorical_threshold ) + }, + + create_session_client = function( + client_spec = NULL, + tools = NA, + update_dashboard = function(query, title) {}, + reset_dashboard = function() {} + ) { + spec <- client_spec %||% private$.client_spec + chat <- as_querychat_client(spec) + chat$set_turns(list()) + + if (is_na(tools)) { + tools <- self$tools + } + + chat$set_system_prompt(private$.system_prompt$render(tools = tools)) + + if (is.null(tools)) { + return(chat) + } + + if ("update" %in% tools) { + chat$register_tool( + tool_update_dashboard( + private$.data_source, + update_fn = update_dashboard + ) + ) + chat$register_tool(tool_reset_dashboard(reset_dashboard)) + } + + if ("query" %in% tools) { + chat$register_tool(tool_query(private$.data_source)) + } + + chat } ), public = list( @@ -253,10 +290,7 @@ QueryChat <- R6::R6Class( private$build_system_prompt() } - # Fork and empty chat now so the per-session forks are fast - client <- as_querychat_client(client) - private$.client <- client$clone() - private$.client$set_turns(list()) + private$.client_spec <- client # By default, only close automatically if a Shiny session is active if (is.na(cleanup)) { @@ -290,10 +324,7 @@ QueryChat <- R6::R6Class( ) { private$require_data_source("$client") - if (is_na(tools)) { - tools <- self$tools - } - if (!is.null(tools)) { + if (!is_na(tools) && !is.null(tools)) { tools <- arg_match( tools, values = c("update", "query"), @@ -301,28 +332,11 @@ QueryChat <- R6::R6Class( ) } - chat <- private$.client$clone() - chat$set_system_prompt(private$.system_prompt$render(tools = tools)) - - if (is.null(tools)) { - return(chat) - } - - if ("update" %in% tools) { - chat$register_tool( - tool_update_dashboard( - private$.data_source, - update_fn = update_dashboard - ) - ) - chat$register_tool(tool_reset_dashboard(reset_dashboard)) - } - - if ("query" %in% tools) { - chat$register_tool(tool_query(private$.data_source)) - } - - chat + private$create_session_client( + tools = tools, + update_dashboard = update_dashboard, + reset_dashboard = reset_dashboard + ) }, #' @description @@ -701,9 +715,7 @@ QueryChat <- R6::R6Class( #' @return The greeting string in Markdown format. generate_greeting = function(echo = c("none", "output")) { private$require_data_source("$generate_greeting") - chat <- self$client() - chat$set_turns(list()) - + chat <- private$create_session_client() as.character(chat$chat(GREETING_PROMPT, echo = echo)) }, diff --git a/pkg-r/tests/testthat/test-QueryChat.R b/pkg-r/tests/testthat/test-QueryChat.R index b01a66c5e..ce43cd5d8 100644 --- a/pkg-r/tests/testthat/test-QueryChat.R +++ b/pkg-r/tests/testthat/test-QueryChat.R @@ -101,6 +101,64 @@ describe("QueryChat$new()", { }) }) +describe("QueryChat deferred client", { + it("accepts NULL data_source with table_name", { + qc <- QueryChat$new(NULL, "users", greeting = "Test") + expect_null(qc$data_source) + expect_equal(qc$id, "querychat_users") + }) + + it("requires table_name when data_source is NULL", { + expect_error( + QueryChat$new(NULL), + "table_name.*required" + ) + }) + + it("stores client spec without resolving it", { + withr::local_envvar(OPENAI_API_KEY = NA) + withr::local_options(querychat.client = NULL) + qc <- QueryChat$new(NULL, "users", greeting = "Test") + expect_null(qc$data_source) + }) + + it("stores explicit client string as spec", { + withr::local_envvar(OPENAI_API_KEY = "boop") + qc <- QueryChat$new(NULL, "users", greeting = "Test", client = "openai") + expect_null(qc$data_source) + }) + + it("$client() errors when data_source is NULL", { + qc <- QueryChat$new(NULL, "users", greeting = "Test") + expect_error(qc$client(), "data_source.*must be set") + }) + + it("$console() errors when data_source is NULL", { + qc <- QueryChat$new(NULL, "users", greeting = "Test") + expect_error(qc$console(), "data_source.*must be set") + }) + + it("$generate_greeting() errors when data_source is NULL", { + qc <- QueryChat$new(NULL, "users", greeting = "Test") + expect_error(qc$generate_greeting(), "data_source.*must be set") + }) + + it("$system_prompt errors when data_source is NULL", { + qc <- QueryChat$new(NULL, "users", greeting = "Test") + expect_error(qc$system_prompt, "data_source.*must be set") + }) + + it("works after setting data_source later", { + skip_if_no_dataframe_engine() + qc <- QueryChat$new(NULL, "users", greeting = "Test") + qc$data_source <- new_users_df() + + expect_s3_class(qc$data_source, "DataFrameSource") + prompt <- qc$system_prompt + expect_match(prompt, "users") + }) +}) + describe("QueryChat integration with DBISource", { it("works with iris dataset queries", { skip_if_not_installed("RSQLite") From 28d69c92d25f060f912c112894b8669a155c5cd1 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 13:09:07 -0500 Subject: [PATCH 30/37] feat(r): add client parameter to $server() for session-local overrides The client parameter allows per-session chat client overrides without mutating the shared client spec. This enables Posit Connect managed OAuth credentials that require session scope. Co-Authored-By: Claude Opus 4.6 --- pkg-r/R/QueryChat.R | 17 ++++++++++++- pkg-r/tests/testthat/test-QueryChat.R | 35 +++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/pkg-r/R/QueryChat.R b/pkg-r/R/QueryChat.R index f90a6fe1c..746f1d8a8 100644 --- a/pkg-r/R/QueryChat.R +++ b/pkg-r/R/QueryChat.R @@ -637,6 +637,11 @@ QueryChat <- R6::R6Class( #' for the deferred pattern where data_source is not known at #' initialization time (e.g., when the data source depends on session- #' specific authentication). + #' @param client Optional chat client override for this session. Can be an + #' [ellmer::Chat] object or a string (e.g., `"openai/gpt-4o"`). If provided, + #' overrides the client set at initialization for this session only — + #' other sessions are unaffected. This is useful when the client must be + #' created within a session scope (e.g., Posit Connect managed credentials). #' @param enable_bookmarking Whether to enable bookmarking for the chat #' state. Default is `FALSE`. When enabled, the chat state (including #' current query, title, and chat history) will be saved and restored @@ -660,6 +665,7 @@ QueryChat <- R6::R6Class( #' server = function( data_source = NULL, + client = NULL, enable_bookmarking = FALSE, ..., id = NULL, @@ -680,11 +686,20 @@ QueryChat <- R6::R6Class( private$require_data_source("$server") + resolved_client_spec <- client %||% private$.client_spec + + create_session_client <- function(...) { + private$create_session_client( + client_spec = resolved_client_spec, + ... + ) + } + mod_server( id %||% self$id, data_source = private$.data_source, greeting = self$greeting, - client = self$client, + client = create_session_client, enable_bookmarking = enable_bookmarking ) }, diff --git a/pkg-r/tests/testthat/test-QueryChat.R b/pkg-r/tests/testthat/test-QueryChat.R index ce43cd5d8..f6014d591 100644 --- a/pkg-r/tests/testthat/test-QueryChat.R +++ b/pkg-r/tests/testthat/test-QueryChat.R @@ -742,3 +742,38 @@ test_that("querychat_app() only cleans up data frame sources on exit", { expect_false(cleanup_result) }) }) + +describe("QueryChat$server() client override", { + it("accepts a client parameter", { + withr::local_envvar(OPENAI_API_KEY = "boop") + test_df <- new_test_df() + qc <- QueryChat$new(test_df, table_name = "test_df", greeting = "Test") + withr::defer(qc$cleanup()) + + expect_error( + qc$server(client = "openai"), + "must be called within a Shiny server function" + ) + }) +}) + +describe("QueryChat deferred client with $server()", { + it("$server() errors when data_source is NULL", { + qc <- QueryChat$new(NULL, "users", greeting = "Test") + expect_error( + qc$server(), + "must be called within a Shiny server function" + ) + }) + + it("$server(data_source=...) sets the data_source", { + skip_if_no_dataframe_engine() + qc <- QueryChat$new(NULL, "users", greeting = "Test") + expect_null(qc$data_source) + + expect_error( + qc$server(data_source = new_users_df()), + "must be called within a Shiny server function" + ) + }) +}) From e3ea543a12ee597d32c2382799e4656093dd9d2d Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 13:09:56 -0500 Subject: [PATCH 31/37] style(r): format deferred client changes with air Co-Authored-By: Claude Opus 4.6 --- pkg-r/R/QueryChat.R | 37 ++++++++++++++++----------- pkg-r/tests/testthat/test-QueryChat.R | 23 ++++++++++++----- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/pkg-r/R/QueryChat.R b/pkg-r/R/QueryChat.R index 746f1d8a8..27a1ca564 100644 --- a/pkg-r/R/QueryChat.R +++ b/pkg-r/R/QueryChat.R @@ -137,8 +137,10 @@ QueryChat <- R6::R6Class( create_session_client = function( client_spec = NULL, tools = NA, - update_dashboard = function(query, title) {}, - reset_dashboard = function() {} + update_dashboard = function(query, title) { + }, + reset_dashboard = function() { + } ) { spec <- client_spec %||% private$.client_spec chat <- as_querychat_client(spec) @@ -319,8 +321,10 @@ QueryChat <- R6::R6Class( #' `reset_dashboard` tool is called. client = function( tools = NA, - update_dashboard = function(query, title) {}, - reset_dashboard = function() {} + update_dashboard = function(query, title) { + }, + reset_dashboard = function() { + } ) { private$require_data_source("$client") @@ -423,10 +427,12 @@ QueryChat <- R6::R6Class( ui <- function(req) { bslib::page_sidebar( - title = shiny::HTML(sprintf( - "querychat with %s", - table_name - )), + title = shiny::HTML( + sprintf( + "querychat with %s", + table_name + ) + ), class = "bslib-page-dashboard", sidebar = self$sidebar(), shiny::useBusyIndicators(pulse = TRUE, spinners = FALSE), @@ -522,12 +528,14 @@ QueryChat <- R6::R6Class( }) shiny::observeEvent(input$close_btn, label = "on_close_btn", { - shiny::stopApp(list( - df = qc_vals$df(), - sql = qc_vals$sql(), - title = qc_vals$title(), - client = qc_vals$client - )) + shiny::stopApp( + list( + df = qc_vals$df(), + sql = qc_vals$sql(), + title = qc_vals$title(), + client = qc_vals$client + ) + ) }) } @@ -970,7 +978,6 @@ normalize_data_source <- function(data_source, table_name) { ) } - namespaced_id <- function(id, session = shiny::getDefaultReactiveDomain()) { if (is.null(session)) { id diff --git a/pkg-r/tests/testthat/test-QueryChat.R b/pkg-r/tests/testthat/test-QueryChat.R index f6014d591..74a986ec0 100644 --- a/pkg-r/tests/testthat/test-QueryChat.R +++ b/pkg-r/tests/testthat/test-QueryChat.R @@ -422,9 +422,13 @@ describe("QueryChat$client()", { # Find and call the update tool tools <- client$get_tools() - update_tool <- tools[[which(sapply(tools, function(t) { - t@name == "querychat_update_dashboard" - }))]] + update_tool <- tools[[ + which( + sapply(tools, function(t) { + t@name == "querychat_update_dashboard" + }) + ) + ]] # Call the tool - it should execute the query and call the callback result <- update_tool( @@ -454,9 +458,13 @@ describe("QueryChat$client()", { # Find and call the reset tool tools <- client$get_tools() - reset_tool <- tools[[which(sapply(tools, function(t) { - t@name == "querychat_reset_dashboard" - }))]] + reset_tool <- tools[[ + which( + sapply(tools, function(t) { + t@name == "querychat_reset_dashboard" + }) + ) + ]] # Call the tool reset_tool() @@ -715,7 +723,8 @@ test_that("querychat_app() only cleans up data frame sources on exit", { # have to use an option because the code is evaluated in a far-away env options(.test_cleanup = cleanup) }, - app = function(...) {} + app = function(...) { + } ) ) withr::local_options(rlang_interactive = TRUE) From fee90c57130ad491bb29949c255c83cf11dcef65 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 13:30:41 -0500 Subject: [PATCH 32/37] fix(r): clone Chat in create_session_client() to prevent mutation When the client spec is a Chat object, as_querychat_client() returns it as-is. Without cloning, subsequent calls to $client() would mutate the same object (duplicate tool registrations, shared turn history). Co-Authored-By: Claude Opus 4.6 --- pkg-r/R/QueryChat.R | 1 + pkg-r/tests/testthat/test-QueryChat.R | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/pkg-r/R/QueryChat.R b/pkg-r/R/QueryChat.R index 27a1ca564..3775c5d8c 100644 --- a/pkg-r/R/QueryChat.R +++ b/pkg-r/R/QueryChat.R @@ -144,6 +144,7 @@ QueryChat <- R6::R6Class( ) { spec <- client_spec %||% private$.client_spec chat <- as_querychat_client(spec) + chat <- chat$clone() chat$set_turns(list()) if (is_na(tools)) { diff --git a/pkg-r/tests/testthat/test-QueryChat.R b/pkg-r/tests/testthat/test-QueryChat.R index 74a986ec0..4fa0ad133 100644 --- a/pkg-r/tests/testthat/test-QueryChat.R +++ b/pkg-r/tests/testthat/test-QueryChat.R @@ -491,6 +491,27 @@ describe("QueryChat$client()", { expect_length(client2$get_turns(), 0) }) + it("returns independent instances when client spec is a Chat object", { + withr::local_envvar(OPENAI_API_KEY = "boop") + chat_spec <- ellmer::chat_openai() + + qc <- QueryChat$new( + new_test_df(), + "test_df", + client = chat_spec + ) + withr::defer(qc$cleanup()) + + client1 <- qc$client() + client2 <- qc$client() + + expect_false(identical(client1, client2)) + + client1$set_turns(list(ellmer::Turn("user", "test message"))) + expect_length(client1$get_turns(), 1) + expect_length(client2$get_turns(), 0) + }) + it("respects QueryChat initialization tools by default", { qc_query_only <- QueryChat$new( new_test_df(), From 8079c3ba13bc0bd0be7fa7223a9d1bf202e14f2e Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 13:31:43 -0500 Subject: [PATCH 33/37] fix(py): keep deferred client resolution lazy --- pkg-py/src/querychat/_querychat_base.py | 4 +++- pkg-py/src/querychat/_shiny_module.py | 8 +++++--- pkg-py/tests/test_deferred_client.py | 13 +++++++++++++ pkg-py/tests/test_deferred_shiny.py | 21 ++++++++++----------- pkg-py/tests/test_querychat.py | 18 ++++++++++++++++++ 5 files changed, 49 insertions(+), 15 deletions(-) diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index fc6c7e9ec..d3bf29e26 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -189,7 +189,9 @@ def client( def generate_greeting(self, *, echo: Literal["none", "output"] = "none") -> str: """Generate a welcome greeting for the chat.""" self._require_data_source("generate_greeting") - chat = self._create_session_client() + chat = create_client(self._client_spec) + if self._system_prompt is not None: + chat.system_prompt = self._system_prompt.render(self.tools) return str(chat.chat(GREETING_PROMPT, echo=echo)) def console( diff --git a/pkg-py/src/querychat/_shiny_module.py b/pkg-py/src/querychat/_shiny_module.py index 335f6803a..4264285bd 100644 --- a/pkg-py/src/querychat/_shiny_module.py +++ b/pkg-py/src/querychat/_shiny_module.py @@ -78,14 +78,16 @@ class ServerValues(Generic[IntoFrameT]): client The session-specific chat client instance. This is a deep copy of the base client configured for this specific session, containing the chat - history and tool registrations for this session only. + history and tool registrations for this session only. This may be + `None` during stub sessions when the client depends on deferred, + session-scoped state. """ df: Callable[[], IntoFrameT] sql: ReactiveStringOrNone title: ReactiveStringOrNone - client: chatlas.Chat + client: chatlas.Chat | None @module.server @@ -115,7 +117,7 @@ def _stub_df(): df=_stub_df, sql=sql, title=title, - client=client if isinstance(client, chatlas.Chat) else client(), + client=client if isinstance(client, chatlas.Chat) else None, ) # Real session requires data_source diff --git a/pkg-py/tests/test_deferred_client.py b/pkg-py/tests/test_deferred_client.py index 61e9c222d..bdd2f6aa0 100644 --- a/pkg-py/tests/test_deferred_client.py +++ b/pkg-py/tests/test_deferred_client.py @@ -43,6 +43,11 @@ def test_init_with_data_source_no_client(self, sample_df): qc = QueryChatBase(sample_df, "users") assert qc._client_spec is None + def test_init_with_invalid_explicit_client_is_still_lazy(self): + """Explicit client specs should be stored lazily and fail only when resolved.""" + qc = QueryChatBase(None, "users", client="not_a_real_provider_xyz123") + assert qc._client_spec == "not_a_real_provider_xyz123" + class TestClientMethodRequirements: """Tests that methods properly require data_source to be set.""" @@ -103,6 +108,14 @@ def test_no_openai_key_error_when_deferred(self, monkeypatch): qc = QueryChatBase(None, "users") assert qc._client_spec is None + def test_invalid_explicit_client_raises_when_client_is_resolved(self, sample_df): + """Invalid explicit client specs should fail when a live client is requested.""" + qc = QueryChatBase(None, "users", client="not_a_real_provider_xyz123") + qc.data_source = sample_df + + with pytest.raises(ValueError, match="is not a known chatlas provider"): + qc.client() + class TestBackwardCompatibility: """Tests that existing patterns continue to work.""" diff --git a/pkg-py/tests/test_deferred_shiny.py b/pkg-py/tests/test_deferred_shiny.py index a7fda32b4..39899a772 100644 --- a/pkg-py/tests/test_deferred_shiny.py +++ b/pkg-py/tests/test_deferred_shiny.py @@ -67,18 +67,17 @@ def test_app_requires_data_source(self): with pytest.raises(RuntimeError, match="data_source must be set"): qc.app() - def test_express_requires_data_source_when_deferred(self): - """Express should fail with a clear error when data_source is still deferred.""" - with ( - session_context(ExpressStubSession()), - pytest.raises(RuntimeError, match="data_source must be set"), - ): - ExpressQueryChat(None, "users") + def test_express_allows_deferred_data_source_during_stub_session(self): + """Express should allow deferred initialization during the stub session.""" + with session_context(ExpressStubSession()): + qc = ExpressQueryChat(None, "users") + + assert qc is not None def test_server_client_override_does_not_mutate_shared_client_spec( self, sample_df, monkeypatch ): - """server(client=...) should keep the override session-local.""" + """server(client=...) should stay lazy during the stub session.""" init_client = ChatOpenAI(model="gpt-4.1") override_client = ChatOpenAI(model="gpt-4.1-mini") qc = QueryChat(None, "users", client=init_client) @@ -94,10 +93,10 @@ def spy_create_client(client_spec): ) with session_context(ExpressStubSession()): - qc.server(data_source=sample_df, client=override_client) + vals = qc.server(data_source=sample_df, client=override_client) - assert recorded_specs - assert recorded_specs[-1] is override_client + assert vals.client is None + assert recorded_specs == [] assert qc._client_spec is init_client def test_multiple_server_overrides_do_not_leak_into_shared_state(self, sample_df): diff --git a/pkg-py/tests/test_querychat.py b/pkg-py/tests/test_querychat.py index fbd89c8d3..7d8b94258 100644 --- a/pkg-py/tests/test_querychat.py +++ b/pkg-py/tests/test_querychat.py @@ -109,6 +109,24 @@ def fake_chat(self, *args, **kwargs): assert "test_table" in seen["system_prompt"] +def test_generate_greeting_does_not_register_querychat_tools(sample_df): + """generate_greeting() should use a plain chat client without dashboard/query tools.""" + qc = QueryChat( + data_source=sample_df, + table_name="test_table", + greeting="Hello!", + ) + + with ( + patch("chatlas.Chat.register_tool") as register_tool, + patch("chatlas.Chat.chat", return_value="Hello from querychat"), + ): + greeting = qc.generate_greeting() + + assert greeting == "Hello from querychat" + register_tool.assert_not_called() + + def test_querychat_with_polars_lazyframe(): """Test that QueryChat accepts a Polars LazyFrame.""" lf = pl.LazyFrame( From e93d60b65abf139fcebb9cb03420ea5846caeffd Mon Sep 17 00:00:00 2001 From: cpsievert Date: Thu, 16 Apr 2026 18:38:33 +0000 Subject: [PATCH 34/37] `air format` (GitHub Actions) --- pkg-r/R/QueryChat.R | 12 ++++-------- pkg-r/tests/testthat/test-QueryChat.R | 3 +-- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/pkg-r/R/QueryChat.R b/pkg-r/R/QueryChat.R index 3775c5d8c..a471c4dd2 100644 --- a/pkg-r/R/QueryChat.R +++ b/pkg-r/R/QueryChat.R @@ -137,10 +137,8 @@ QueryChat <- R6::R6Class( create_session_client = function( client_spec = NULL, tools = NA, - update_dashboard = function(query, title) { - }, - reset_dashboard = function() { - } + update_dashboard = function(query, title) {}, + reset_dashboard = function() {} ) { spec <- client_spec %||% private$.client_spec chat <- as_querychat_client(spec) @@ -322,10 +320,8 @@ QueryChat <- R6::R6Class( #' `reset_dashboard` tool is called. client = function( tools = NA, - update_dashboard = function(query, title) { - }, - reset_dashboard = function() { - } + update_dashboard = function(query, title) {}, + reset_dashboard = function() {} ) { private$require_data_source("$client") diff --git a/pkg-r/tests/testthat/test-QueryChat.R b/pkg-r/tests/testthat/test-QueryChat.R index 4fa0ad133..71d1e6aa3 100644 --- a/pkg-r/tests/testthat/test-QueryChat.R +++ b/pkg-r/tests/testthat/test-QueryChat.R @@ -744,8 +744,7 @@ test_that("querychat_app() only cleans up data frame sources on exit", { # have to use an option because the code is evaluated in a far-away env options(.test_cleanup = cleanup) }, - app = function(...) { - } + app = function(...) {} ) ) withr::local_options(rlang_interactive = TRUE) From 9b66f375c19562cf6ff5cd47427d56d136e0ee9e Mon Sep 17 00:00:00 2001 From: cpsievert Date: Thu, 16 Apr 2026 18:38:37 +0000 Subject: [PATCH 35/37] `devtools::document()` (GitHub Actions) --- pkg-r/man/QueryChat.Rd | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg-r/man/QueryChat.Rd b/pkg-r/man/QueryChat.Rd index 2d782737b..da67c32e1 100644 --- a/pkg-r/man/QueryChat.Rd +++ b/pkg-r/man/QueryChat.Rd @@ -466,6 +466,7 @@ server <- function(input, output, session) \{ \subsection{Usage}{ \if{html}{\out{
}}\preformatted{QueryChat$server( data_source = NULL, + client = NULL, enable_bookmarking = FALSE, ..., id = NULL, @@ -482,6 +483,12 @@ for the deferred pattern where data_source is not known at initialization time (e.g., when the data source depends on session- specific authentication).} +\item{\code{client}}{Optional chat client override for this session. Can be an +\link[ellmer:Chat]{ellmer::Chat} object or a string (e.g., \code{"openai/gpt-4o"}). If provided, +overrides the client set at initialization for this session only — +other sessions are unaffected. This is useful when the client must be +created within a session scope (e.g., Posit Connect managed credentials).} + \item{\code{enable_bookmarking}}{Whether to enable bookmarking for the chat state. Default is \code{FALSE}. When enabled, the chat state (including current query, title, and chat history) will be saved and restored From 7ada432e30e666328a4082f82d58a9dc62b990dc Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 13:35:16 -0500 Subject: [PATCH 36/37] docs(r): add deferred client feature to NEWS.md Co-Authored-By: Claude Opus 4.6 --- pkg-r/NEWS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md index 6620d2159..639010cac 100644 --- a/pkg-r/NEWS.md +++ b/pkg-r/NEWS.md @@ -1,5 +1,7 @@ # querychat (development version) +* `QueryChat$server()` now accepts a `client` parameter for session-scoped chat client overrides. This enables Posit Connect managed OAuth workflows where API credentials are only available inside the Shiny server function. The client spec is stored lazily at construction time and resolved only when needed, so `QueryChat$new(NULL, "table")` no longer requires an API key. (#205) + * When a custom `prompt_template` is provided that doesn't contain Mustache references to `{{schema}}`, the expensive `get_schema()` call is now skipped entirely. This allows users with large databases to avoid slow startup by providing their own prompt that includes schema information inline (or omits it). (#208) * Added support for Snowflake Semantic Views. When connected to Snowflake via DBI, querychat automatically discovers available Semantic Views and includes their definitions in the system prompt. This helps the LLM generate correct queries using the `SEMANTIC_VIEW()` table function with certified business metrics and dimensions. (#200) From b7ff8d1807180035922ac43fb0738539192990b2 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 13:39:04 -0500 Subject: [PATCH 37/37] docs(r): mention deferred client in build vignette Co-Authored-By: Claude Opus 4.6 --- pkg-r/vignettes/build.Rmd | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg-r/vignettes/build.Rmd b/pkg-r/vignettes/build.Rmd index 2c0ce7370..860cea0f7 100644 --- a/pkg-r/vignettes/build.Rmd +++ b/pkg-r/vignettes/build.Rmd @@ -118,6 +118,8 @@ server <- function(input, output, session) { shinyApp(ui, server) ``` +If your chat client also depends on session-scoped credentials, you can defer that too by passing it to `qc$server(client = ...)` alongside the `data_source`. + This is also a useful pattern when using something like [`{pool}`](https://github.com/rstudio/pool) to efficiently manage a pool of database connections (which we strongly recommend for production apps). ## Reactives