Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7eccd84
feat(py): add TableSet value object and build_query_executor()
cpsievert Sep 12, 2026
d9ccdfb
fix(py): SQLAlchemySource.cleanup() no longer disposes the caller's e…
cpsievert Sep 12, 2026
2240b6f
feat(r): add internal TableSet class and validate_source_group_compat…
cpsievert Sep 12, 2026
9f712a7
fix(r): DBISource/TblSqlSource cleanup() no longer disconnect the cal…
cpsievert Sep 12, 2026
d9fa920
fix: move TblSqlSource cleanup test to end of file per brief requirement
cpsievert Sep 12, 2026
5775b4b
refactor(py): give each Shiny session a read-only TableSet instead of…
cpsievert Sep 12, 2026
3c53e8d
refactor(r): give each Shiny session a read-only TableSet instead of …
cpsievert Sep 12, 2026
28a0e87
fix(r): don't close a caller-supplied DataSource when its session ends
cpsievert Sep 12, 2026
f58f755
refactor(py): track one owned base client; session overrides close on…
cpsievert Sep 12, 2026
9ac54f6
docs(r): describe session-scoped $server(data_source = ) and the clea…
cpsievert Sep 12, 2026
84f580f
docs(py): describe session-scoped server(data_source=) and the cleanu…
cpsievert Sep 12, 2026
f221546
fix(py): close final-review gaps in session-local table set refactor
cpsievert Sep 13, 2026
3112b36
fix(r): PinSource$cleanup() no longer leaks its own DuckDB connection
cpsievert Sep 13, 2026
a0329b9
fix(r): keep $add_table() and $server(data_source=) description infer…
cpsievert Sep 13, 2026
ce4e979
fix(r): TableSet$cleanup_executor() resets its executor so it can reb…
cpsievert Sep 13, 2026
f7e18f9
test(r): rename a test that no longer claims to close anything
cpsievert Sep 13, 2026
30073d2
fix(r): don't let a rejected add_table()/add_tables() call corrupt th…
cpsievert Sep 13, 2026
e28a11d
Merge branch 'refactor/r-session-local-table-set' into refactor/sessi…
cpsievert Sep 13, 2026
e419052
Merge branch 'refactor/py-session-local-table-set' into refactor/sess…
cpsievert Sep 13, 2026
e36bca3
fix(py): preserve table registration order on replace, warn on failed…
cpsievert Sep 13, 2026
3c214f9
fix: only mark sessions as started once server registration succeeds
cpsievert Sep 13, 2026
dd276a0
fix(py): make remaining teardown paths warn instead of raise, fix Exp…
cpsievert Sep 13, 2026
87796fb
docs(r): fix DBISource/TblSqlSource examples still telling users to s…
cpsievert Sep 13, 2026
e8a5eec
fix: rollback cleanup failures no longer mask the original build/regi…
cpsievert Sep 13, 2026
2c48077
fix(py): make TableSet.data_sources/system_prompt read-only
cpsievert Sep 13, 2026
94df207
refactor(py): make TableSet generic so mod_server keeps IntoFrameT ty…
cpsievert Sep 13, 2026
5aa0a3e
chore(r): remove dead in_shiny_session() helper
cpsievert Sep 13, 2026
8562f4f
docs(r): note cleanup=NA auto-close no longer disconnects caller conn…
cpsievert Sep 13, 2026
2bf21cb
fix(py): reject a second pin table at registration time
cpsievert Sep 13, 2026
2d52065
fix(r): reject multiple pins and tables from different DBI connections
cpsievert Sep 13, 2026
fbe94a2
docs(r): clarify cleanup= auto-runs at app stop, not session stop
cpsievert Sep 13, 2026
e3c0cde
style(py): satisfy ruff TRY004/D213 in executor and TableSet
cpsievert Sep 13, 2026
a249b05
docs(r): correct cleanup= wording - onStop() is session-scoped inside…
cpsievert Sep 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions pkg-py/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changes

* `.server(data_source=)` no longer modifies the `QueryChat` instance. The table is registered for that session only: the instance's tables, greeting tables, and system prompt are unchanged, a same-named instance table is shadowed for that session, and the session's data source is cleaned up when the session ends. This removes the concurrent-session edge cases that `0.8.0` patched around (#300, #302, #303, #304, #308).

* `cleanup()` follows one rule: querychat closes only what it created. `SQLAlchemySource.cleanup()` no longer disposes your engine; dispose it yourself on shutdown. `DataFrameSource`/`PinSource` DuckDB connections and querychat-created chat clients are still closed.

* Adding a *new* table with `add_table()`/`add_tables()` after a session has started now warns instead of raising; running sessions keep their tables and new sessions see the addition. Replacing or removing an existing table after a session has started still raises.

* `cleanup()` no longer closes a spec-resolved `.server(client=...)` override while its session is still running; it is closed when the session ends.

* Registering a second pins table with `add_table()` now raises a clear error at registration time instead of failing at query time: each pin queries through its own DuckDB connection, so cross-pin queries can't run. To combine a pin with other tables, register them in a shared DuckDB connection and pass that instead.

## [0.8.0] - 2026-09-12

### New features
Expand Down
2 changes: 2 additions & 0 deletions pkg-py/docs/build.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,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`.

A data source passed to `qc.server()` belongs to that session: other sessions never see it, it does not change the tables registered on `qc`, and querychat cleans up any connection it created for it when the session ends. If you create the connection yourself (a SQLAlchemy engine, an Ibis backend), closing it is up to you; `session.on_ended` is a good place.

:::

:::
Expand Down
23 changes: 8 additions & 15 deletions pkg-py/src/querychat/_datasource.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,15 +298,12 @@ def get_data(self) -> IntoFrameT:
@abstractmethod
def cleanup(self) -> None:
"""
Clean up resources associated with the data source.

This method should clean up any connections or resources used by the
data source.

Returns
-------
None
Release resources this data source created.

Only resources querychat created are closed here (for example the
in-memory DuckDB connection a ``DataFrameSource`` opens). Connections,
engines, and backends passed in by the caller are never closed; their
lifecycle stays with the caller.
"""

def get_data_description(self) -> str:
Expand Down Expand Up @@ -819,15 +816,11 @@ def _get_connection(self) -> Connection:

def cleanup(self) -> None:
"""
Dispose of the SQLAlchemy engine.

Returns
-------
None
No-op: the SQLAlchemy engine is owned by the caller.

Dispose it yourself with ``engine.dispose()`` when your application
shuts down.
"""
if self._engine:
self._engine.dispose()


class PolarsLazySource(DataSource["pl.LazyFrame"]):
Expand Down
39 changes: 38 additions & 1 deletion pkg-py/src/querychat/_query_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast

import duckdb
import narwhals.stable.v1 as nw
Expand All @@ -19,6 +19,8 @@
from ._utils import check_query

if TYPE_CHECKING:
from collections.abc import Mapping

from ._datasource import DataFrameSource, DataSource, PolarsLazySource


Expand Down Expand Up @@ -266,6 +268,7 @@ def check_source_compatibility(
IbisSource,
SQLAlchemySource,
)
from ._pin_source import PinSource

first_source = next(iter(existing.values()))

Expand All @@ -276,6 +279,20 @@ def check_source_compatibility(
f"Existing tables use {type(first_source).__name__}."
)

# Reached only when the existing sources are also PinSources: a second pin
# would validate here but fail at query time, since each pin queries
# through its own private connection and DataSourceExecutor delegates all
# queries to the first one.
if isinstance(new_source, PinSource):
# ValueError like the neighboring checks: this is a group constraint
# violation, not a wrong-argument-type error (contra TRY004).
raise ValueError( # noqa: TRY004
f"Cannot add pin '{new_name}': only one pin table is supported per "
"chat. Each pin queries through its own DuckDB connection, so "
"cross-pin queries can't run. To combine a pin with other tables, "
"register them in a shared DuckDB connection and pass that instead."
)

if isinstance(new_source, DataFrameSource) and isinstance(
first_source, DataFrameSource
):
Expand Down Expand Up @@ -314,3 +331,23 @@ def get_dataframe_backend_name(source: DataFrameSource) -> str:
return nw.get_native_namespace(
nw.from_native(source.get_data(), eager_only=True)
).__name__


def build_query_executor(sources: Mapping[str, DataSource]) -> QueryExecutor:
"""Pick the executor for a compatible group of sources."""
from ._datasource import DataFrameSource, PolarsLazySource

# After validation, every source has the same type as the first one.
validate_source_group_compatibility(dict(sources))

if len(sources) == 1:
return DataSourceExecutor(dict(sources))

first_source = next(iter(sources.values()))

if isinstance(first_source, DataFrameSource):
return DuckDBExecutor(cast("dict[str, DataFrameSource]", dict(sources)))
if isinstance(first_source, PolarsLazySource):
return PolarsSQLExecutor(cast("dict[str, PolarsLazySource]", dict(sources)))

return DataSourceExecutor(dict(sources))
Loading
Loading