From 96883f9de09ea38f8b5261ce225d1e619b0cecb6 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 26 Apr 2026 01:20:29 +0000 Subject: [PATCH 01/14] fix(packaging): add starlette framework extra --- pyproject.toml | 1 + tests/unit/test_package_metadata.py | 20 ++++++++++++++++++++ uv.lock | 8 ++++++-- 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_package_metadata.py diff --git a/pyproject.toml b/pyproject.toml index 45e9ec3e..f995f76b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ pydantic = ["pydantic", "pydantic-extra-types"] pymssql = ["pymssql"] pymysql = ["pymysql"] spanner = ["google-cloud-spanner"] +starlette = ["starlette"] uuid = ["uuid-utils"] [dependency-groups] diff --git a/tests/unit/test_package_metadata.py b/tests/unit/test_package_metadata.py new file mode 100644 index 00000000..cc582150 --- /dev/null +++ b/tests/unit/test_package_metadata.py @@ -0,0 +1,20 @@ +from pathlib import Path + +try: + import tomllib +except ImportError: # pragma: no cover + import tomli as tomllib + + +PROJECT_ROOT = Path(__file__).parents[2] + + +def test_framework_optional_dependencies_are_defined() -> None: + pyproject = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text()) + + optional_dependencies = pyproject["project"]["optional-dependencies"] + + assert optional_dependencies["fastapi"] == ["fastapi"] + assert optional_dependencies["flask"] == ["flask"] + assert optional_dependencies["litestar"] == ["litestar"] + assert optional_dependencies["starlette"] == ["starlette"] diff --git a/uv.lock b/uv.lock index 1ce16fa0..870a99bf 100644 --- a/uv.lock +++ b/uv.lock @@ -1454,7 +1454,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -6971,6 +6971,9 @@ pymysql = [ spanner = [ { name = "google-cloud-spanner" }, ] +starlette = [ + { name = "starlette" }, +] uuid = [ { name = "uuid-utils" }, ] @@ -7184,11 +7187,12 @@ requires-dist = [ { name = "rich-click", specifier = ">=1.9.0" }, { name = "sqlglot", specifier = ">=30.0.0" }, { name = "sqlglot", extras = ["c"], marker = "extra == 'mypyc'", specifier = ">=30.0.0" }, + { name = "starlette", marker = "extra == 'starlette'" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" }, { name = "typing-extensions" }, { name = "uuid-utils", marker = "extra == 'uuid'" }, ] -provides-extras = ["adbc", "adk", "aiomysql", "aioodbc", "aiosqlite", "alloydb", "asyncmy", "asyncpg", "attrs", "bigquery", "cloud-sql", "cockroachdb", "duckdb", "fastapi", "flask", "fsspec", "litestar", "msgspec", "mypyc", "mysql-connector", "nanoid", "obstore", "opentelemetry", "oracledb", "orjson", "pandas", "performance", "polars", "prometheus", "psqlpy", "psycopg", "pydantic", "pymssql", "pymysql", "spanner", "uuid"] +provides-extras = ["adbc", "adk", "aiomysql", "aioodbc", "aiosqlite", "alloydb", "asyncmy", "asyncpg", "attrs", "bigquery", "cloud-sql", "cockroachdb", "duckdb", "fastapi", "flask", "fsspec", "litestar", "msgspec", "mypyc", "mysql-connector", "nanoid", "obstore", "opentelemetry", "oracledb", "orjson", "pandas", "performance", "polars", "prometheus", "psqlpy", "psycopg", "pydantic", "pymssql", "pymysql", "spanner", "starlette", "uuid"] [package.metadata.requires-dev] benchmarks = [ From 5a7dcbb08b7ead5f1a03d08f2c4a7bc84179f6f8 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 26 Apr 2026 01:43:37 +0000 Subject: [PATCH 02/14] fix(fastapi): use fastapi extension config --- .../frameworks/fastapi/multi_database.py | 4 +- docs/reference/extensions/fastapi.rst | 4 +- docs/usage/frameworks/fastapi.rst | 2 +- sqlspec/config.py | 7 +- sqlspec/extensions/fastapi/extension.py | 41 +++++++++++- .../extensions/test_fastapi/test_extension.py | 66 +++++++++++++++++-- 6 files changed, 110 insertions(+), 14 deletions(-) diff --git a/docs/examples/frameworks/fastapi/multi_database.py b/docs/examples/frameworks/fastapi/multi_database.py index f1370894..ccb46cd9 100644 --- a/docs/examples/frameworks/fastapi/multi_database.py +++ b/docs/examples/frameworks/fastapi/multi_database.py @@ -23,7 +23,7 @@ def test_fastapi_multi_database() -> None: AiosqliteConfig( connection_config={"database": ":memory:"}, extension_config={ - "starlette": {"session_key": "db", "connection_key": "db_connection", "pool_key": "db_pool"} + "fastapi": {"session_key": "db", "connection_key": "db_connection", "pool_key": "db_pool"} }, ) ) @@ -33,7 +33,7 @@ def test_fastapi_multi_database() -> None: SqliteConfig( connection_config={"database": ":memory:"}, extension_config={ - "starlette": {"session_key": "etl_db", "connection_key": "etl_connection", "pool_key": "etl_pool"} + "fastapi": {"session_key": "etl_db", "connection_key": "etl_connection", "pool_key": "etl_pool"} }, ) ) diff --git a/docs/reference/extensions/fastapi.rst b/docs/reference/extensions/fastapi.rst index 6eef0c1c..1b66fe1e 100644 --- a/docs/reference/extensions/fastapi.rst +++ b/docs/reference/extensions/fastapi.rst @@ -2,8 +2,8 @@ FastAPI ====== -FastAPI integration extending the Starlette plugin with dependency injection -helpers for FastAPI's ``Depends()`` system, including filter dependency builders. +FastAPI integration with dependency injection helpers for FastAPI's ``Depends()`` +system, including filter dependency builders. Plugin ====== diff --git a/docs/usage/frameworks/fastapi.rst b/docs/usage/frameworks/fastapi.rst index 74bbea66..f357ae42 100644 --- a/docs/usage/frameworks/fastapi.rst +++ b/docs/usage/frameworks/fastapi.rst @@ -42,7 +42,7 @@ Basic Setup Create a SQLSpec instance, register your database config, and attach the plugin to your FastAPI app. The plugin provides a ``provide_session`` dependency that yields a session -for each request. +for each request. Configure FastAPI-specific options under ``extension_config["fastapi"]``. .. literalinclude:: /examples/frameworks/fastapi/basic_setup.py :language: python diff --git a/sqlspec/config.py b/sqlspec/config.py index 43a3f9aa..e3da12e3 100644 --- a/sqlspec/config.py +++ b/sqlspec/config.py @@ -313,7 +313,7 @@ class LitestarConfig(TypedDict): class StarletteConfig(TypedDict): - """Configuration options for Starlette and FastAPI extensions. + """Configuration options for Starlette SQLSpec extension. All fields are optional with sensible defaults. Use in extension_config["starlette"]: @@ -331,7 +331,6 @@ class StarletteConfig(TypedDict): ) Notes: - Both Starlette and FastAPI extensions use the "starlette" key. This TypedDict provides type safety for extension config. """ @@ -389,7 +388,8 @@ class StarletteConfig(TypedDict): class FastAPIConfig(StarletteConfig): """Configuration options for FastAPI SQLSpec extension. - All fields are optional with sensible defaults. Use in extension_config["fastapi"]: + All fields are optional with sensible defaults. Use in ``extension_config["fastapi"]``. + SQLCommenter defaults the framework attribute to ``"fastapi"``. Example: from sqlspec.adapters.asyncpg import AsyncpgConfig @@ -402,6 +402,7 @@ class FastAPIConfig(StarletteConfig): "session_key": "db" } } + ) """ diff --git a/sqlspec/extensions/fastapi/extension.py b/sqlspec/extensions/fastapi/extension.py index 382980ca..06669034 100644 --- a/sqlspec/extensions/fastapi/extension.py +++ b/sqlspec/extensions/fastapi/extension.py @@ -45,7 +45,7 @@ class SQLSpecPlugin(_StarlettePlugin): AsyncpgConfig( connection_config={"dsn": "postgresql://localhost/mydb"}, extension_config={ - "starlette": { # FastAPI uses the "starlette" key + "fastapi": { "commit_mode": "autocommit", "session_key": "db" } @@ -71,6 +71,45 @@ def __init__(self, sqlspec: SQLSpec, app: "FastAPI | None" = None) -> None: """ super().__init__(sqlspec, app) + def _extract_starlette_settings(self, config: Any) -> "dict[str, Any]": + """Extract FastAPI settings from config.extension_config. + + Args: + config: Database configuration instance. + + Returns: + Dictionary of FastAPI-specific settings. + """ + fastapi_config = config.extension_config.get("fastapi", {}) + + connection_key = fastapi_config.get("connection_key", "db_connection") + pool_key = fastapi_config.get("pool_key", "db_pool") + session_key = fastapi_config.get("session_key", "db_session") + commit_mode = fastapi_config.get("commit_mode", "manual") + + if not config.supports_connection_pooling and pool_key == "db_pool": + pool_key = f"_db_pool_{id(config)}" + + correlation_headers = fastapi_config.get("correlation_headers") + if correlation_headers is not None: + correlation_headers = tuple(correlation_headers) + + return { + "connection_key": connection_key, + "pool_key": pool_key, + "session_key": session_key, + "commit_mode": commit_mode, + "extra_commit_statuses": fastapi_config.get("extra_commit_statuses"), + "extra_rollback_statuses": fastapi_config.get("extra_rollback_statuses"), + "disable_di": fastapi_config.get("disable_di", False), + "enable_correlation_middleware": fastapi_config.get("enable_correlation_middleware", False), + "correlation_header": fastapi_config.get("correlation_header", "x-request-id"), + "correlation_headers": correlation_headers, + "auto_trace_headers": fastapi_config.get("auto_trace_headers", True), + "enable_sqlcommenter_middleware": fastapi_config.get("enable_sqlcommenter_middleware", True), + "sqlcommenter_framework": fastapi_config.get("sqlcommenter_framework", "fastapi"), + } + @overload def provide_session( self, key: None = None diff --git a/tests/unit/extensions/test_fastapi/test_extension.py b/tests/unit/extensions/test_fastapi/test_extension.py index 7940fdb0..2d671d9f 100644 --- a/tests/unit/extensions/test_fastapi/test_extension.py +++ b/tests/unit/extensions/test_fastapi/test_extension.py @@ -45,8 +45,8 @@ def test_provide_connection_method_exists() -> None: assert not hasattr(plugin, "connection_dependency") -def test_uses_starlette_default_session_key() -> None: - """FastAPI inherits from Starlette and should use same DEFAULT_SESSION_KEY.""" +def test_uses_default_session_key() -> None: + """FastAPI should default to DEFAULT_SESSION_KEY.""" sqlspec = SQLSpec() config = AiosqliteConfig(connection_config={"database": ":memory:"}) sqlspec.add_config(config) @@ -59,11 +59,11 @@ def test_uses_starlette_default_session_key() -> None: def test_respects_custom_session_key() -> None: - """Plugin should respect custom session_key via starlette config.""" + """Plugin should respect custom session_key via fastapi config.""" custom_key = "custom_db" sqlspec = SQLSpec() config = AiosqliteConfig( - connection_config={"database": ":memory:"}, extension_config={"starlette": {"session_key": custom_key}} + connection_config={"database": ":memory:"}, extension_config={"fastapi": {"session_key": custom_key}} ) sqlspec.add_config(config) @@ -73,11 +73,67 @@ def test_respects_custom_session_key() -> None: assert plugin._config_states[0].session_key == custom_key # pyright: ignore[reportPrivateUsage] +def test_respects_fastapi_extension_config_key() -> None: + """Plugin should respect FastAPI-specific extension_config.""" + custom_key = "fastapi_db" + sqlspec = SQLSpec() + config = AiosqliteConfig( + connection_config={"database": ":memory:"}, extension_config={"fastapi": {"session_key": custom_key}} + ) + sqlspec.add_config(config) + + plugin = SQLSpecPlugin(sqlspec) + + assert len(plugin._config_states) == 1 # pyright: ignore[reportPrivateUsage] + assert plugin._config_states[0].session_key == custom_key # pyright: ignore[reportPrivateUsage] + + +def test_ignores_starlette_extension_config_key() -> None: + """FastAPI plugin should not read Starlette extension config.""" + sqlspec = SQLSpec() + config = AiosqliteConfig( + connection_config={"database": ":memory:"}, extension_config={"starlette": {"session_key": "starlette_db"}} + ) + sqlspec.add_config(config) + + plugin = SQLSpecPlugin(sqlspec) + + assert len(plugin._config_states) == 1 # pyright: ignore[reportPrivateUsage] + assert plugin._config_states[0].session_key == DEFAULT_SESSION_KEY # pyright: ignore[reportPrivateUsage] + + +def test_fastapi_extension_config_takes_fastapi_config() -> None: + """FastAPI-specific config should own FastAPI settings.""" + sqlspec = SQLSpec() + config = AiosqliteConfig( + connection_config={"database": ":memory:"}, + extension_config={"starlette": {"session_key": "starlette_db"}, "fastapi": {"session_key": "fastapi_db"}}, + ) + sqlspec.add_config(config) + + plugin = SQLSpecPlugin(sqlspec) + + assert len(plugin._config_states) == 1 # pyright: ignore[reportPrivateUsage] + assert plugin._config_states[0].session_key == "fastapi_db" # pyright: ignore[reportPrivateUsage] + + +def test_fastapi_sqlcommenter_framework_defaults_to_fastapi() -> None: + """FastAPI plugin should report FastAPI to SQLCommenter by default.""" + sqlspec = SQLSpec() + config = AiosqliteConfig(connection_config={"database": ":memory:"}) + sqlspec.add_config(config) + + plugin = SQLSpecPlugin(sqlspec) + + assert len(plugin._config_states) == 1 # pyright: ignore[reportPrivateUsage] + assert plugin._config_states[0].sqlcommenter_framework == "fastapi" # pyright: ignore[reportPrivateUsage] + + def test_provide_session_works_in_route() -> None: """Test that provide_session() works correctly in FastAPI routes.""" sqlspec = SQLSpec() config = AiosqliteConfig( - connection_config={"database": ":memory:"}, extension_config={"starlette": {"commit_mode": "autocommit"}} + connection_config={"database": ":memory:"}, extension_config={"fastapi": {"commit_mode": "autocommit"}} ) sqlspec.add_config(config) From f7de958e531a30fb92e0e133cc400471100fc6f9 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 26 Apr 2026 01:50:09 +0000 Subject: [PATCH 03/14] fix(starlette): support sync adapter lifecycle --- sqlspec/extensions/starlette/extension.py | 7 +- sqlspec/extensions/starlette/middleware.py | 27 ++++---- .../extensions/fastapi/test_integration.py | 69 ++++++++++++++++--- .../extensions/starlette/test_integration.py | 43 ++++++++++++ 4 files changed, 119 insertions(+), 27 deletions(-) diff --git a/sqlspec/extensions/starlette/extension.py b/sqlspec/extensions/starlette/extension.py index 1cca40dc..18891bf0 100644 --- a/sqlspec/extensions/starlette/extension.py +++ b/sqlspec/extensions/starlette/extension.py @@ -12,6 +12,7 @@ SQLSpecManualMiddleware, ) from sqlspec.utils.logging import get_logger, log_with_context +from sqlspec.utils.sync_tools import ensure_async_ if TYPE_CHECKING: from collections.abc import AsyncGenerator @@ -297,7 +298,7 @@ async def lifespan(self, app: "Starlette") -> "AsyncGenerator[None, None]": """ for config_state in self._config_states: if config_state.config.supports_connection_pooling: - pool = await config_state.config.create_pool() + pool = await ensure_async_(config_state.config.create_pool)() setattr(app.state, config_state.pool_key, pool) log_with_context( logger, @@ -313,9 +314,7 @@ async def lifespan(self, app: "Starlette") -> "AsyncGenerator[None, None]": finally: for config_state in self._config_states: if config_state.config.supports_connection_pooling: - close_result = config_state.config.close_pool() - if close_result is not None: - await close_result + await ensure_async_(config_state.config.close_pool)() log_with_context( logger, logging.DEBUG, diff --git a/sqlspec/extensions/starlette/middleware.py b/sqlspec/extensions/starlette/middleware.py index 326d5b66..6a207128 100644 --- a/sqlspec/extensions/starlette/middleware.py +++ b/sqlspec/extensions/starlette/middleware.py @@ -6,6 +6,7 @@ from sqlspec.core.sqlcommenter import SQLCommenterContext from sqlspec.extensions.starlette._utils import get_state_value, pop_state_value, set_state_value from sqlspec.utils.correlation import CorrelationContext +from sqlspec.utils.sync_tools import ensure_async_, with_ensure_async_ if TYPE_CHECKING: from starlette.requests import Request @@ -52,19 +53,20 @@ async def dispatch(self, request: "Request", call_next: Any) -> Any: if config.supports_connection_pooling: pool = get_state_value(request.app.state, self.config_state.pool_key) - async with config.provide_connection(pool) as connection: # type: ignore[union-attr] + async with with_ensure_async_(config.provide_connection(pool)) as connection: # type: ignore[union-attr] set_state_value(request.state, connection_key, connection) try: return await call_next(request) finally: pop_state_value(request.state, connection_key) else: - connection = await config.create_connection() + connection = await ensure_async_(config.create_connection)() set_state_value(request.state, connection_key, connection) try: return await call_next(request) finally: - await connection.close() + pop_state_value(request.state, connection_key) + await ensure_async_(connection.close)() class SQLSpecAutocommitMiddleware(BaseHTTPMiddleware): @@ -100,39 +102,40 @@ async def dispatch(self, request: "Request", call_next: Any) -> Any: if config.supports_connection_pooling: pool = get_state_value(request.app.state, self.config_state.pool_key) - async with config.provide_connection(pool) as connection: # type: ignore[union-attr] + async with with_ensure_async_(config.provide_connection(pool)) as connection: # type: ignore[union-attr] set_state_value(request.state, connection_key, connection) try: response = await call_next(request) if self._should_commit(response.status_code): - await connection.commit() + await ensure_async_(connection.commit)() else: - await connection.rollback() + await ensure_async_(connection.rollback)() except Exception: - await connection.rollback() + await ensure_async_(connection.rollback)() raise else: return response finally: pop_state_value(request.state, connection_key) else: - connection = await config.create_connection() + connection = await ensure_async_(config.create_connection)() set_state_value(request.state, connection_key, connection) try: response = await call_next(request) if self._should_commit(response.status_code): - await connection.commit() + await ensure_async_(connection.commit)() else: - await connection.rollback() + await ensure_async_(connection.rollback)() except Exception: - await connection.rollback() + await ensure_async_(connection.rollback)() raise else: return response finally: - await connection.close() + pop_state_value(request.state, connection_key) + await ensure_async_(connection.close)() def _should_commit(self, status_code: int) -> bool: """Determine if response status code should trigger commit. diff --git a/tests/integration/extensions/fastapi/test_integration.py b/tests/integration/extensions/fastapi/test_integration.py index 5c1ab94e..1367c786 100644 --- a/tests/integration/extensions/fastapi/test_integration.py +++ b/tests/integration/extensions/fastapi/test_integration.py @@ -4,10 +4,11 @@ from typing import Annotated, Any import pytest -from fastapi import Depends, FastAPI +from fastapi import Depends, FastAPI, Response from fastapi.testclient import TestClient from sqlspec.adapters.aiosqlite import AiosqliteConfig, AiosqliteConnection, AiosqliteDriver +from sqlspec.adapters.sqlite import SqliteConfig, SqliteDriver from sqlspec.base import SQLSpec from sqlspec.extensions.fastapi import SQLSpecPlugin @@ -20,7 +21,7 @@ def test_fastapi_dependency_injection() -> None: sql = SQLSpec() config = AiosqliteConfig( connection_config={"database": tmp.name}, - extension_config={"starlette": {"commit_mode": "manual", "session_key": "db"}}, + extension_config={"fastapi": {"commit_mode": "manual", "session_key": "db"}}, ) sql.add_config(config) @@ -46,7 +47,7 @@ def test_fastapi_connection_dependency() -> None: sql = SQLSpec() config = AiosqliteConfig( connection_config={"database": tmp.name}, - extension_config={"starlette": {"commit_mode": "manual", "session_key": "db"}}, + extension_config={"fastapi": {"commit_mode": "manual", "session_key": "db"}}, ) sql.add_config(config) @@ -75,7 +76,7 @@ def test_fastapi_manual_commit() -> None: sql = SQLSpec() config = AiosqliteConfig( connection_config={"database": tmp.name}, - extension_config={"starlette": {"commit_mode": "manual", "session_key": "db"}}, + extension_config={"fastapi": {"commit_mode": "manual", "session_key": "db"}}, ) sql.add_config(config) @@ -114,7 +115,7 @@ def test_fastapi_autocommit_mode() -> None: sql = SQLSpec() config = AiosqliteConfig( connection_config={"database": tmp.name}, - extension_config={"starlette": {"commit_mode": "autocommit", "session_key": "db"}}, + extension_config={"fastapi": {"commit_mode": "autocommit", "session_key": "db"}}, ) sql.add_config(config) @@ -144,13 +145,59 @@ async def get_data(db: Annotated[AiosqliteDriver, Depends(db_ext.provide_session assert response.json() == {"count": 1} +def test_fastapi_sync_sqlite_autocommit_commit_and_rollback() -> None: + """Test sync SQLite works through FastAPI request middleware.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=True) as tmp: + sql = SQLSpec() + config = SqliteConfig( + connection_config={"database": tmp.name}, + extension_config={"fastapi": {"commit_mode": "autocommit", "session_key": "db"}}, + ) + sql.add_config(config) + + app = FastAPI() + db_ext = SQLSpecPlugin(sql, app=app) + + @app.post("/setup") + async def setup(db: Annotated[SqliteDriver, Depends(db_ext.provide_session(config))]) -> dict[str, bool]: + db.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)") + db.execute("INSERT INTO test (name) VALUES (?)", ("committed",)) + return {"created": True} + + @app.post("/insert-error") + async def insert_error( + db: Annotated[SqliteDriver, Depends(db_ext.provide_session(config))], response: Response + ) -> dict[str, str]: + db.execute("INSERT INTO test (name) VALUES (?)", ("rolled-back",)) + response.status_code = 500 + return {"error": "failed"} + + @app.get("/data") + async def get_data( + db: Annotated[SqliteDriver, Depends(db_ext.provide_session(config))], + ) -> dict[str, list[str]]: + rows = db.execute("SELECT * FROM test ORDER BY id").all() + return {"names": [row["name"] for row in rows]} + + with TestClient(app) as client: + response = client.post("/setup") + assert response.status_code == 200 + + response = client.post("/insert-error") + assert response.status_code == 500 + + response = client.get("/data") + assert response.status_code == 200 + assert response.json() == {"names": ["committed"]} + + def test_fastapi_session_caching_across_dependencies() -> None: """Test session is cached across multiple dependencies in same request.""" with tempfile.NamedTemporaryFile(suffix=".db", delete=True) as tmp: sql = SQLSpec() config = AiosqliteConfig( connection_config={"database": tmp.name}, - extension_config={"starlette": {"commit_mode": "manual", "session_key": "db"}}, + extension_config={"fastapi": {"commit_mode": "manual", "session_key": "db"}}, ) sql.add_config(config) @@ -176,7 +223,7 @@ def test_fastapi_complex_route_with_multiple_queries() -> None: sql = SQLSpec() config = AiosqliteConfig( connection_config={"database": tmp.name}, - extension_config={"starlette": {"commit_mode": "manual", "session_key": "db"}}, + extension_config={"fastapi": {"commit_mode": "manual", "session_key": "db"}}, ) sql.add_config(config) @@ -232,13 +279,13 @@ async def get_stats(db: Annotated[AiosqliteDriver, Depends(db_ext.provide_sessio assert len(data["user_posts"]) == 2 -def test_fastapi_inherits_starlette_behavior() -> None: - """Test FastAPI extension behaves identically to Starlette for basic operations.""" +def test_fastapi_basic_operations() -> None: + """Test FastAPI extension handles basic operations.""" with tempfile.NamedTemporaryFile(suffix=".db", delete=True) as tmp: sql = SQLSpec() config = AiosqliteConfig( connection_config={"database": tmp.name}, - extension_config={"starlette": {"commit_mode": "manual", "session_key": "db"}}, + extension_config={"fastapi": {"commit_mode": "manual", "session_key": "db"}}, ) sql.add_config(config) @@ -265,7 +312,7 @@ def test_fastapi_default_session_key() -> None: with tempfile.NamedTemporaryFile(suffix=".db", delete=True) as tmp: sql = SQLSpec() - config = AiosqliteConfig(connection_config={"database": tmp.name}, extension_config={"starlette": {}}) + config = AiosqliteConfig(connection_config={"database": tmp.name}, extension_config={"fastapi": {}}) sql.add_config(config) app = FastAPI() diff --git a/tests/integration/extensions/starlette/test_integration.py b/tests/integration/extensions/starlette/test_integration.py index 03c5a0e4..655dd49e 100644 --- a/tests/integration/extensions/starlette/test_integration.py +++ b/tests/integration/extensions/starlette/test_integration.py @@ -10,6 +10,7 @@ from starlette.testclient import TestClient from sqlspec.adapters.aiosqlite import AiosqliteConfig +from sqlspec.adapters.sqlite import SqliteConfig from sqlspec.base import SQLSpec from sqlspec.extensions.starlette import SQLSpecPlugin @@ -152,6 +153,48 @@ async def get_data(request: Request) -> Response: assert response.json() == {"count": 0} +def test_starlette_sync_sqlite_autocommit_commit_and_rollback() -> None: + """Test sync SQLite works through Starlette request middleware.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=True) as tmp: + sql = SQLSpec() + config = SqliteConfig( + connection_config={"database": tmp.name}, + extension_config={"starlette": {"commit_mode": "autocommit", "session_key": "db"}}, + ) + sql.add_config(config) + db_ext = SQLSpecPlugin(sql) + + async def setup(request: Request) -> Response: + session = db_ext.get_session(request) + session.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)") + session.execute("INSERT INTO test (name) VALUES (?)", ("committed",)) + return JSONResponse({"created": True}) + + async def insert_error(request: Request) -> Response: + session = db_ext.get_session(request) + session.execute("INSERT INTO test (name) VALUES (?)", ("rolled-back",)) + return JSONResponse({"error": "failed"}, status_code=500) + + async def get_data(request: Request) -> Response: + session = db_ext.get_session(request) + rows = session.execute("SELECT * FROM test ORDER BY id").all() + return JSONResponse({"names": [row["name"] for row in rows]}) + + app = Starlette(routes=[Route("/setup", setup), Route("/insert-error", insert_error), Route("/data", get_data)]) + db_ext.init_app(app) + + with TestClient(app) as client: + response = client.get("/setup") + assert response.status_code == 200 + + response = client.get("/insert-error") + assert response.status_code == 500 + + response = client.get("/data") + assert response.status_code == 200 + assert response.json() == {"names": ["committed"]} + + def test_starlette_session_caching() -> None: """Test session caching within single request.""" with tempfile.NamedTemporaryFile(suffix=".db", delete=True) as tmp: From 2ec94ae1b2b4b3dfd35d7c7d3aa67cdafbfa3b1e Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 26 Apr 2026 01:55:47 +0000 Subject: [PATCH 04/14] fix(frameworks): validate autocommit status conflicts --- sqlspec/extensions/flask/_state.py | 10 +++++++++ sqlspec/extensions/starlette/_state.py | 10 +++++++++ .../unit/extensions/test_flask/test_state.py | 20 +++++++++++++++++ .../test_starlette/test_config_state.py | 22 +++++++++++++++++++ 4 files changed, 62 insertions(+) diff --git a/sqlspec/extensions/flask/_state.py b/sqlspec/extensions/flask/_state.py index 6fc36002..a024669b 100644 --- a/sqlspec/extensions/flask/_state.py +++ b/sqlspec/extensions/flask/_state.py @@ -3,6 +3,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal +from sqlspec.exceptions import ImproperConfigurationError + if TYPE_CHECKING: from sqlspec.config import DatabaseConfigProtocol @@ -34,6 +36,14 @@ class FlaskConfigState: auto_trace_headers: bool = True enable_sqlcommenter_middleware: bool = True + def __post_init__(self) -> None: + """Validate status configuration.""" + extra_commit_statuses = self.extra_commit_statuses or set() + extra_rollback_statuses = self.extra_rollback_statuses or set() + if extra_commit_statuses & extra_rollback_statuses: + msg = "Extra rollback statuses and commit statuses must not share any status codes" + raise ImproperConfigurationError(msg) + def should_commit(self, status_code: int) -> bool: """Determine if HTTP status code should trigger commit. diff --git a/sqlspec/extensions/starlette/_state.py b/sqlspec/extensions/starlette/_state.py index 721c347b..63256b5a 100644 --- a/sqlspec/extensions/starlette/_state.py +++ b/sqlspec/extensions/starlette/_state.py @@ -1,6 +1,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal +from sqlspec.exceptions import ImproperConfigurationError + if TYPE_CHECKING: from sqlspec.config import DatabaseConfigProtocol @@ -30,3 +32,11 @@ class SQLSpecConfigState: auto_trace_headers: bool = True enable_sqlcommenter_middleware: bool = True sqlcommenter_framework: str = "starlette" + + def __post_init__(self) -> None: + """Validate status configuration.""" + extra_commit_statuses = self.extra_commit_statuses or set() + extra_rollback_statuses = self.extra_rollback_statuses or set() + if extra_commit_statuses & extra_rollback_statuses: + msg = "Extra rollback statuses and commit statuses must not share any status codes" + raise ImproperConfigurationError(msg) diff --git a/tests/unit/extensions/test_flask/test_state.py b/tests/unit/extensions/test_flask/test_state.py index 34d38ee0..c414f031 100644 --- a/tests/unit/extensions/test_flask/test_state.py +++ b/tests/unit/extensions/test_flask/test_state.py @@ -1,5 +1,8 @@ """Tests for Flask configuration state.""" +import pytest + +from sqlspec.exceptions import ImproperConfigurationError from sqlspec.extensions.flask import FlaskConfigState @@ -109,6 +112,23 @@ def test_should_commit_extra_rollback_statuses() -> None: assert not state.should_commit(201) +def test_config_state_rejects_conflicting_extra_statuses() -> None: + """Test extra commit and rollback statuses cannot overlap.""" + with pytest.raises(ImproperConfigurationError) as exc_info: + FlaskConfigState( + config=None, # type: ignore[arg-type] + connection_key="conn", + session_key="db", + commit_mode="autocommit", + extra_commit_statuses={418}, + extra_rollback_statuses={418}, + is_async=False, + disable_di=False, + ) + + assert "must not share" in str(exc_info.value) + + def test_should_rollback_manual_mode() -> None: """Test should_rollback in manual mode never rolls back.""" state = FlaskConfigState( diff --git a/tests/unit/extensions/test_starlette/test_config_state.py b/tests/unit/extensions/test_starlette/test_config_state.py index 9ca77b18..3520ef50 100644 --- a/tests/unit/extensions/test_starlette/test_config_state.py +++ b/tests/unit/extensions/test_starlette/test_config_state.py @@ -2,6 +2,9 @@ from unittest.mock import MagicMock +import pytest + +from sqlspec.exceptions import ImproperConfigurationError from sqlspec.extensions.starlette import SQLSpecConfigState @@ -50,6 +53,25 @@ def test_config_state_with_extra_statuses() -> None: assert state.extra_rollback_statuses == extra_rollback +def test_config_state_rejects_conflicting_extra_statuses() -> None: + """Test extra commit and rollback statuses cannot overlap.""" + mock_config = MagicMock() + + with pytest.raises(ImproperConfigurationError) as exc_info: + SQLSpecConfigState( + config=mock_config, + connection_key="conn", + pool_key="pool", + session_key="session", + commit_mode="autocommit", + extra_commit_statuses={418}, + extra_rollback_statuses={418}, + disable_di=False, + ) + + assert "must not share" in str(exc_info.value) + + def test_config_state_commit_modes() -> None: """Test _ConfigState with different commit modes.""" mock_config = MagicMock() From e15b5c1e18c2cd1b6922d2cf420a8dbeaf9941c6 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 26 Apr 2026 02:19:08 +0000 Subject: [PATCH 05/14] fix(filters): validate framework sort fields --- docs/examples/patterns/filter_dependencies.py | 2 +- docs/recipes/service_layer.rst | 2 +- docs/usage/filtering.rst | 4 +- sqlspec/extensions/fastapi/providers.py | 19 ++++++-- sqlspec/extensions/litestar/providers.py | 25 +++++++++-- .../fastapi/test_filters_integration.py | 43 ++++++++++--------- .../test_litestar/test_providers.py | 28 +++++++++++- 7 files changed, 91 insertions(+), 32 deletions(-) diff --git a/docs/examples/patterns/filter_dependencies.py b/docs/examples/patterns/filter_dependencies.py index 9f61d05c..41a9128d 100644 --- a/docs/examples/patterns/filter_dependencies.py +++ b/docs/examples/patterns/filter_dependencies.py @@ -11,7 +11,7 @@ def show_filter_dependencies() -> None: user_filters: FilterConfig = { "id_filter": str, # Filter by user IDs "id_field": "id", # Column name for ID filter - "sort_field": "created_at", # Default sort column + "sort_field": ["created_at", "name"], # Allowed sort columns "sort_order": "desc", # Default sort direction "pagination_type": "limit_offset", # Enable pagination "pagination_size": 20, # Default page size diff --git a/docs/recipes/service_layer.rst b/docs/recipes/service_layer.rst index 3bdac5d6..e1b8dc68 100644 --- a/docs/recipes/service_layer.rst +++ b/docs/recipes/service_layer.rst @@ -371,7 +371,7 @@ forwarded through the service to the driver: dependencies = create_filter_dependencies({ "pagination_type": "limit_offset", "pagination_size": 20, - "sort_field": "created_at", + "sort_field": ["created_at", "name"], "sort_order": "desc", "search": "name,email", }) diff --git a/docs/usage/filtering.rst b/docs/usage/filtering.rst index 79c8dc32..cf1f25ed 100644 --- a/docs/usage/filtering.rst +++ b/docs/usage/filtering.rst @@ -59,7 +59,7 @@ Using filters in a Litestar handler: user_filter_deps = create_filter_dependencies({ "pagination_type": "limit_offset", "pagination_size": 20, - "sort_field": "created_at", + "sort_field": ["created_at", "name"], "sort_order": "desc", "search": "name,email", }) @@ -73,7 +73,7 @@ Using filters in a Litestar handler: data, total = await db_session.select_with_total(query, *filters) return {"data": data, "total": total} -The generated dependencies automatically handle query parameters like +The generated dependencies automatically handle query parameters for configured fields like ``?currentPage=2&pageSize=10&searchString=alice&orderBy=name&sortOrder=asc``. Related Guides diff --git a/sqlspec/extensions/fastapi/providers.py b/sqlspec/extensions/fastapi/providers.py index d0ac1131..95e68469 100644 --- a/sqlspec/extensions/fastapi/providers.py +++ b/sqlspec/extensions/fastapi/providers.py @@ -40,6 +40,7 @@ "HashableType", "HashableValue", "IntOrNone", + "SortField", "SortOrder", "SortOrderOrNone", "StringOrNone", @@ -55,6 +56,7 @@ BooleanOrNone = bool | None SortOrder = Literal["asc", "desc"] SortOrderOrNone = SortOrder | None +SortField = str | set[str] | list[str] HashableValue = str | int | float | bool | None HashableType = HashableValue | tuple[Any, ...] | tuple[tuple[str, Any], ...] | tuple[HashableValue, ...] @@ -90,8 +92,8 @@ class FilterConfig(TypedDict): """Type of ID filter to enable (UUID, int, or str). When set, enables collection filtering by IDs.""" id_field: NotRequired[str] """Field name for ID filtering. Defaults to 'id'.""" - sort_field: NotRequired[str | set[str]] - """Default field(s) to use for sorting.""" + sort_field: NotRequired[SortField] + """Allowed field(s) to use for sorting.""" sort_order: NotRequired[SortOrder] """Default sort order ('asc' or 'desc'). Defaults to 'desc'.""" pagination_type: NotRequired[Literal["limit_offset"]] @@ -150,6 +152,13 @@ def _empty_filter_list() -> "list[FilterTypes]": return [] +def _resolve_sort_fields(sort_field: SortField) -> tuple[str, set[str]]: + if isinstance(sort_field, str): + return sort_field, {sort_field} + fields = tuple(sorted(sort_field)) if isinstance(sort_field, set) else tuple(sort_field) + return fields[0], set(fields) + + def provide_filters( config: FilterConfig, dep_defaults: DependencyDefaults = DEPENDENCY_DEFAULTS ) -> "Callable[..., list[FilterTypes]]": @@ -443,7 +452,8 @@ def provide_search_filter( if sort_field := config.get("sort_field"): sort_order_default = config.get("sort_order", "desc") - default_field = sort_field if isinstance(sort_field, str) else next(iter(sort_field)) + default_field, allowed_fields = _resolve_sort_fields(sort_field) + allowed_field_names = ", ".join(sorted(allowed_fields)) def provide_order_by( field_name: Annotated[str, Query(alias="orderBy", description="Field to order by.")] = default_field, @@ -451,6 +461,9 @@ def provide_order_by( SortOrder | None, Query(alias="sortOrder", description="Sort order ('asc' or 'desc').") ] = sort_order_default, ) -> OrderByFilter: + if field_name not in allowed_fields: + msg = f"Invalid orderBy field '{field_name}'. Allowed fields: {allowed_field_names}" + raise RequestValidationError(errors=[{"loc": ("query", "orderBy"), "msg": msg, "type": "value_error"}]) return OrderByFilter(field_name=field_name, sort_order=sort_order or sort_order_default) param_name = dep_defaults.ORDER_BY_FILTER_DEPENDENCY_KEY diff --git a/sqlspec/extensions/litestar/providers.py b/sqlspec/extensions/litestar/providers.py index 70dc5185..a7bd590b 100644 --- a/sqlspec/extensions/litestar/providers.py +++ b/sqlspec/extensions/litestar/providers.py @@ -11,6 +11,7 @@ from uuid import UUID from litestar.di import Provide +from litestar.exceptions import ValidationException from litestar.params import Dependency, Parameter from typing_extensions import NotRequired @@ -38,6 +39,7 @@ "HashableType", "HashableValue", "IntOrNone", + "SortField", "SortOrder", "SortOrderOrNone", "StringOrNone", @@ -53,6 +55,7 @@ BooleanOrNone = bool | None SortOrder = Literal["asc", "desc"] SortOrderOrNone = SortOrder | None +SortField = str | set[str] | list[str] HashableValue = str | int | float | bool | None HashableType = HashableValue | tuple[Any, ...] | tuple[tuple[str, Any], ...] | tuple[HashableValue, ...] @@ -83,7 +86,7 @@ class FilterConfig(TypedDict): id_filter: NotRequired[type[UUID | int | str]] id_field: NotRequired[str] - sort_field: NotRequired[str] + sort_field: NotRequired[SortField] sort_order: NotRequired[SortOrder] pagination_type: NotRequired[Literal["limit_offset"]] pagination_size: NotRequired[int] @@ -158,6 +161,13 @@ def _make_hashable(value: Any) -> HashableType: return str(value) +def _resolve_sort_fields(sort_field: SortField) -> tuple[str, set[str]]: + if isinstance(sort_field, str): + return sort_field, {sort_field} + fields = tuple(sorted(sort_field)) if isinstance(sort_field, set) else tuple(sort_field) + return fields[0], set(fields) + + def _create_statement_filters( # noqa: C901 config: FilterConfig, dep_defaults: DependencyDefaults = DEPENDENCY_DEFAULTS ) -> dict[str, Provide]: @@ -237,16 +247,23 @@ def provide_search_filter( filters[dep_defaults.SEARCH_FILTER_DEPENDENCY_KEY] = Provide(provide_search_filter, sync_to_thread=False) if sort_field := config.get("sort_field"): + default_field, allowed_fields = _resolve_sort_fields(sort_field) + allowed_field_names = ", ".join(sorted(allowed_fields)) + sort_order_default = config.get("sort_order", "desc") def provide_order_by( field_name: StringOrNone = Parameter( - title="Order by field", query="orderBy", default=sort_field, required=False + title="Order by field", query="orderBy", default=default_field, required=False ), sort_order: SortOrderOrNone = Parameter( - title="Field to search", query="sortOrder", default=config.get("sort_order", "desc"), required=False + title="Field to search", query="sortOrder", default=sort_order_default, required=False ), ) -> OrderByFilter: - return OrderByFilter(field_name=field_name, sort_order=sort_order) # type: ignore[arg-type] + resolved_field = field_name or default_field + if resolved_field not in allowed_fields: + msg = f"Invalid orderBy field '{resolved_field}'. Allowed fields: {allowed_field_names}" + raise ValidationException(detail=msg) + return OrderByFilter(field_name=resolved_field, sort_order=sort_order or sort_order_default) filters[dep_defaults.ORDER_BY_FILTER_DEPENDENCY_KEY] = Provide(provide_order_by, sync_to_thread=False) diff --git a/tests/integration/extensions/fastapi/test_filters_integration.py b/tests/integration/extensions/fastapi/test_filters_integration.py index 19bdcc67..0c6e8716 100644 --- a/tests/integration/extensions/fastapi/test_filters_integration.py +++ b/tests/integration/extensions/fastapi/test_filters_integration.py @@ -30,7 +30,7 @@ def test_fastapi_id_filter_dependency() -> None: sqlspec = SQLSpec() config = AiosqliteConfig( connection_config={"database": ":memory:"}, - extension_config={"starlette": {"commit_mode": "manual", "session_key": "db"}}, + extension_config={"fastapi": {"commit_mode": "manual", "session_key": "db"}}, ) sqlspec.add_config(config) @@ -63,7 +63,7 @@ def test_fastapi_search_filter_dependency() -> None: """Test search filter dependency with actual HTTP request.""" sqlspec = SQLSpec() config = AiosqliteConfig( - connection_config={"database": ":memory:"}, extension_config={"starlette": {"commit_mode": "manual"}} + connection_config={"database": ":memory:"}, extension_config={"fastapi": {"commit_mode": "manual"}} ) sqlspec.add_config(config) @@ -102,7 +102,7 @@ def test_fastapi_pagination_filter_dependency() -> None: """Test pagination filter dependency with actual HTTP request.""" sqlspec = SQLSpec() config = AiosqliteConfig( - connection_config={"database": ":memory:"}, extension_config={"starlette": {"commit_mode": "manual"}} + connection_config={"database": ":memory:"}, extension_config={"fastapi": {"commit_mode": "manual"}} ) sqlspec.add_config(config) @@ -144,7 +144,7 @@ def test_fastapi_order_by_filter_dependency() -> None: """Test order by filter dependency with actual HTTP request.""" sqlspec = SQLSpec() config = AiosqliteConfig( - connection_config={"database": ":memory:"}, extension_config={"starlette": {"commit_mode": "manual"}} + connection_config={"database": ":memory:"}, extension_config={"fastapi": {"commit_mode": "manual"}} ) sqlspec.add_config(config) @@ -153,7 +153,7 @@ def test_fastapi_order_by_filter_dependency() -> None: @app.get("/users") async def list_users( - filters: Annotated[list[FilterTypes], Depends(db_ext.provide_filters({"sort_field": "created_at"}))], + filters: Annotated[list[FilterTypes], Depends(db_ext.provide_filters({"sort_field": {"created_at", "name"}}))], ) -> dict[str, Any]: order_by = next((f for f in filters if isinstance(f, OrderByFilter)), None) if order_by: @@ -166,7 +166,7 @@ async def list_users( assert response.status_code == 200 assert response.json() == {"field": "created_at", "order": "desc"} - # Custom field + # Allowed custom field response = client.get("/users?orderBy=name") assert response.status_code == 200 assert response.json() == {"field": "name", "order": "desc"} @@ -176,17 +176,16 @@ async def list_users( assert response.status_code == 200 assert response.json() == {"field": "created_at", "order": "asc"} - # Both custom - response = client.get("/users?orderBy=email&sortOrder=asc") - assert response.status_code == 200 - assert response.json() == {"field": "email", "order": "asc"} + # Disallowed custom field + response = client.get("/users?orderBy=password_hash&sortOrder=asc") + assert response.status_code == 422 def test_fastapi_date_range_filter_dependency() -> None: """Test date range filter dependency with actual HTTP request.""" sqlspec = SQLSpec() config = AiosqliteConfig( - connection_config={"database": ":memory:"}, extension_config={"starlette": {"commit_mode": "manual"}} + connection_config={"database": ":memory:"}, extension_config={"fastapi": {"commit_mode": "manual"}} ) sqlspec.add_config(config) @@ -239,7 +238,7 @@ def test_fastapi_multiple_filters_combined() -> None: """Test combining multiple filter types in one request.""" sqlspec = SQLSpec() config = AiosqliteConfig( - connection_config={"database": ":memory:"}, extension_config={"starlette": {"commit_mode": "manual"}} + connection_config={"database": ":memory:"}, extension_config={"fastapi": {"commit_mode": "manual"}} ) sqlspec.add_config(config) @@ -255,7 +254,7 @@ async def list_users( "id_filter": UUID, "search": "name", "pagination_type": "limit_offset", - "sort_field": "created_at", + "sort_field": {"created_at", "name"}, }) ), ], @@ -285,7 +284,7 @@ def test_fastapi_filter_with_actual_query_execution() -> None: """Test filters applied to actual SQL query execution.""" sqlspec = SQLSpec() config = AiosqliteConfig( - connection_config={"database": ":memory:"}, extension_config={"starlette": {"commit_mode": "autocommit"}} + connection_config={"database": ":memory:"}, extension_config={"fastapi": {"commit_mode": "autocommit"}} ) sqlspec.add_config(config) @@ -305,7 +304,11 @@ async def list_users( filters: Annotated[ list[FilterTypes], Depends( - db_ext.provide_filters({"pagination_type": "limit_offset", "sort_field": "name", "sort_order": "desc"}) + db_ext.provide_filters({ + "pagination_type": "limit_offset", + "sort_field": ["name", "age"], + "sort_order": "desc", + }) ), ], db: Annotated[AiosqliteDriver, Depends(db_ext.provide_session(config))], @@ -338,7 +341,7 @@ def test_fastapi_openapi_schema_includes_filter_params() -> None: """Test that OpenAPI schema includes filter query parameters.""" sqlspec = SQLSpec() config = AiosqliteConfig( - connection_config={"database": ":memory:"}, extension_config={"starlette": {"commit_mode": "manual"}} + connection_config={"database": ":memory:"}, extension_config={"fastapi": {"commit_mode": "manual"}} ) sqlspec.add_config(config) @@ -372,7 +375,7 @@ def test_fastapi_filter_validation_error() -> None: """Test that invalid filter values return proper validation errors.""" sqlspec = SQLSpec() config = AiosqliteConfig( - connection_config={"database": ":memory:"}, extension_config={"starlette": {"commit_mode": "manual"}} + connection_config={"database": ":memory:"}, extension_config={"fastapi": {"commit_mode": "manual"}} ) sqlspec.add_config(config) @@ -399,7 +402,7 @@ def test_fastapi_in_fields_filter_dependency() -> None: """Test in_fields filter dependency with actual HTTP request (issue #405).""" sqlspec = SQLSpec() config = AiosqliteConfig( - connection_config={"database": ":memory:"}, extension_config={"starlette": {"commit_mode": "manual"}} + connection_config={"database": ":memory:"}, extension_config={"fastapi": {"commit_mode": "manual"}} ) sqlspec.add_config(config) @@ -443,7 +446,7 @@ def test_fastapi_not_in_fields_filter_dependency() -> None: """Test not_in_fields filter dependency with actual HTTP request (issue #405).""" sqlspec = SQLSpec() config = AiosqliteConfig( - connection_config={"database": ":memory:"}, extension_config={"starlette": {"commit_mode": "manual"}} + connection_config={"database": ":memory:"}, extension_config={"fastapi": {"commit_mode": "manual"}} ) sqlspec.add_config(config) @@ -487,7 +490,7 @@ def test_fastapi_in_fields_with_query_execution() -> None: """Test in_fields filter applied to actual SQL query execution (issue #405).""" sqlspec = SQLSpec() config = AiosqliteConfig( - connection_config={"database": ":memory:"}, extension_config={"starlette": {"commit_mode": "autocommit"}} + connection_config={"database": ":memory:"}, extension_config={"fastapi": {"commit_mode": "autocommit"}} ) sqlspec.add_config(config) diff --git a/tests/unit/extensions/test_litestar/test_providers.py b/tests/unit/extensions/test_litestar/test_providers.py index f24f5be4..bc48e600 100644 --- a/tests/unit/extensions/test_litestar/test_providers.py +++ b/tests/unit/extensions/test_litestar/test_providers.py @@ -3,8 +3,9 @@ from typing import Any import pytest +from litestar.exceptions import ValidationException -from sqlspec.core import InCollectionFilter, NotInCollectionFilter +from sqlspec.core import InCollectionFilter, NotInCollectionFilter, OrderByFilter from sqlspec.extensions.litestar.providers import ( FieldNameType, FilterConfig, @@ -121,3 +122,28 @@ def test_in_fields_with_string_config() -> None: result = provider.dependency(values=["active"]) assert isinstance(result, InCollectionFilter) assert result.field_name == "status" + + +def test_order_by_provider_allows_configured_sort_field() -> None: + """orderBy provider allows fields configured in sort_field.""" + config = FilterConfig(sort_field={"created_at", "name"}) + deps = _create_statement_filters(config) + + provider = deps["order_by_filter"] + result = provider.dependency(field_name="name", sort_order="asc") + + assert isinstance(result, OrderByFilter) + assert result.field_name == "name" + assert result.sort_order == "asc" + + +def test_order_by_provider_rejects_unconfigured_sort_field() -> None: + """orderBy provider rejects fields outside the configured allowlist.""" + config = FilterConfig(sort_field={"created_at", "name"}) + deps = _create_statement_filters(config) + + provider = deps["order_by_filter"] + with pytest.raises(ValidationException) as exc_info: + provider.dependency(field_name="password_hash", sort_order="asc") + + assert "Invalid orderBy field" in str(exc_info.value) From 83006c96cc29fd3cf6a235a03cae7b73566fca9f Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 26 Apr 2026 02:32:41 +0000 Subject: [PATCH 06/14] feat(sanic): add extension skeleton --- pyproject.toml | 2 + sqlspec/config.py | 67 ++++++ sqlspec/extensions/sanic/__init__.py | 11 + sqlspec/extensions/sanic/_state.py | 43 ++++ sqlspec/extensions/sanic/_utils.py | 115 +++++++++ sqlspec/extensions/sanic/extension.py | 217 +++++++++++++++++ tests/unit/extensions/test_sanic/__init__.py | 1 + .../extensions/test_sanic/test_extension.py | 38 +++ tests/unit/test_package_metadata.py | 9 + uv.lock | 226 +++++++++++++++++- 10 files changed, 728 insertions(+), 1 deletion(-) create mode 100644 sqlspec/extensions/sanic/__init__.py create mode 100644 sqlspec/extensions/sanic/_state.py create mode 100644 sqlspec/extensions/sanic/_utils.py create mode 100644 sqlspec/extensions/sanic/extension.py create mode 100644 tests/unit/extensions/test_sanic/__init__.py create mode 100644 tests/unit/extensions/test_sanic/test_extension.py diff --git a/pyproject.toml b/pyproject.toml index f995f76b..8b32c315 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ psycopg = ["psycopg[binary,pool]"] pydantic = ["pydantic", "pydantic-extra-types"] pymssql = ["pymssql"] pymysql = ["pymysql"] +sanic = ["sanic"] spanner = ["google-cloud-spanner"] starlette = ["starlette"] uuid = ["uuid-utils"] @@ -146,6 +147,7 @@ test = [ "pytest-sugar>=1.0.0", "pytest-xdist>=3.6.1", "pytest-timeout>=2.3.1", + "sanic-testing", ] [project.scripts] diff --git a/sqlspec/config.py b/sqlspec/config.py index e3da12e3..aceb80e2 100644 --- a/sqlspec/config.py +++ b/sqlspec/config.py @@ -56,6 +56,7 @@ "NoPoolSyncConfig", "OpenTelemetryConfig", "PrometheusConfig", + "SanicConfig", "StarletteConfig", "SyncConfigT", "SyncDatabaseConfig", @@ -406,6 +407,71 @@ class FastAPIConfig(StarletteConfig): """ +class SanicConfig(TypedDict): + """Configuration options for Sanic SQLSpec extension. + + All fields are optional with sensible defaults. Use in ``extension_config["sanic"]``. + + Example: + from sqlspec.adapters.asyncpg import AsyncpgConfig + + config = AsyncpgConfig( + connection_config={"dsn": "postgresql://localhost/mydb"}, + extension_config={ + "sanic": { + "commit_mode": "autocommit", + "session_key": "db" + } + } + ) + + Notes: + This TypedDict provides type safety for extension config. + Sanic extension uses ``app.ctx`` for pools and ``request.ctx`` for + request-scoped connections and sessions. + """ + + connection_key: NotRequired[str] + """Key for storing connection in request.ctx. Default: 'db_connection'""" + + pool_key: NotRequired[str] + """Key for storing connection pool in app.ctx. Default: 'db_pool'""" + + session_key: NotRequired[str] + """Key for storing session in request.ctx. Default: 'db_session'""" + + commit_mode: NotRequired[Literal["manual", "autocommit", "autocommit_include_redirect"]] + """Transaction commit mode. Default: 'manual' + + - manual: No automatic commit/rollback + - autocommit: Commit on 2xx, rollback otherwise + - autocommit_include_redirect: Commit on 2xx-3xx, rollback otherwise + """ + + extra_commit_statuses: NotRequired[set[int]] + """Additional HTTP status codes that trigger commit. Default: set()""" + + extra_rollback_statuses: NotRequired[set[int]] + """Additional HTTP status codes that trigger rollback. Default: set()""" + + disable_di: NotRequired[bool] + """Disable built-in dependency injection. Default: False. + When True, the Sanic extension will not register request middleware for + managing database connections and sessions. Users are responsible for + managing the database lifecycle manually via their own DI solution. + """ + + enable_sqlcommenter_middleware: NotRequired[bool] + """Control automatic SQLCommenter middleware registration. Default: True. + When the driver's :class:`~sqlspec.core.statement.StatementConfig` has + ``enable_sqlcommenter=True``, the middleware is registered automatically. + Set to ``False`` to explicitly disable middleware registration. + """ + + sqlcommenter_framework: NotRequired[str] + """Framework name for SQLCommenter attributes. Default: 'sanic'.""" + + class ADKPartitionConfig(TypedDict): """Configuration for table partitioning and sharding strategies. @@ -983,6 +1049,7 @@ class PrometheusConfig(TypedDict): | LitestarConfig | FastAPIConfig | StarletteConfig + | SanicConfig | FlaskConfig | ADKConfig | EventsConfig diff --git a/sqlspec/extensions/sanic/__init__.py b/sqlspec/extensions/sanic/__init__.py new file mode 100644 index 00000000..d8f0e822 --- /dev/null +++ b/sqlspec/extensions/sanic/__init__.py @@ -0,0 +1,11 @@ +"""Sanic extension for SQLSpec. + +Provides Sanic-native app and request context helpers plus plugin wiring for +connection lifecycle and request-scoped sessions. +""" + +from sqlspec.extensions.sanic._state import SanicConfigState +from sqlspec.extensions.sanic._utils import get_connection_from_request, get_or_create_session +from sqlspec.extensions.sanic.extension import SQLSpecPlugin + +__all__ = ("SQLSpecPlugin", "SanicConfigState", "get_connection_from_request", "get_or_create_session") diff --git a/sqlspec/extensions/sanic/_state.py b/sqlspec/extensions/sanic/_state.py new file mode 100644 index 00000000..bc6a6d6a --- /dev/null +++ b/sqlspec/extensions/sanic/_state.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal + +from sqlspec.exceptions import ImproperConfigurationError + +if TYPE_CHECKING: + from sqlspec.config import DatabaseConfigProtocol + +__all__ = ("CommitMode", "SanicConfigState") + +CommitMode = Literal["manual", "autocommit", "autocommit_include_redirect"] + + +@dataclass +class SanicConfigState: + """Internal state for a Sanic database configuration. + + Tracks the keys and behavior needed to bind one SQLSpec config into a + Sanic app and its request context. + """ + + config: "DatabaseConfigProtocol[Any, Any, Any]" + connection_key: str + pool_key: str + session_key: str + commit_mode: CommitMode + extra_commit_statuses: "set[int] | None" + extra_rollback_statuses: "set[int] | None" + disable_di: bool + enable_correlation_middleware: bool = False + correlation_header: str = "x-request-id" + correlation_headers: "tuple[str, ...] | None" = None + auto_trace_headers: bool = True + enable_sqlcommenter_middleware: bool = True + sqlcommenter_framework: str = "sanic" + + def __post_init__(self) -> None: + """Validate status configuration.""" + extra_commit_statuses = self.extra_commit_statuses or set() + extra_rollback_statuses = self.extra_rollback_statuses or set() + if extra_commit_statuses & extra_rollback_statuses: + msg = "Extra rollback statuses and commit statuses must not share any status codes" + raise ImproperConfigurationError(msg) diff --git a/sqlspec/extensions/sanic/_utils.py b/sqlspec/extensions/sanic/_utils.py new file mode 100644 index 00000000..9a8c44af --- /dev/null +++ b/sqlspec/extensions/sanic/_utils.py @@ -0,0 +1,115 @@ +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from sqlspec.extensions.sanic._state import SanicConfigState + +__all__ = ( + "get_connection_from_request", + "get_context_value", + "get_or_create_session", + "has_context_value", + "pop_context_value", + "set_context_value", +) + +_MISSING = object() + + +def get_context_value(context: Any, key: str, default: Any = _MISSING) -> Any: + """Get a value from a Sanic ``ctx`` object. + + Args: + context: Sanic ``app.ctx`` or ``request.ctx`` object. + key: Attribute name to retrieve. + default: Optional value returned when the key is missing. + + Returns: + Stored context value. + """ + if default is _MISSING: + return getattr(context, key) + return getattr(context, key, default) + + +def set_context_value(context: Any, key: str, value: Any) -> None: + """Set a value on a Sanic ``ctx`` object. + + Args: + context: Sanic ``app.ctx`` or ``request.ctx`` object. + key: Attribute name to set. + value: Value to store. + """ + setattr(context, key, value) + + +def pop_context_value(context: Any, key: str) -> Any | None: + """Remove a value from a Sanic ``ctx`` object. + + Args: + context: Sanic ``app.ctx`` or ``request.ctx`` object. + key: Attribute name to remove. + + Returns: + Removed value if present, otherwise ``None``. + """ + if not hasattr(context, key): + return None + value = getattr(context, key) + delattr(context, key) + return value + + +def has_context_value(context: Any, key: str) -> bool: + """Check if a Sanic ``ctx`` object has a stored value. + + Args: + context: Sanic ``app.ctx`` or ``request.ctx`` object. + key: Attribute name to check. + + Returns: + ``True`` when the key is present. + """ + return hasattr(context, key) + + +def get_connection_from_request(request: Any, config_state: "SanicConfigState") -> Any: + """Get database connection from request context. + + Args: + request: Sanic request instance. + config_state: Configuration state for the database. + + Returns: + Database connection object. + """ + return get_context_value(request.ctx, config_state.connection_key) + + +def get_or_create_session(request: Any, config_state: "SanicConfigState") -> Any: + """Get or create database session for request. + + Sessions are cached per request to return the same session instance across + multiple calls in one request. + + Args: + request: Sanic request instance. + config_state: Configuration state for the database. + + Returns: + Database session driver instance. + """ + session_instance_key = f"{config_state.session_key}_instance" + + existing_session = get_context_value(request.ctx, session_instance_key, None) + if existing_session is not None: + return existing_session + + connection = get_connection_from_request(request, config_state) + session = config_state.config.driver_type( + connection=connection, + statement_config=config_state.config.statement_config, + driver_features=config_state.config.driver_features, + ) + + set_context_value(request.ctx, session_instance_key, session) + return session diff --git a/sqlspec/extensions/sanic/extension.py b/sqlspec/extensions/sanic/extension.py new file mode 100644 index 00000000..443bc36f --- /dev/null +++ b/sqlspec/extensions/sanic/extension.py @@ -0,0 +1,217 @@ +import logging +from typing import TYPE_CHECKING, Any + +from sqlspec.base import SQLSpec +from sqlspec.exceptions import ImproperConfigurationError +from sqlspec.extensions.sanic._state import SanicConfigState +from sqlspec.extensions.sanic._utils import get_context_value, get_or_create_session +from sqlspec.utils.logging import get_logger, log_with_context + +if TYPE_CHECKING: + from sanic import Sanic + +__all__ = ("SQLSpecPlugin",) + +logger = get_logger("sqlspec.extensions.sanic") + +DEFAULT_COMMIT_MODE = "manual" +DEFAULT_CONNECTION_KEY = "db_connection" +DEFAULT_POOL_KEY = "db_pool" +DEFAULT_SESSION_KEY = "db_session" + + +class SQLSpecPlugin: + """SQLSpec integration for Sanic applications. + + Provides Sanic-native configuration parsing and request helper methods. + Runtime listener and middleware behavior is registered by ``init_app``. + + Example: + from sanic import Sanic + from sqlspec import SQLSpec + from sqlspec.adapters.asyncpg import AsyncpgConfig + from sqlspec.extensions.sanic import SQLSpecPlugin + + sqlspec = SQLSpec() + sqlspec.add_config( + AsyncpgConfig( + connection_config={"dsn": "postgresql://localhost/mydb"}, + extension_config={ + "sanic": { + "commit_mode": "autocommit", + "session_key": "db", + } + }, + ) + ) + + app = Sanic("app") + db_ext = SQLSpecPlugin(sqlspec, app) + """ + + __slots__ = ("_config_states", "_sqlspec") + + def __init__(self, sqlspec: SQLSpec, app: "Sanic[Any, Any] | None" = None) -> None: + """Initialize SQLSpec Sanic extension. + + Args: + sqlspec: Pre-configured SQLSpec instance with registered configs. + app: Optional Sanic application to initialize immediately. + """ + self._sqlspec = sqlspec + self._config_states: list[SanicConfigState] = [] + + for cfg in self._sqlspec.configs.values(): + settings = self._extract_sanic_settings(cfg) + state = self._create_config_state(cfg, settings) + self._config_states.append(state) + + if app is not None: + self.init_app(app) + + log_with_context( + logger, + logging.DEBUG, + "extension.init", + framework="sanic", + stage="init", + config_count=len(self._config_states), + ) + + def _extract_sanic_settings(self, config: Any) -> "dict[str, Any]": + """Extract Sanic settings from config.extension_config. + + Args: + config: Database configuration instance. + + Returns: + Dictionary of Sanic-specific settings. + """ + sanic_config = config.extension_config.get("sanic", {}) + + connection_key = sanic_config.get("connection_key", DEFAULT_CONNECTION_KEY) + pool_key = sanic_config.get("pool_key", DEFAULT_POOL_KEY) + session_key = sanic_config.get("session_key", DEFAULT_SESSION_KEY) + commit_mode = sanic_config.get("commit_mode", DEFAULT_COMMIT_MODE) + + if not config.supports_connection_pooling and pool_key == DEFAULT_POOL_KEY: + pool_key = f"_{DEFAULT_POOL_KEY}_{id(config)}" + + correlation_headers = sanic_config.get("correlation_headers") + if correlation_headers is not None: + correlation_headers = tuple(correlation_headers) + + return { + "connection_key": connection_key, + "pool_key": pool_key, + "session_key": session_key, + "commit_mode": commit_mode, + "extra_commit_statuses": sanic_config.get("extra_commit_statuses"), + "extra_rollback_statuses": sanic_config.get("extra_rollback_statuses"), + "disable_di": sanic_config.get("disable_di", False), + "enable_correlation_middleware": sanic_config.get("enable_correlation_middleware", False), + "correlation_header": sanic_config.get("correlation_header", "x-request-id"), + "correlation_headers": correlation_headers, + "auto_trace_headers": sanic_config.get("auto_trace_headers", True), + "enable_sqlcommenter_middleware": sanic_config.get("enable_sqlcommenter_middleware", True), + "sqlcommenter_framework": sanic_config.get("sqlcommenter_framework", "sanic"), + } + + def _create_config_state(self, config: Any, settings: "dict[str, Any]") -> SanicConfigState: + """Create configuration state object. + + Args: + config: Database configuration instance. + settings: Extracted Sanic settings. + + Returns: + Configuration state instance. + """ + return SanicConfigState( + config=config, + connection_key=settings["connection_key"], + pool_key=settings["pool_key"], + session_key=settings["session_key"], + commit_mode=settings["commit_mode"], + extra_commit_statuses=settings["extra_commit_statuses"], + extra_rollback_statuses=settings["extra_rollback_statuses"], + disable_di=settings["disable_di"], + enable_correlation_middleware=settings["enable_correlation_middleware"], + correlation_header=settings["correlation_header"], + correlation_headers=settings["correlation_headers"], + auto_trace_headers=settings["auto_trace_headers"], + enable_sqlcommenter_middleware=settings["enable_sqlcommenter_middleware"], + sqlcommenter_framework=settings["sqlcommenter_framework"], + ) + + def init_app(self, app: "Sanic[Any, Any]") -> None: + """Initialize Sanic application with SQLSpec. + + Args: + app: Sanic application instance. + """ + self._validate_unique_keys() + setattr(app.ctx, "sqlspec_plugin", self) + + def _validate_unique_keys(self) -> None: + """Validate that all context keys are unique across configs. + + Raises: + ImproperConfigurationError: If duplicate keys are found. + """ + all_keys: set[str] = set() + + for state in self._config_states: + keys = {state.connection_key, state.pool_key, state.session_key} + duplicates = all_keys & keys + + if duplicates: + msg = f"Duplicate context keys found: {duplicates}" + raise ImproperConfigurationError(msg) + + all_keys.update(keys) + + def get_session(self, request: Any, key: "str | None" = None) -> Any: + """Get or create database session for request. + + Args: + request: Sanic request instance. + key: Optional session key to retrieve a specific database session. + + Returns: + Database session driver instance. + """ + config_state = self._config_states[0] if key is None else self._get_config_state_by_key(key) + return get_or_create_session(request, config_state) + + def get_connection(self, request: Any, key: "str | None" = None) -> Any: + """Get database connection from request context. + + Args: + request: Sanic request instance. + key: Optional session key to retrieve a specific database connection. + + Returns: + Database connection object. + """ + config_state = self._config_states[0] if key is None else self._get_config_state_by_key(key) + return get_context_value(request.ctx, config_state.connection_key) + + def _get_config_state_by_key(self, key: str) -> SanicConfigState: + """Get configuration state by session key. + + Args: + key: Session key to search for. + + Returns: + Configuration state matching the key. + + Raises: + ValueError: If no configuration is found with the specified key. + """ + for state in self._config_states: + if state.session_key == key: + return state + + msg = f"No configuration found with session_key: {key}" + raise ValueError(msg) diff --git a/tests/unit/extensions/test_sanic/__init__.py b/tests/unit/extensions/test_sanic/__init__.py new file mode 100644 index 00000000..d4a71abf --- /dev/null +++ b/tests/unit/extensions/test_sanic/__init__.py @@ -0,0 +1 @@ +"""Tests for Sanic SQLSpec extension.""" diff --git a/tests/unit/extensions/test_sanic/test_extension.py b/tests/unit/extensions/test_sanic/test_extension.py new file mode 100644 index 00000000..60c2c506 --- /dev/null +++ b/tests/unit/extensions/test_sanic/test_extension.py @@ -0,0 +1,38 @@ +"""Tests for Sanic SQLSpec plugin skeleton.""" + +from sqlspec import SQLSpec +from sqlspec.adapters.aiosqlite import AiosqliteConfig +from sqlspec.config import SanicConfig +from sqlspec.extensions.sanic import SanicConfigState, SQLSpecPlugin, get_connection_from_request, get_or_create_session + + +def test_sanic_config_typing_is_exported() -> None: + """SanicConfig should expose the Sanic extension configuration surface.""" + assert SanicConfig.__required_keys__ == frozenset() + assert "connection_key" in SanicConfig.__annotations__ + assert "pool_key" in SanicConfig.__annotations__ + assert "session_key" in SanicConfig.__annotations__ + assert "commit_mode" in SanicConfig.__annotations__ + + +def test_sanic_public_api_imports_without_sanic_dependency() -> None: + """The extension module should expose its public API without importing Sanic.""" + assert SQLSpecPlugin is not None + assert SanicConfigState is not None + assert callable(get_connection_from_request) + assert callable(get_or_create_session) + + +def test_sanic_plugin_reads_sanic_extension_config() -> None: + """The skeleton plugin should build config state from extension_config['sanic'].""" + sqlspec = SQLSpec() + config = AiosqliteConfig( + connection_config={"database": ":memory:"}, extension_config={"sanic": {"session_key": "sanic_db"}} + ) + sqlspec.add_config(config) + + plugin = SQLSpecPlugin(sqlspec) + + assert len(plugin._config_states) == 1 # pyright: ignore[reportPrivateUsage] + assert plugin._config_states[0].session_key == "sanic_db" # pyright: ignore[reportPrivateUsage] + assert plugin._config_states[0].sqlcommenter_framework == "sanic" # pyright: ignore[reportPrivateUsage] diff --git a/tests/unit/test_package_metadata.py b/tests/unit/test_package_metadata.py index cc582150..1b56c29e 100644 --- a/tests/unit/test_package_metadata.py +++ b/tests/unit/test_package_metadata.py @@ -17,4 +17,13 @@ def test_framework_optional_dependencies_are_defined() -> None: assert optional_dependencies["fastapi"] == ["fastapi"] assert optional_dependencies["flask"] == ["flask"] assert optional_dependencies["litestar"] == ["litestar"] + assert optional_dependencies["sanic"] == ["sanic"] assert optional_dependencies["starlette"] == ["starlette"] + + +def test_sanic_testing_dependency_is_defined() -> None: + pyproject = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text()) + + test_dependencies = pyproject["dependency-groups"]["test"] + + assert "sanic-testing" in test_dependencies diff --git a/uv.lock b/uv.lock index 870a99bf..852c8cdc 100644 --- a/uv.lock +++ b/uv.lock @@ -2588,6 +2588,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/8a/44032265776062a89171285ede55a0bdaadc8ac00f27f0512a71a9e3e1c8/hatchling-1.29.0-py3-none-any.whl", hash = "sha256:50af9343281f34785fab12da82e445ed987a6efb34fd8c2fc0f6e6630dbcc1b0", size = 76356, upload-time = "2026-02-23T19:42:05.197Z" }, ] +[[package]] +name = "html5tagger" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/02/2ae5f46d517a2c1d4a17f2b1e4834c2c7cc0fb3a69c92389172fa16ab389/html5tagger-1.3.0.tar.gz", hash = "sha256:84fa3dfb49e5c83b79bbd856ab7b1de8e2311c3bb46a8be925f119e3880a8da9", size = 14196, upload-time = "2023-03-28T05:59:34.642Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/12/2f5d43ee912ea14a6baba4b3db6d309b02d932e3b7074c3339b4aded98ff/html5tagger-1.3.0-py3-none-any.whl", hash = "sha256:ce14313515edffec8ed8a36c5890d023922641171b4e6e5774ad1a74998f5351", size = 10956, upload-time = "2023-03-28T05:59:32.524Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -2613,6 +2622,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" }, ] +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531, upload-time = "2025-10-10T03:54:20.887Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408, upload-time = "2025-10-10T03:54:22.455Z" }, + { url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889, upload-time = "2025-10-10T03:54:23.753Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460, upload-time = "2025-10-10T03:54:25.313Z" }, + { url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267, upload-time = "2025-10-10T03:54:26.81Z" }, + { url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429, upload-time = "2025-10-10T03:54:28.174Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173, upload-time = "2025-10-10T03:54:29.5Z" }, + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -6184,6 +6236,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/52/5ccdc01f7a8a61357d15a66b5d8a6580aa8529cb33f32e6cbb71c52622c5/s3fs-2026.3.0-py3-none-any.whl", hash = "sha256:2fa40a64c03003cfa5ae0e352788d97aa78ae8f9e25ea98b28ce9d21ba10c1b8", size = 32399, upload-time = "2026-03-27T19:28:19.702Z" }, ] +[[package]] +name = "sanic" +version = "25.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "html5tagger" }, + { name = "httptools" }, + { name = "multidict" }, + { name = "sanic-routing" }, + { name = "setuptools" }, + { name = "tracerite" }, + { name = "typing-extensions" }, + { name = "ujson", marker = "implementation_name == 'cpython' and sys_platform != 'win32'" }, + { name = "uvloop", marker = "implementation_name == 'cpython' and sys_platform != 'win32'" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/f6/5e9ba853d2872119a252bff0cad712c015c1ed5318cceab5da68c7d2f1c4/sanic-25.12.0.tar.gz", hash = "sha256:ec124338f83a781da8095ed2676e60eb40c7fe21e7aa649a879f8860b4c7bd7a", size = 373452, upload-time = "2025-12-31T19:36:49.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/8a/16adaf66d358abfd0d24f2b76857196cf7effbf75c01306306bf39904e30/sanic-25.12.0-py3-none-any.whl", hash = "sha256:42ccf717f564aadab529a1522c489a709c4971c8123793ae07852aa110f8a913", size = 257787, upload-time = "2025-12-31T19:36:47.406Z" }, +] + +[[package]] +name = "sanic-routing" +version = "23.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/5c/2a7edd14fbccca3719a8d680951d4b25f986752c781c61ccf156a6d1ebff/sanic-routing-23.12.0.tar.gz", hash = "sha256:1dcadc62c443e48c852392dba03603f9862b6197fc4cba5bbefeb1ace0848b04", size = 29473, upload-time = "2023-12-31T09:28:36.992Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/e3/3425c9a8773807ac2c01d6a56c8521733f09b627e5827e733c5cd36b9ac5/sanic_routing-23.12.0-py3-none-any.whl", hash = "sha256:1558a72afcb9046ed3134a5edae02fc1552cff08f0fff2e8d5de0877ea43ed73", size = 25522, upload-time = "2023-12-31T09:28:35.233Z" }, +] + +[[package]] +name = "sanic-testing" +version = "24.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/56/8d31d8a7e0b61633d6358694edfae976e69739b5bd640ceac7989b62e749/sanic_testing-24.6.0.tar.gz", hash = "sha256:7591ce537e2a651efb6dc01b458e7e4ea5347f6d91438676774c6f505a124731", size = 10871, upload-time = "2024-06-30T12:13:31.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/93/1d588f1cb9b710b9f22fa78b53d699a8062edc94204d50dd0d78c5f5b495/sanic_testing-24.6.0-py3-none-any.whl", hash = "sha256:b1027184735e88230891aa0461fff84093abfa3bff0f4d29c0f78f42e59efada", size = 10326, upload-time = "2024-06-30T12:13:30.014Z" }, +] + [[package]] name = "setuptools" version = "82.0.1" @@ -6968,6 +7063,9 @@ pymssql = [ pymysql = [ { name = "pymysql" }, ] +sanic = [ + { name = "sanic" }, +] spanner = [ { name = "google-cloud-spanner" }, ] @@ -7034,6 +7132,7 @@ dev = [ { name = "pytest-xdist" }, { name = "requests" }, { name = "ruff" }, + { name = "sanic-testing" }, { name = "shibuya" }, { name = "slotscheck" }, { name = "sniffio" }, @@ -7136,6 +7235,7 @@ test = [ { name = "pytest-timeout" }, { name = "pytest-xdist" }, { name = "requests" }, + { name = "sanic-testing" }, { name = "sniffio" }, ] @@ -7185,6 +7285,7 @@ requires-dist = [ { name = "pymysql", marker = "extra == 'pymysql'" }, { name = "pytz", marker = "extra == 'duckdb'" }, { name = "rich-click", specifier = ">=1.9.0" }, + { name = "sanic", marker = "extra == 'sanic'" }, { name = "sqlglot", specifier = ">=30.0.0" }, { name = "sqlglot", extras = ["c"], marker = "extra == 'mypyc'", specifier = ">=30.0.0" }, { name = "starlette", marker = "extra == 'starlette'" }, @@ -7192,7 +7293,7 @@ requires-dist = [ { name = "typing-extensions" }, { name = "uuid-utils", marker = "extra == 'uuid'" }, ] -provides-extras = ["adbc", "adk", "aiomysql", "aioodbc", "aiosqlite", "alloydb", "asyncmy", "asyncpg", "attrs", "bigquery", "cloud-sql", "cockroachdb", "duckdb", "fastapi", "flask", "fsspec", "litestar", "msgspec", "mypyc", "mysql-connector", "nanoid", "obstore", "opentelemetry", "oracledb", "orjson", "pandas", "performance", "polars", "prometheus", "psqlpy", "psycopg", "pydantic", "pymssql", "pymysql", "spanner", "starlette", "uuid"] +provides-extras = ["adbc", "adk", "aiomysql", "aioodbc", "aiosqlite", "alloydb", "asyncmy", "asyncpg", "attrs", "bigquery", "cloud-sql", "cockroachdb", "duckdb", "fastapi", "flask", "fsspec", "litestar", "msgspec", "mypyc", "mysql-connector", "nanoid", "obstore", "opentelemetry", "oracledb", "orjson", "pandas", "performance", "polars", "prometheus", "psqlpy", "psycopg", "pydantic", "pymssql", "pymysql", "sanic", "spanner", "starlette", "uuid"] [package.metadata.requires-dev] benchmarks = [ @@ -7248,6 +7349,7 @@ dev = [ { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "requests" }, { name = "ruff", specifier = ">=0.7.1" }, + { name = "sanic-testing" }, { name = "shibuya" }, { name = "slotscheck", specifier = ">=0.16.5" }, { name = "sniffio" }, @@ -7336,6 +7438,7 @@ test = [ { name = "pytest-timeout", specifier = ">=2.3.1" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "requests" }, + { name = "sanic-testing" }, { name = "sniffio" }, ] @@ -7503,6 +7606,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" }, ] +[[package]] +name = "tracerite" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "html5tagger" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/b9/89b065c1818e5973c333a33311f823954ff4c7c48440c20b37669c5b752c/tracerite-2.3.1.tar.gz", hash = "sha256:f46ee672d240d500a2331781b09eb33564d473f6ae60cd871ebce6c2413cffa8", size = 61303, upload-time = "2025-12-30T22:51:19.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/62/3f385a67ff3cc91209f107d20bbebdecf7a4e4aba55a43f9f71bddc424a9/tracerite-2.3.1-py3-none-any.whl", hash = "sha256:5f9595ba90f075b58e14a9baf84d8204fec3cdce50029f1c32d757af79d9ccbe", size = 65884, upload-time = "2025-12-30T22:51:18.1Z" }, +] + [[package]] name = "traitlets" version = "5.14.3" @@ -7650,6 +7765,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] +[[package]] +name = "ujson" +version = "5.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/3e/c35530c5ffc25b71c59ae0cd7b8f99df37313daa162ce1e2f7925f7c2877/ujson-5.12.0.tar.gz", hash = "sha256:14b2e1eb528d77bc0f4c5bd1a7ebc05e02b5b41beefb7e8567c9675b8b13bcf4", size = 7158451, upload-time = "2026-03-11T22:19:30.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/ee/45c7c1f9268b0fecdd68f9ada490bc09632b74f5f90a9be759e51a746ddc/ujson-5.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:38051f36423f084b909aaadb3b41c9c6a2958e86956ba21a8489636911e87504", size = 56145, upload-time = "2026-03-11T22:17:49.409Z" }, + { url = "https://files.pythonhosted.org/packages/6d/dc/ed181dbfb2beee598e91280c6903ba71e10362b051716317e2d3664614bb/ujson-5.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:457fabc2700a8e6ddb85bc5a1d30d3345fe0d3ec3ee8161a4e032ec585801dfa", size = 53839, upload-time = "2026-03-11T22:17:50.973Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d8/eb9ef42c660f431deeedc2e1b09c4ba29aa22818a439ddda7da6ae23ddfa/ujson-5.12.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57930ac9519099b852e190d2c04b1fb5d97ea128db33bce77ed874eccb4c7f09", size = 57844, upload-time = "2026-03-11T22:17:53.029Z" }, + { url = "https://files.pythonhosted.org/packages/68/37/0b586d079d3f2a5be5aa58ab5c423cbb4fae2ee4e65369c87aa74ac7e113/ujson-5.12.0-cp310-cp310-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:9b3b86ec3e818f3dd3e13a9de628e88a9990f4af68ecb0b12dd3de81227f0a26", size = 59923, upload-time = "2026-03-11T22:17:54.332Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/6a4b69eb397502767f438b5a2b4c066dccc9e3b263115f5ee07510250fc7/ujson-5.12.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e76a4daff214ae33ab959494962c93918cb44714ea3e3f748b14aa37f8a87", size = 57427, upload-time = "2026-03-11T22:17:55.317Z" }, + { url = "https://files.pythonhosted.org/packages/bb/4b/ae118440a72e85e68ee8dd26cfc47ea7857954a3341833cde9da7dc40ca3/ujson-5.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e584d0cdd37cac355aca52ed788d1a2d939d6837e2870d3b70e585db24025a50", size = 1037301, upload-time = "2026-03-11T22:17:56.427Z" }, + { url = "https://files.pythonhosted.org/packages/c2/76/834caa7905f65d3a695e4f5ff8d5d4a98508e396a9e8ab0739ab4fe2d422/ujson-5.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0fe9128e75c6aa6e9ae06c1408d6edd9179a2fef0fe6d9cda3166b887eba521d", size = 1196664, upload-time = "2026-03-11T22:17:58.061Z" }, + { url = "https://files.pythonhosted.org/packages/f2/33/1f3c1543c1d3f18c54bb3f8c1e74314fd6ad3c1aa375f01433e89a86bfa6/ujson-5.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3ed5cb149892141b1e77ef312924a327f2cc718b34247dae346ed66329e1b8be", size = 1089668, upload-time = "2026-03-11T22:17:59.617Z" }, + { url = "https://files.pythonhosted.org/packages/10/22/fd22e2f6766bae934d3050517ca47d463016bd8688508d1ecc1baa18a7ad/ujson-5.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:58a11cb49482f1a095a2bd9a1d81dd7c8fb5d2357f959ece85db4e46a825fd00", size = 56139, upload-time = "2026-03-11T22:18:04.591Z" }, + { url = "https://files.pythonhosted.org/packages/c6/fd/6839adff4fc0164cbcecafa2857ba08a6eaeedd7e098d6713cb899a91383/ujson-5.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9b3cf13facf6f77c283af0e1713e5e8c47a0fe295af81326cb3cb4380212e797", size = 53836, upload-time = "2026-03-11T22:18:05.662Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b0/0c19faac62d68ceeffa83a08dc3d71b8462cf5064d0e7e0b15ba19898dad/ujson-5.12.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb94245a715b4d6e24689de12772b85329a1f9946cbf6187923a64ecdea39e65", size = 57851, upload-time = "2026-03-11T22:18:06.744Z" }, + { url = "https://files.pythonhosted.org/packages/04/f6/e7fd283788de73b86e99e08256726bb385923249c21dcd306e59d532a1a1/ujson-5.12.0-cp311-cp311-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:0fe6b8b8968e11dd9b2348bd508f0f57cf49ab3512064b36bc4117328218718e", size = 59906, upload-time = "2026-03-11T22:18:07.791Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3a/b100735a2b43ee6e8fe4c883768e362f53576f964d4ea841991060aeaf35/ujson-5.12.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89e302abd3749f6d6699691747969a5d85f7c73081d5ed7e2624c7bd9721a2ab", size = 57409, upload-time = "2026-03-11T22:18:08.79Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/f97cc20c99ca304662191b883ae13ae02912ca7244710016ba0cb8a5be34/ujson-5.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0727363b05ab05ee737a28f6200dc4078bce6b0508e10bd8aab507995a15df61", size = 1037339, upload-time = "2026-03-11T22:18:10.424Z" }, + { url = "https://files.pythonhosted.org/packages/10/7a/53ddeda0ffe1420db2f9999897b3cbb920fbcff1849d1f22b196d0f34785/ujson-5.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b62cb9a7501e1f5c9ffe190485501349c33e8862dde4377df774e40b8166871f", size = 1196625, upload-time = "2026-03-11T22:18:11.82Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/4c64a6bef522e9baf195dd5be151bc815cd4896c50c6e2489599edcda85f/ujson-5.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6ec5bf6bc361f2f0f9644907a36ce527715b488988a8df534120e5c34eeda94", size = 1089669, upload-time = "2026-03-11T22:18:13.343Z" }, + { url = "https://files.pythonhosted.org/packages/84/f6/ac763d2108d28f3a40bb3ae7d2fafab52ca31b36c2908a4ad02cd3ceba2a/ujson-5.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:09b4beff9cc91d445d5818632907b85fb06943b61cb346919ce202668bf6794a", size = 56326, upload-time = "2026-03-11T22:18:18.467Z" }, + { url = "https://files.pythonhosted.org/packages/25/46/d0b3af64dcdc549f9996521c8be6d860ac843a18a190ffc8affeb7259687/ujson-5.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ca0c7ce828bb76ab78b3991904b477c2fd0f711d7815c252d1ef28ff9450b052", size = 53910, upload-time = "2026-03-11T22:18:19.502Z" }, + { url = "https://files.pythonhosted.org/packages/9a/10/853c723bcabc3e9825a079019055fc99e71b85c6bae600607a2b9d31d18d/ujson-5.12.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d79c6635ccffcbfc1d5c045874ba36b594589be81d50d43472570bb8de9c57", size = 57754, upload-time = "2026-03-11T22:18:20.874Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c6/6e024830d988f521f144ead641981c1f7a82c17ad1927c22de3242565f5c/ujson-5.12.0-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:7e07f6f644d2c44d53b7a320a084eef98063651912c1b9449b5f45fcbdc6ccd2", size = 59936, upload-time = "2026-03-11T22:18:21.924Z" }, + { url = "https://files.pythonhosted.org/packages/34/c9/c5f236af5abe06b720b40b88819d00d10182d2247b1664e487b3ed9229cf/ujson-5.12.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:085b6ce182cdd6657481c7c4003a417e0655c4f6e58b76f26ee18f0ae21db827", size = 57463, upload-time = "2026-03-11T22:18:22.924Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/41342d9ef68e793a87d84e4531a150c2b682f3bcedfe59a7a5e3f73e9213/ujson-5.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:16b4fe9c97dc605f5e1887a9e1224287291e35c56cbc379f8aa44b6b7bcfe2bb", size = 1037239, upload-time = "2026-03-11T22:18:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/d4/81/dc2b7617d5812670d4ff4a42f6dd77926430ee52df0dedb2aec7990b2034/ujson-5.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0d2e8db5ade3736a163906154ca686203acc7d1d30736cbf577c730d13653d84", size = 1196713, upload-time = "2026-03-11T22:18:25.391Z" }, + { url = "https://files.pythonhosted.org/packages/b6/9c/80acff0504f92459ed69e80a176286e32ca0147ac6a8252cd0659aad3227/ujson-5.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:93bc91fdadcf046da37a214eaa714574e7e9b1913568e93bb09527b2ceb7f759", size = 1089742, upload-time = "2026-03-11T22:18:26.738Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f1/0ef0eeab1db8493e1833c8b440fe32cf7538f7afa6e7f7c7e9f62cef464d/ujson-5.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:15d416440148f3e56b9b244fdaf8a09fcf5a72e4944b8e119f5bf60417a2bfc8", size = 56331, upload-time = "2026-03-11T22:18:31.539Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2f/9159f6f399b3f572d20847a2b80d133e3a03c14712b0da4971a36879fb64/ujson-5.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0dd3676ea0837cd70ea1879765e9e9f6be063be0436de9b3ea4b775caf83654", size = 53910, upload-time = "2026-03-11T22:18:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/a9/f96376818d71495d1a4be19a0ab6acf0cc01dd8826553734c3d4dac685b2/ujson-5.12.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7bbf05c38debc90d1a195b11340cc85cb43ab3e753dc47558a3a84a38cbc72da", size = 57757, upload-time = "2026-03-11T22:18:33.866Z" }, + { url = "https://files.pythonhosted.org/packages/98/8d/dd4a151caac6fdcb77f024fbe7f09d465ebf347a628ed6dd581a0a7f6364/ujson-5.12.0-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:3c2f947e55d3c7cfe124dd4521ee481516f3007d13c6ad4bf6aeb722e190eb1b", size = 59940, upload-time = "2026-03-11T22:18:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/c7/17/0d36c2fee0a8d8dc37b011ccd5bbdcfaff8b8ec2bcfc5be998661cdc935b/ujson-5.12.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ea6206043385343aff0b7da65cf73677f6f5e50de8f1c879e557f4298cac36a", size = 57465, upload-time = "2026-03-11T22:18:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/8c/04/b0ee4a4b643a01ba398441da1e357480595edb37c6c94c508dbe0eb9eb60/ujson-5.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb349dbba57c76eec25e5917e07f35aabaf0a33b9e67fc13d188002500106487", size = 1037236, upload-time = "2026-03-11T22:18:37.743Z" }, + { url = "https://files.pythonhosted.org/packages/2d/08/0e7780d0bbb48fe57ded91f550144bcc99c03b5360bf2886dd0dae0ea8f5/ujson-5.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:937794042342006f707837f38d721426b11b0774d327a2a45c0bd389eb750a87", size = 1196717, upload-time = "2026-03-11T22:18:39.101Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4c/e0e34107715bb4dd2d4dcc1ce244d2f074638837adf38aff85a37506efe4/ujson-5.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6ad57654570464eb1b040b5c353dee442608e06cff9102b8fcb105565a44c9ed", size = 1089748, upload-time = "2026-03-11T22:18:40.473Z" }, + { url = "https://files.pythonhosted.org/packages/10/bd/9a8d693254bada62bfea75a507e014afcfdb6b9d047b6f8dd134bfefaf67/ujson-5.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85833bca01aa5cae326ac759276dc175c5fa3f7b3733b7d543cf27f2df12d1ef", size = 56499, upload-time = "2026-03-11T22:18:45.431Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2d/285a83df8176e18dcd675d1a4cff8f7620f003f30903ea43929406e98986/ujson-5.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d22cad98c2a10bbf6aa083a8980db6ed90d4285a841c4de892890c2b28286ef9", size = 53998, upload-time = "2026-03-11T22:18:47.184Z" }, + { url = "https://files.pythonhosted.org/packages/bf/8b/e2f09e16dabfa91f6a84555df34a4329fa7621e92ed054d170b9054b9bb2/ujson-5.12.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99cc80facad240b0c2fb5a633044420878aac87a8e7c348b9486450cba93f27c", size = 57783, upload-time = "2026-03-11T22:18:48.271Z" }, + { url = "https://files.pythonhosted.org/packages/68/fb/ba1d06f3658a0c36d0ab3869ec3914f202bad0a9bde92654e41516c7bb13/ujson-5.12.0-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:d1831c07bd4dce53c4b666fa846c7eba4b7c414f2e641a4585b7f50b72f502dc", size = 60011, upload-time = "2026-03-11T22:18:49.284Z" }, + { url = "https://files.pythonhosted.org/packages/64/2b/3e322bf82d926d9857206cd5820438d78392d1f523dacecb8bd899952f73/ujson-5.12.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e00cec383eab2406c9e006bd4edb55d284e94bb943fda558326048178d26961", size = 57465, upload-time = "2026-03-11T22:18:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/e9/fd/af72d69603f9885e5136509a529a4f6d88bf652b457263ff96aefcd3ab7d/ujson-5.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f19b3af31d02a2e79c5f9a6deaab0fb3c116456aeb9277d11720ad433de6dfc6", size = 1037275, upload-time = "2026-03-11T22:18:51.998Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a7/a2411ec81aef7872578e56304c3e41b3a544a9809e95c8e1df46923fc40b/ujson-5.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bacbd3c69862478cbe1c7ed4325caedec580d8acf31b8ee1b9a1e02a56295cad", size = 1196758, upload-time = "2026-03-11T22:18:53.548Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/aa18ae175dd03a118555aa14304d4f466f9db61b924c97c6f84388ecacb1/ujson-5.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94c5f1621cbcab83c03be46441f090b68b9f307b6c7ec44d4e3f6d5997383df4", size = 1089760, upload-time = "2026-03-11T22:18:55.336Z" }, + { url = "https://files.pythonhosted.org/packages/c3/71/9b4dacb177d3509077e50497222d39eec04c8b41edb1471efc764d645237/ujson-5.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ddb08b3c2f9213df1f2e3eb2fbea4963d80ec0f8de21f0b59898e34f3b3d96d", size = 56845, upload-time = "2026-03-11T22:18:59.629Z" }, + { url = "https://files.pythonhosted.org/packages/24/c2/8abffa3be1f3d605c4a62445fab232b3e7681512ce941c6b23014f404d36/ujson-5.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a3ae28f0b209be5af50b54ca3e2123a3de3a57d87b75f1e5aa3d7961e041983", size = 54463, upload-time = "2026-03-11T22:19:00.697Z" }, + { url = "https://files.pythonhosted.org/packages/db/2e/60114a35d1d6796eb428f7affcba00a921831ff604a37d9142c3d8bbe5c5/ujson-5.12.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30ad4359413c8821cc7b3707f7ca38aa8bc852ba3b9c5a759ee2d7740157315", size = 58689, upload-time = "2026-03-11T22:19:01.739Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ad/010925c2116c21ce119f9c2ff18d01f48a19ade3ff4c5795da03ce5829fc/ujson-5.12.0-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:02f93da7a4115e24f886b04fd56df1ee8741c2ce4ea491b7ab3152f744ad8f8e", size = 60618, upload-time = "2026-03-11T22:19:03.101Z" }, + { url = "https://files.pythonhosted.org/packages/9b/74/db7f638bf20282b1dccf454386cbd483faaaed3cdbb9cb27e06f74bb109e/ujson-5.12.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ff4ede90ed771140caa7e1890de17431763a483c54b3c1f88bd30f0cc1affc0", size = 58151, upload-time = "2026-03-11T22:19:04.175Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7e/3ebaecfa70a2e8ce623db8e21bd5cb05d42a5ef943bcbb3309d71b5de68d/ujson-5.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bf9cc97f05048ac8f3e02cd58f0fe62b901453c24345bfde287f4305dcc31c", size = 1038117, upload-time = "2026-03-11T22:19:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/e073eda7f0036c2973b28db7bb99faba17a932e7b52d801f9bb3e726271f/ujson-5.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2324d9a0502317ffc35d38e153c1b2fa9610ae03775c9d0f8d0cca7b8572b04e", size = 1197434, upload-time = "2026-03-11T22:19:06.92Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/b9a13f058fdd50c746b192c4447ca8d6352e696dcda912ccee10f032ff85/ujson-5.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:50524f4f6a1c839714dbaff5386a1afb245d2d5ec8213a01fbc99cea7307811e", size = 1090401, upload-time = "2026-03-11T22:19:08.383Z" }, + { url = "https://files.pythonhosted.org/packages/95/3c/5ee154d505d1aad2debc4ba38b1a60ae1949b26cdb5fa070e85e320d6b64/ujson-5.12.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:bf85a00ac3b56a1e7a19c5be7b02b5180a0895ac4d3c234d717a55e86960691c", size = 54494, upload-time = "2026-03-11T22:19:13.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b3/9496ec399ec921e434a93b340bd5052999030b7ac364be4cbe5365ac6b20/ujson-5.12.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:64df53eef4ac857eb5816a56e2885ccf0d7dff6333c94065c93b39c51063e01d", size = 57999, upload-time = "2026-03-11T22:19:14.385Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/e9ae98133336e7c0d50b43626c3f2327937cecfa354d844e02ac17379ed1/ujson-5.12.0-graalpy312-graalpy250_312_native-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c0aed6a4439994c9666fb8a5b6c4eac94d4ef6ddc95f9b806a599ef83547e3b", size = 54518, upload-time = "2026-03-11T22:19:15.4Z" }, + { url = "https://files.pythonhosted.org/packages/58/10/978d89dded6bb1558cd46ba78f4351198bd2346db8a8ee1a94119022ce40/ujson-5.12.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efae5df7a8cc8bdb1037b0f786b044ce281081441df5418c3a0f0e1f86fe7bb3", size = 55736, upload-time = "2026-03-11T22:19:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/f4a957dddb99bd68c8be91928c0b6fefa7aa8aafc92c93f5d1e8b32f6702/ujson-5.12.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:871c0e5102e47995b0e37e8df7819a894a6c3da0d097545cd1f9f1f7d7079927", size = 52145, upload-time = "2026-03-11T22:19:18.566Z" }, + { url = "https://files.pythonhosted.org/packages/55/6e/50b5cf612de1ca06c7effdc5a5d7e815774dee85a5858f1882c425553b82/ujson-5.12.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:56ba3f7abbd6b0bb282a544dc38406d1a188d8bb9164f49fdb9c2fee62cb29da", size = 49577, upload-time = "2026-03-11T22:19:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/6e/24/b6713fa9897774502cd4c2d6955bb4933349f7d84c3aa805531c382a4209/ujson-5.12.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c5a52987a990eb1bae55f9000994f1afdb0326c154fb089992f839ab3c30688", size = 50807, upload-time = "2026-03-11T22:19:20.778Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b6/c0e0f7901180ef80d16f3a4bccb5dc8b01515a717336a62928963a07b80b/ujson-5.12.0-pp311-pypy311_pp73-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:adf28d13a33f9d750fe7a78fb481cac298fa257d8863d8727b2ea4455ea41235", size = 56972, upload-time = "2026-03-11T22:19:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/02/a9/05d91b4295ea7239151eb08cf240e5a2ba969012fda50bc27bcb1ea9cd71/ujson-5.12.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51acc750ec7a2df786cdc868fb16fa04abd6269a01d58cf59bafc57978773d8e", size = 52045, upload-time = "2026-03-11T22:19:22.879Z" }, +] + [[package]] name = "uritemplate" version = "4.2.0" @@ -7711,6 +7891,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/88/d0f7512465b166a4e931ccf7e77792be60fb88466a43964c7566cbaff752/uvicorn-0.45.0-py3-none-any.whl", hash = "sha256:2db26f588131aeec7439de00f2dd52d5f210710c1f01e407a52c90b880d1fd4f", size = 69838, upload-time = "2026-04-21T10:43:45.029Z" }, ] +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + [[package]] name = "virtualenv" version = "21.2.4" From e66cb948cf560784ebde7b18d6e6012a2b6bdde8 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 26 Apr 2026 02:35:25 +0000 Subject: [PATCH 07/14] feat(sanic): add context helper validation --- sqlspec/extensions/sanic/_utils.py | 14 ++- .../test_sanic/test_config_state.py | 99 +++++++++++++++++++ .../unit/extensions/test_sanic/test_utils.py | 76 ++++++++++++++ 3 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 tests/unit/extensions/test_sanic/test_config_state.py create mode 100644 tests/unit/extensions/test_sanic/test_utils.py diff --git a/sqlspec/extensions/sanic/_utils.py b/sqlspec/extensions/sanic/_utils.py index 9a8c44af..82e8a91a 100644 --- a/sqlspec/extensions/sanic/_utils.py +++ b/sqlspec/extensions/sanic/_utils.py @@ -1,5 +1,7 @@ from typing import TYPE_CHECKING, Any +from sqlspec.exceptions import ImproperConfigurationError + if TYPE_CHECKING: from sqlspec.extensions.sanic._state import SanicConfigState @@ -81,8 +83,18 @@ def get_connection_from_request(request: Any, config_state: "SanicConfigState") Returns: Database connection object. + + Raises: + ImproperConfigurationError: If SQLSpec request middleware has not stored a connection. """ - return get_context_value(request.ctx, config_state.connection_key) + try: + return get_context_value(request.ctx, config_state.connection_key) + except AttributeError as exc: + msg = ( + f"Sanic request context does not contain SQLSpec connection '{config_state.connection_key}'. " + "Ensure SQLSpecPlugin is initialized and its request middleware runs before accessing sessions." + ) + raise ImproperConfigurationError(msg) from exc def get_or_create_session(request: Any, config_state: "SanicConfigState") -> Any: diff --git a/tests/unit/extensions/test_sanic/test_config_state.py b/tests/unit/extensions/test_sanic/test_config_state.py new file mode 100644 index 00000000..3f24739c --- /dev/null +++ b/tests/unit/extensions/test_sanic/test_config_state.py @@ -0,0 +1,99 @@ +"""Tests for Sanic extension configuration state.""" + +from unittest.mock import MagicMock + +import pytest + +from sqlspec.exceptions import ImproperConfigurationError +from sqlspec.extensions.sanic import SanicConfigState, SQLSpecPlugin + + +def test_config_state_creation() -> None: + """SanicConfigState should preserve configured context keys.""" + mock_config = MagicMock() + + state = SanicConfigState( + config=mock_config, + connection_key="db_connection", + pool_key="db_pool", + session_key="db_session", + commit_mode="manual", + extra_commit_statuses=None, + extra_rollback_statuses=None, + disable_di=False, + ) + + assert state.config is mock_config + assert state.connection_key == "db_connection" + assert state.pool_key == "db_pool" + assert state.session_key == "db_session" + assert state.commit_mode == "manual" + assert state.sqlcommenter_framework == "sanic" + + +def test_config_state_rejects_conflicting_extra_statuses() -> None: + """Extra commit and rollback statuses cannot overlap.""" + mock_config = MagicMock() + + with pytest.raises(ImproperConfigurationError) as exc_info: + SanicConfigState( + config=mock_config, + connection_key="conn", + pool_key="pool", + session_key="session", + commit_mode="autocommit", + extra_commit_statuses={418}, + extra_rollback_statuses={418}, + disable_di=False, + ) + + assert "must not share" in str(exc_info.value) + + +def test_duplicate_context_keys_are_rejected() -> None: + """Each configured Sanic context key should be unique across configs.""" + mock_config = MagicMock() + plugin = SQLSpecPlugin(MagicMock(configs={})) + state_one = SanicConfigState( + config=mock_config, + connection_key="db_connection", + pool_key="db_pool", + session_key="db_session", + commit_mode="manual", + extra_commit_statuses=None, + extra_rollback_statuses=None, + disable_di=False, + ) + state_two = SanicConfigState( + config=mock_config, + connection_key="other_connection", + pool_key="db_pool", + session_key="other_session", + commit_mode="manual", + extra_commit_statuses=None, + extra_rollback_statuses=None, + disable_di=False, + ) + plugin._config_states = [state_one, state_two] # pyright: ignore[reportPrivateUsage] + + with pytest.raises(ImproperConfigurationError) as exc_info: + plugin._validate_unique_keys() # pyright: ignore[reportPrivateUsage] + + assert "Duplicate context keys" in str(exc_info.value) + + +@pytest.mark.parametrize( + ("supports_connection_pooling", "expected_pool_key_prefix"), [(True, "db_pool"), (False, "_db_pool_")] +) +def test_pool_key_defaults_handle_pooled_and_non_pooled_configs( + supports_connection_pooling: bool, expected_pool_key_prefix: str +) -> None: + """Non-pooled configs should get isolated pool keys for duplicate-key validation.""" + plugin = SQLSpecPlugin(MagicMock(configs={})) + config = MagicMock() + config.supports_connection_pooling = supports_connection_pooling + config.extension_config = {"sanic": {}} + + settings = plugin._extract_sanic_settings(config) # pyright: ignore[reportPrivateUsage] + + assert settings["pool_key"].startswith(expected_pool_key_prefix) diff --git a/tests/unit/extensions/test_sanic/test_utils.py b/tests/unit/extensions/test_sanic/test_utils.py new file mode 100644 index 00000000..faf37484 --- /dev/null +++ b/tests/unit/extensions/test_sanic/test_utils.py @@ -0,0 +1,76 @@ +"""Tests for Sanic extension context utilities.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from sqlspec.exceptions import ImproperConfigurationError +from sqlspec.extensions.sanic import SanicConfigState, get_connection_from_request, get_or_create_session +from sqlspec.extensions.sanic._utils import get_context_value, pop_context_value, set_context_value + + +def test_context_set_get_pop() -> None: + """Sanic ctx helpers should use attribute storage.""" + ctx = SimpleNamespace() + value = object() + + set_context_value(ctx, "db_connection", value) + + assert get_context_value(ctx, "db_connection") is value + assert pop_context_value(ctx, "db_connection") is value + assert pop_context_value(ctx, "db_connection") is None + + +def test_get_connection_from_request() -> None: + """get_connection_from_request should read request.ctx.""" + connection = object() + request = SimpleNamespace(ctx=SimpleNamespace(db_connection=connection)) + config_state = _make_state() + + result = get_connection_from_request(request, config_state) + + assert result is connection + + +def test_get_connection_from_request_raises_when_missing() -> None: + """Missing request connections should raise a SQLSpec configuration error.""" + request = SimpleNamespace(ctx=SimpleNamespace()) + config_state = _make_state() + + with pytest.raises(ImproperConfigurationError) as exc_info: + get_connection_from_request(request, config_state) + + assert "db_connection" in str(exc_info.value) + + +def test_get_or_create_session_creates_and_caches_session() -> None: + """get_or_create_session should cache one driver instance per request.""" + connection = object() + request = SimpleNamespace(ctx=SimpleNamespace(db_connection=connection)) + config = MagicMock() + config.driver_type = MagicMock() + config.statement_config = {"test": "config"} + config.driver_features = {"feature": True} + config_state = _make_state(config=config) + + session = get_or_create_session(request, config_state) + cached_session = get_or_create_session(request, config_state) + + assert cached_session is session + config.driver_type.assert_called_once_with( + connection=connection, statement_config={"test": "config"}, driver_features={"feature": True} + ) + + +def _make_state(config: MagicMock | None = None) -> SanicConfigState: + return SanicConfigState( + config=config or MagicMock(), + connection_key="db_connection", + pool_key="db_pool", + session_key="db_session", + commit_mode="manual", + extra_commit_statuses=None, + extra_rollback_statuses=None, + disable_di=False, + ) From e833f605bbdbe35db3ee78801627ea561a3cd6d2 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 26 Apr 2026 02:38:58 +0000 Subject: [PATCH 08/14] feat(sanic): add lifecycle listeners --- sqlspec/extensions/sanic/extension.py | 96 ++++++++++++++++++- .../extensions/test_sanic/test_lifecycle.py | 90 +++++++++++++++++ 2 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 tests/unit/extensions/test_sanic/test_lifecycle.py diff --git a/sqlspec/extensions/sanic/extension.py b/sqlspec/extensions/sanic/extension.py index 443bc36f..fad1d636 100644 --- a/sqlspec/extensions/sanic/extension.py +++ b/sqlspec/extensions/sanic/extension.py @@ -4,8 +4,15 @@ from sqlspec.base import SQLSpec from sqlspec.exceptions import ImproperConfigurationError from sqlspec.extensions.sanic._state import SanicConfigState -from sqlspec.extensions.sanic._utils import get_context_value, get_or_create_session +from sqlspec.extensions.sanic._utils import ( + get_context_value, + get_or_create_session, + has_context_value, + pop_context_value, + set_context_value, +) from sqlspec.utils.logging import get_logger, log_with_context +from sqlspec.utils.sync_tools import ensure_async_ if TYPE_CHECKING: from sanic import Sanic @@ -49,7 +56,7 @@ class SQLSpecPlugin: db_ext = SQLSpecPlugin(sqlspec, app) """ - __slots__ = ("_config_states", "_sqlspec") + __slots__ = ("_config_states", "_lifecycle_listeners_added", "_sqlspec") def __init__(self, sqlspec: SQLSpec, app: "Sanic[Any, Any] | None" = None) -> None: """Initialize SQLSpec Sanic extension. @@ -60,6 +67,7 @@ def __init__(self, sqlspec: SQLSpec, app: "Sanic[Any, Any] | None" = None) -> No """ self._sqlspec = sqlspec self._config_states: list[SanicConfigState] = [] + self._lifecycle_listeners_added = False for cfg in self._sqlspec.configs.values(): settings = self._extract_sanic_settings(cfg) @@ -152,6 +160,90 @@ def init_app(self, app: "Sanic[Any, Any]") -> None: """ self._validate_unique_keys() setattr(app.ctx, "sqlspec_plugin", self) + self._add_lifecycle_listeners(app) + + def _add_lifecycle_listeners(self, app: "Sanic[Any, Any]") -> None: + """Register Sanic server lifecycle listeners. + + Args: + app: Sanic application instance. + """ + if self._lifecycle_listeners_added: + return + + app.before_server_start(self._before_server_start) + app.after_server_stop(self._after_server_stop) + self._lifecycle_listeners_added = True + + async def _before_server_start(self, app: Any, *_: Any) -> None: + """Create configured connection pools before the worker starts. + + Args: + app: Sanic application instance. + *_: Optional Sanic listener arguments. + """ + for config_state in self._config_states: + if not config_state.config.supports_connection_pooling: + continue + if has_context_value(app.ctx, config_state.pool_key): + continue + + try: + pool = await ensure_async_(config_state.config.create_pool)() + set_context_value(app.ctx, config_state.pool_key, pool) + except Exception: + log_with_context( + logger, + logging.ERROR, + "pool.create.failed", + framework="sanic", + pool_key=config_state.pool_key, + session_key=config_state.session_key, + ) + raise + log_with_context( + logger, + logging.DEBUG, + "pool.create", + framework="sanic", + pool_key=config_state.pool_key, + session_key=config_state.session_key, + ) + + async def _after_server_stop(self, app: Any, *_: Any) -> None: + """Close configured connection pools after the worker stops. + + Args: + app: Sanic application instance. + *_: Optional Sanic listener arguments. + """ + for config_state in self._config_states: + if not config_state.config.supports_connection_pooling: + continue + if not has_context_value(app.ctx, config_state.pool_key): + continue + + try: + await ensure_async_(config_state.config.close_pool)() + except Exception: + log_with_context( + logger, + logging.ERROR, + "pool.close.failed", + framework="sanic", + pool_key=config_state.pool_key, + session_key=config_state.session_key, + ) + raise + pop_context_value(app.ctx, config_state.pool_key) + log_with_context( + logger, + logging.DEBUG, + "pool.close", + framework="sanic", + pool_key=config_state.pool_key, + session_key=config_state.session_key, + ) def _validate_unique_keys(self) -> None: """Validate that all context keys are unique across configs. diff --git a/tests/unit/extensions/test_sanic/test_lifecycle.py b/tests/unit/extensions/test_sanic/test_lifecycle.py new file mode 100644 index 00000000..70ab820e --- /dev/null +++ b/tests/unit/extensions/test_sanic/test_lifecycle.py @@ -0,0 +1,90 @@ +"""Tests for Sanic extension lifecycle listeners.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest + +pytest.importorskip("sanic") + +from sanic import Sanic + +from sqlspec.extensions.sanic import SQLSpecPlugin +from sqlspec.extensions.sanic._utils import has_context_value + + +async def test_startup_creates_pool_on_app_context() -> None: + """before_server_start should create configured pools on app.ctx.""" + pool = object() + config = _make_config(pool=pool) + plugin = SQLSpecPlugin(MagicMock(configs={"default": config})) + app = SimpleNamespace(ctx=SimpleNamespace()) + + await plugin._before_server_start(app) # pyright: ignore[reportPrivateUsage] + + assert app.ctx.db_pool is pool + config.create_pool.assert_awaited_once_with() + + +async def test_startup_is_idempotent_for_existing_pool() -> None: + """before_server_start should not recreate a pool already present on app.ctx.""" + pool = object() + config = _make_config(pool=pool) + plugin = SQLSpecPlugin(MagicMock(configs={"default": config})) + app = SimpleNamespace(ctx=SimpleNamespace(db_pool=pool)) + + await plugin._before_server_start(app) # pyright: ignore[reportPrivateUsage] + + assert app.ctx.db_pool is pool + config.create_pool.assert_not_awaited() + + +async def test_shutdown_closes_pool_and_removes_app_context_value() -> None: + """after_server_stop should close configured pools and remove app.ctx storage.""" + pool = object() + config = _make_config(pool=pool) + plugin = SQLSpecPlugin(MagicMock(configs={"default": config})) + app = SimpleNamespace(ctx=SimpleNamespace(db_pool=pool)) + + await plugin._after_server_stop(app) # pyright: ignore[reportPrivateUsage] + + config.close_pool.assert_awaited_once_with() + assert not has_context_value(app.ctx, "db_pool") + + +async def test_lifecycle_wraps_sync_pool_hooks() -> None: + """Lifecycle listeners should support sync adapter pool hooks.""" + pool = object() + config = _make_config(pool=pool) + config.create_pool = MagicMock(return_value=pool) + config.close_pool = MagicMock() + plugin = SQLSpecPlugin(MagicMock(configs={"default": config})) + app = SimpleNamespace(ctx=SimpleNamespace()) + + await plugin._before_server_start(app) # pyright: ignore[reportPrivateUsage] + await plugin._after_server_stop(app) # pyright: ignore[reportPrivateUsage] + + config.create_pool.assert_called_once_with() + config.close_pool.assert_called_once_with() + assert not has_context_value(app.ctx, "db_pool") + + +def test_init_app_registers_server_lifecycle_listeners() -> None: + """init_app should register Sanic startup and shutdown listeners.""" + app = Sanic(f"SQLSpecLifecycle{uuid4().hex}") + plugin = SQLSpecPlugin(MagicMock(configs={})) + + plugin.init_app(app) + + assert "server.init.before" in app.signal_router.name_index + assert "server.shutdown.after" in app.signal_router.name_index + + +def _make_config(*, pool: object) -> MagicMock: + config = MagicMock() + config.supports_connection_pooling = True + config.extension_config = {"sanic": {}} + config.create_pool = AsyncMock(return_value=pool) + config.close_pool = AsyncMock() + return config From ea5287bd5bffc9feaeeaa43a1f93695ff9326896 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 26 Apr 2026 02:44:24 +0000 Subject: [PATCH 09/14] feat(sanic): manage request transactions --- sqlspec/extensions/sanic/extension.py | 162 +++++++++++++- .../extensions/test_sanic/test_middleware.py | 203 ++++++++++++++++++ 2 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 tests/unit/extensions/test_sanic/test_middleware.py diff --git a/sqlspec/extensions/sanic/extension.py b/sqlspec/extensions/sanic/extension.py index fad1d636..ef5903b8 100644 --- a/sqlspec/extensions/sanic/extension.py +++ b/sqlspec/extensions/sanic/extension.py @@ -12,7 +12,7 @@ set_context_value, ) from sqlspec.utils.logging import get_logger, log_with_context -from sqlspec.utils.sync_tools import ensure_async_ +from sqlspec.utils.sync_tools import ensure_async_, with_ensure_async_ if TYPE_CHECKING: from sanic import Sanic @@ -25,6 +25,9 @@ DEFAULT_CONNECTION_KEY = "db_connection" DEFAULT_POOL_KEY = "db_pool" DEFAULT_SESSION_KEY = "db_session" +HTTP_200_OK = 200 +HTTP_300_MULTIPLE_CHOICES = 300 +HTTP_400_BAD_REQUEST = 400 class SQLSpecPlugin: @@ -56,7 +59,7 @@ class SQLSpecPlugin: db_ext = SQLSpecPlugin(sqlspec, app) """ - __slots__ = ("_config_states", "_lifecycle_listeners_added", "_sqlspec") + __slots__ = ("_config_states", "_lifecycle_listeners_added", "_request_middleware_added", "_sqlspec") def __init__(self, sqlspec: SQLSpec, app: "Sanic[Any, Any] | None" = None) -> None: """Initialize SQLSpec Sanic extension. @@ -68,6 +71,7 @@ def __init__(self, sqlspec: SQLSpec, app: "Sanic[Any, Any] | None" = None) -> No self._sqlspec = sqlspec self._config_states: list[SanicConfigState] = [] self._lifecycle_listeners_added = False + self._request_middleware_added = False for cfg in self._sqlspec.configs.values(): settings = self._extract_sanic_settings(cfg) @@ -161,6 +165,7 @@ def init_app(self, app: "Sanic[Any, Any]") -> None: self._validate_unique_keys() setattr(app.ctx, "sqlspec_plugin", self) self._add_lifecycle_listeners(app) + self._add_request_middleware(app) def _add_lifecycle_listeners(self, app: "Sanic[Any, Any]") -> None: """Register Sanic server lifecycle listeners. @@ -175,6 +180,159 @@ def _add_lifecycle_listeners(self, app: "Sanic[Any, Any]") -> None: app.after_server_stop(self._after_server_stop) self._lifecycle_listeners_added = True + def _add_request_middleware(self, app: "Sanic[Any, Any]") -> None: + """Register Sanic request and response middleware. + + Args: + app: Sanic application instance. + """ + if self._request_middleware_added or all(state.disable_di for state in self._config_states): + return + + app.on_request(self._on_request) + app.on_response(self._on_response) + self._request_middleware_added = True + + async def _on_request(self, request: Any) -> None: + """Acquire request-scoped connections. + + Args: + request: Sanic request instance. + """ + acquired_states: list[SanicConfigState] = [] + try: + for config_state in self._config_states: + if config_state.disable_di: + continue + await self._acquire_request_connection(request, config_state) + acquired_states.append(config_state) + except Exception: + for config_state in reversed(acquired_states): + await self._release_request_connection(request, config_state) + raise + + async def _on_response(self, request: Any, response: Any) -> None: + """Finalize request-scoped connections. + + Args: + request: Sanic request instance. + response: Sanic response instance. + """ + for config_state in reversed(self._config_states): + if config_state.disable_di: + continue + await self._finalize_request_connection(request, response, config_state) + + async def _acquire_request_connection(self, request: Any, config_state: SanicConfigState) -> None: + """Acquire and store a connection for one config state. + + Args: + request: Sanic request instance. + config_state: Configuration state. + """ + config = config_state.config + + if config.supports_connection_pooling: + pool = get_context_value(request.app.ctx, config_state.pool_key) + connection_manager = with_ensure_async_(config.provide_connection(pool)) # type: ignore[union-attr] + connection = await connection_manager.__aenter__() + set_context_value(request.ctx, self._connection_manager_key(config_state), connection_manager) + else: + connection = await ensure_async_(config.create_connection)() + + set_context_value(request.ctx, config_state.connection_key, connection) + + async def _finalize_request_connection(self, request: Any, response: Any, config_state: SanicConfigState) -> None: + """Commit or rollback, then release one request connection. + + Args: + request: Sanic request instance. + response: Sanic response instance. + config_state: Configuration state. + """ + connection = get_context_value(request.ctx, config_state.connection_key, None) + if connection is None: + return + + try: + if config_state.commit_mode != "manual": + status_code = self._response_status_code(response) + if self._should_commit(config_state, status_code): + await ensure_async_(connection.commit)() + else: + await ensure_async_(connection.rollback)() + finally: + await self._release_request_connection(request, config_state) + + async def _release_request_connection(self, request: Any, config_state: SanicConfigState) -> None: + """Release and clear one request connection. + + Args: + request: Sanic request instance. + config_state: Configuration state. + """ + connection = pop_context_value(request.ctx, config_state.connection_key) + pop_context_value(request.ctx, f"{config_state.session_key}_instance") + + if connection is None: + pop_context_value(request.ctx, self._connection_manager_key(config_state)) + return + + if config_state.config.supports_connection_pooling: + connection_manager = pop_context_value(request.ctx, self._connection_manager_key(config_state)) + if connection_manager is not None: + await connection_manager.__aexit__(None, None, None) + return + + await ensure_async_(connection.close)() + + def _should_commit(self, config_state: SanicConfigState, status_code: int) -> bool: + """Determine whether a response status should commit. + + Args: + config_state: Configuration state. + status_code: HTTP response status. + + Returns: + ``True`` when the transaction should commit. + """ + extra_commit = config_state.extra_commit_statuses or set() + extra_rollback = config_state.extra_rollback_statuses or set() + + if status_code in extra_commit: + return True + if status_code in extra_rollback: + return False + + if HTTP_200_OK <= status_code < HTTP_300_MULTIPLE_CHOICES: + return True + return bool( + config_state.commit_mode == "autocommit_include_redirect" + and HTTP_300_MULTIPLE_CHOICES <= status_code < HTTP_400_BAD_REQUEST + ) + + def _response_status_code(self, response: Any) -> int: + """Return a Sanic response status code. + + Args: + response: Sanic response instance. + + Returns: + HTTP status code. + """ + return int(getattr(response, "status", getattr(response, "status_code", 500))) + + def _connection_manager_key(self, config_state: SanicConfigState) -> str: + """Return the request context key for a connection manager. + + Args: + config_state: Configuration state. + + Returns: + Request context key. + """ + return f"{config_state.connection_key}_context_manager" + async def _before_server_start(self, app: Any, *_: Any) -> None: """Create configured connection pools before the worker starts. diff --git a/tests/unit/extensions/test_sanic/test_middleware.py b/tests/unit/extensions/test_sanic/test_middleware.py new file mode 100644 index 00000000..e3eeba54 --- /dev/null +++ b/tests/unit/extensions/test_sanic/test_middleware.py @@ -0,0 +1,203 @@ +"""Tests for Sanic extension request middleware.""" + +from contextlib import AbstractContextManager +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest + +pytest.importorskip("sanic") + +from sanic import Sanic + +from sqlspec.extensions.sanic import SQLSpecPlugin +from sqlspec.extensions.sanic._utils import has_context_value + + +async def test_request_middleware_acquires_pooled_connection() -> None: + """Request middleware should expose pooled connections on request.ctx.""" + connection = _make_connection() + pool = object() + config = _make_config(connection=connection) + plugin = SQLSpecPlugin(MagicMock(configs={"default": config})) + request = _make_request(pool=pool) + + await plugin._on_request(request) # pyright: ignore[reportPrivateUsage] + + assert request.ctx.db_connection is connection + config.provide_connection.assert_called_once_with(pool) + + +async def test_manual_response_middleware_cleans_connection_without_transaction() -> None: + """Manual mode should release connection state without commit or rollback.""" + connection = _make_connection() + config = _make_config(connection=connection) + plugin = SQLSpecPlugin(MagicMock(configs={"default": config})) + request = _make_request(pool=object()) + + await plugin._on_request(request) # pyright: ignore[reportPrivateUsage] + await plugin._on_response(request, SimpleNamespace(status=200)) # pyright: ignore[reportPrivateUsage] + + connection.commit.assert_not_awaited() + connection.rollback.assert_not_awaited() + assert not has_context_value(request.ctx, "db_connection") + assert not has_context_value(request.ctx, "db_session_instance") + + +async def test_autocommit_response_commits_success_status() -> None: + """Autocommit mode should commit 2xx responses.""" + connection = _make_connection() + config = _make_config(connection=connection, commit_mode="autocommit") + plugin = SQLSpecPlugin(MagicMock(configs={"default": config})) + request = _make_request(pool=object()) + + await plugin._on_request(request) # pyright: ignore[reportPrivateUsage] + await plugin._on_response(request, SimpleNamespace(status=201)) # pyright: ignore[reportPrivateUsage] + + connection.commit.assert_awaited_once_with() + connection.rollback.assert_not_awaited() + + +async def test_autocommit_response_rolls_back_error_status() -> None: + """Autocommit mode should rollback non-success responses.""" + connection = _make_connection() + config = _make_config(connection=connection, commit_mode="autocommit") + plugin = SQLSpecPlugin(MagicMock(configs={"default": config})) + request = _make_request(pool=object()) + + await plugin._on_request(request) # pyright: ignore[reportPrivateUsage] + await plugin._on_response(request, SimpleNamespace(status=500)) # pyright: ignore[reportPrivateUsage] + + connection.commit.assert_not_awaited() + connection.rollback.assert_awaited_once_with() + + +async def test_autocommit_include_redirect_commits_redirect_status() -> None: + """autocommit_include_redirect should commit 3xx responses.""" + connection = _make_connection() + config = _make_config(connection=connection, commit_mode="autocommit_include_redirect") + plugin = SQLSpecPlugin(MagicMock(configs={"default": config})) + request = _make_request(pool=object()) + + await plugin._on_request(request) # pyright: ignore[reportPrivateUsage] + await plugin._on_response(request, SimpleNamespace(status=302)) # pyright: ignore[reportPrivateUsage] + + connection.commit.assert_awaited_once_with() + connection.rollback.assert_not_awaited() + + +async def test_non_pooled_response_closes_connection() -> None: + """Non-pooled configs should create and close one connection per request.""" + connection = _make_connection() + config = _make_config(connection=connection, supports_connection_pooling=False) + plugin = SQLSpecPlugin(MagicMock(configs={"default": config})) + request = _make_request(pool=None) + + await plugin._on_request(request) # pyright: ignore[reportPrivateUsage] + await plugin._on_response(request, SimpleNamespace(status=200)) # pyright: ignore[reportPrivateUsage] + + config.create_connection.assert_awaited_once_with() + connection.close.assert_awaited_once_with() + assert not has_context_value(request.ctx, "db_connection") + + +async def test_request_middleware_wraps_sync_connection_manager() -> None: + """Request middleware should support sync adapter connection managers.""" + connection = _make_connection() + config = _make_config(connection=connection) + manager = _SyncConnectionManager(connection) + config.provide_connection = MagicMock(return_value=manager) + plugin = SQLSpecPlugin(MagicMock(configs={"default": config})) + request = _make_request(pool=object()) + + await plugin._on_request(request) # pyright: ignore[reportPrivateUsage] + await plugin._on_response(request, SimpleNamespace(status=200)) # pyright: ignore[reportPrivateUsage] + + assert manager.entered == 1 + assert manager.exited == 1 + + +def test_init_app_registers_request_middleware() -> None: + """init_app should register request and response middleware when DI is enabled.""" + app = Sanic(f"SQLSpecMiddleware{uuid4().hex}") + config = _make_config(connection=_make_connection()) + plugin = SQLSpecPlugin(MagicMock(configs={"default": config})) + + plugin.init_app(app) + + assert len(app.request_middleware) == 1 + assert len(app.response_middleware) == 1 + + +def test_disable_di_skips_request_middleware_registration() -> None: + """disable_di should leave pool lifecycle intact but skip request management.""" + app = Sanic(f"SQLSpecMiddlewareDisabled{uuid4().hex}") + config = _make_config(connection=_make_connection(), disable_di=True) + plugin = SQLSpecPlugin(MagicMock(configs={"default": config})) + + plugin.init_app(app) + + assert len(app.request_middleware) == 0 + assert len(app.response_middleware) == 0 + assert "server.init.before" in app.signal_router.name_index + + +def _make_connection() -> MagicMock: + connection = MagicMock() + connection.commit = AsyncMock() + connection.rollback = AsyncMock() + connection.close = AsyncMock() + return connection + + +def _make_config( + *, + connection: MagicMock, + commit_mode: str = "manual", + supports_connection_pooling: bool = True, + disable_di: bool = False, +) -> MagicMock: + config = MagicMock() + config.supports_connection_pooling = supports_connection_pooling + config.extension_config = {"sanic": {"commit_mode": commit_mode, "disable_di": disable_di}} + config.provide_connection = MagicMock(return_value=_AsyncConnectionManager(connection)) + config.create_connection = AsyncMock(return_value=connection) + config.statement_config = {} + config.driver_features = {} + return config + + +def _make_request(*, pool: object | None) -> SimpleNamespace: + app_ctx = SimpleNamespace() + if pool is not None: + app_ctx.db_pool = pool + return SimpleNamespace(app=SimpleNamespace(ctx=app_ctx), ctx=SimpleNamespace(db_session_instance=object())) + + +class _AsyncConnectionManager: + def __init__(self, connection: MagicMock) -> None: + self.connection = connection + self.entered = 0 + self.exited = 0 + + async def __aenter__(self) -> MagicMock: + self.entered += 1 + return self.connection + + async def __aexit__(self, *_: object) -> None: + self.exited += 1 + + +class _SyncConnectionManager(AbstractContextManager[MagicMock]): + def __init__(self, connection: MagicMock) -> None: + self.connection = connection + self.entered = 0 + self.exited = 0 + + def __enter__(self) -> MagicMock: + self.entered += 1 + return self.connection + + def __exit__(self, *_: object) -> None: + self.exited += 1 From 38ffeea2960c74db00c7699b9cbef6f2100ba01a Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 26 Apr 2026 02:50:51 +0000 Subject: [PATCH 10/14] feat(sanic): add observability middleware --- sqlspec/extensions/sanic/extension.py | 188 +++++++++++++++++- .../test_sanic/test_observability.py | 102 ++++++++++ 2 files changed, 285 insertions(+), 5 deletions(-) create mode 100644 tests/unit/extensions/test_sanic/test_observability.py diff --git a/sqlspec/extensions/sanic/extension.py b/sqlspec/extensions/sanic/extension.py index ef5903b8..6b5fb729 100644 --- a/sqlspec/extensions/sanic/extension.py +++ b/sqlspec/extensions/sanic/extension.py @@ -2,6 +2,8 @@ from typing import TYPE_CHECKING, Any from sqlspec.base import SQLSpec +from sqlspec.core import CorrelationExtractor +from sqlspec.core.sqlcommenter import SQLCommenterContext from sqlspec.exceptions import ImproperConfigurationError from sqlspec.extensions.sanic._state import SanicConfigState from sqlspec.extensions.sanic._utils import ( @@ -11,6 +13,7 @@ pop_context_value, set_context_value, ) +from sqlspec.utils.correlation import CorrelationContext from sqlspec.utils.logging import get_logger, log_with_context from sqlspec.utils.sync_tools import ensure_async_, with_ensure_async_ @@ -186,7 +189,7 @@ def _add_request_middleware(self, app: "Sanic[Any, Any]") -> None: Args: app: Sanic application instance. """ - if self._request_middleware_added or all(state.disable_di for state in self._config_states): + if self._request_middleware_added or not self._needs_request_middleware(): return app.on_request(self._on_request) @@ -199,6 +202,7 @@ async def _on_request(self, request: Any) -> None: Args: request: Sanic request instance. """ + self._set_observability_contexts(request) acquired_states: list[SanicConfigState] = [] try: for config_state in self._config_states: @@ -209,6 +213,7 @@ async def _on_request(self, request: Any) -> None: except Exception: for config_state in reversed(acquired_states): await self._release_request_connection(request, config_state) + self._restore_observability_contexts(request, None) raise async def _on_response(self, request: Any, response: Any) -> None: @@ -218,10 +223,183 @@ async def _on_response(self, request: Any, response: Any) -> None: request: Sanic request instance. response: Sanic response instance. """ - for config_state in reversed(self._config_states): - if config_state.disable_di: - continue - await self._finalize_request_connection(request, response, config_state) + try: + for config_state in reversed(self._config_states): + if config_state.disable_di: + continue + await self._finalize_request_connection(request, response, config_state) + finally: + self._restore_observability_contexts(request, response) + + def _needs_request_middleware(self) -> bool: + """Return whether this plugin should register request middleware. + + Returns: + ``True`` when connection management or observability middleware is enabled. + """ + return any( + not state.disable_di or state.enable_correlation_middleware or self._state_enables_sqlcommenter(state) + for state in self._config_states + ) + + def _set_observability_contexts(self, request: Any) -> None: + """Set request-scoped observability contexts. + + Args: + request: Sanic request instance. + """ + self._set_correlation_context(request) + self._set_sqlcommenter_context(request) + + def _restore_observability_contexts(self, request: Any, response: Any | None) -> None: + """Restore request-scoped observability contexts. + + Args: + request: Sanic request instance. + response: Sanic response instance, if one is available. + """ + self._restore_sqlcommenter_context(request) + self._restore_correlation_context(request, response) + + def _set_correlation_context(self, request: Any) -> None: + """Set CorrelationContext for this request when enabled. + + Args: + request: Sanic request instance. + """ + config_state = self._first_correlation_state() + if config_state is None: + return + + extractor = CorrelationExtractor( + primary_header=config_state.correlation_header, + additional_headers=config_state.correlation_headers, + auto_trace_headers=config_state.auto_trace_headers, + ) + correlation_id = extractor.extract(lambda header: request.headers.get(header)) + set_context_value(request.ctx, "_sqlspec_previous_correlation_id", CorrelationContext.get()) + set_context_value(request.ctx, "_sqlspec_correlation_id", correlation_id) + set_context_value(request.ctx, "correlation_id", correlation_id) + CorrelationContext.set(correlation_id) + + def _restore_correlation_context(self, request: Any, response: Any | None) -> None: + """Restore CorrelationContext after this request. + + Args: + request: Sanic request instance. + response: Sanic response instance, if one is available. + """ + if not has_context_value(request.ctx, "_sqlspec_previous_correlation_id"): + return + + correlation_id = pop_context_value(request.ctx, "_sqlspec_correlation_id") + if response is not None and correlation_id is not None and hasattr(response, "headers"): + response.headers["X-Correlation-ID"] = correlation_id + + previous = pop_context_value(request.ctx, "_sqlspec_previous_correlation_id") + pop_context_value(request.ctx, "correlation_id") + CorrelationContext.set(previous) + + def _set_sqlcommenter_context(self, request: Any) -> None: + """Set SQLCommenterContext for this request when enabled. + + Args: + request: Sanic request instance. + """ + config_state = self._first_sqlcommenter_state() + if config_state is None: + return + + attrs = {"framework": config_state.sqlcommenter_framework, "route": self._request_route(request)} + action = self._request_action(request) + if action is not None: + attrs["action"] = action + + set_context_value(request.ctx, "_sqlspec_previous_sqlcommenter", SQLCommenterContext.get()) + SQLCommenterContext.set(attrs) + + def _restore_sqlcommenter_context(self, request: Any) -> None: + """Restore SQLCommenterContext after this request. + + Args: + request: Sanic request instance. + """ + if not has_context_value(request.ctx, "_sqlspec_previous_sqlcommenter"): + return + previous = pop_context_value(request.ctx, "_sqlspec_previous_sqlcommenter") + SQLCommenterContext.set(previous) + + def _first_correlation_state(self) -> SanicConfigState | None: + """Return the first config state with correlation enabled. + + Returns: + Matching configuration state, if any. + """ + for config_state in self._config_states: + if config_state.enable_correlation_middleware: + return config_state + return None + + def _first_sqlcommenter_state(self) -> SanicConfigState | None: + """Return the first config state with SQLCommenter enabled. + + Returns: + Matching configuration state, if any. + """ + for config_state in self._config_states: + if self._state_enables_sqlcommenter(config_state): + return config_state + return None + + def _state_enables_sqlcommenter(self, config_state: SanicConfigState) -> bool: + """Return whether one config state enables SQLCommenter middleware. + + Args: + config_state: Configuration state. + + Returns: + ``True`` when SQLCommenter middleware should run. + """ + statement_config = config_state.config.statement_config + return bool( + config_state.enable_sqlcommenter_middleware and getattr(statement_config, "enable_sqlcommenter", False) + ) + + def _request_route(self, request: Any) -> str: + """Return the best available Sanic route template. + + Args: + request: Sanic request instance. + + Returns: + Route template or path. + """ + return str(getattr(request, "uri_template", None) or getattr(request, "path", "")) + + def _request_action(self, request: Any) -> str | None: + """Return the best available Sanic handler action. + + Args: + request: Sanic request instance. + + Returns: + Handler/action name when available. + """ + endpoint = getattr(request, "endpoint", None) + if isinstance(endpoint, str) and endpoint: + return endpoint.rsplit(".", 1)[-1] + if endpoint is not None and hasattr(endpoint, "__name__"): + return endpoint.__name__ + + route = getattr(request, "route", None) + handler = getattr(route, "handler", None) + if handler is not None and hasattr(handler, "__name__"): + return handler.__name__ + + name = getattr(request, "name", None) + if isinstance(name, str) and name: + return name.rsplit(".", 1)[-1] + return None async def _acquire_request_connection(self, request: Any, config_state: SanicConfigState) -> None: """Acquire and store a connection for one config state. diff --git a/tests/unit/extensions/test_sanic/test_observability.py b/tests/unit/extensions/test_sanic/test_observability.py new file mode 100644 index 00000000..e547f196 --- /dev/null +++ b/tests/unit/extensions/test_sanic/test_observability.py @@ -0,0 +1,102 @@ +"""Tests for Sanic correlation and SQLCommenter middleware.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest + +pytest.importorskip("sanic") + +from sanic import Sanic + +from sqlspec.core.sqlcommenter import SQLCommenterContext +from sqlspec.extensions.sanic import SQLSpecPlugin +from sqlspec.utils.correlation import CorrelationContext + + +async def test_correlation_context_is_set_and_restored() -> None: + """Correlation middleware should use request headers and restore prior context.""" + CorrelationContext.set("outer-context") + plugin = SQLSpecPlugin(MagicMock(configs={"default": _make_config(enable_correlation=True)})) + request = _make_request(headers={"x-request-id": "request-123"}) + response = SimpleNamespace(status=500, headers={}) + + try: + await plugin._on_request(request) # pyright: ignore[reportPrivateUsage] + + assert CorrelationContext.get() == "request-123" + assert request.ctx.correlation_id == "request-123" + + await plugin._on_response(request, response) # pyright: ignore[reportPrivateUsage] + + assert response.headers["X-Correlation-ID"] == "request-123" + assert CorrelationContext.get() == "outer-context" + finally: + CorrelationContext.clear() + + +async def test_sqlcommenter_context_is_set_with_sanic_framework_and_restored() -> None: + """SQLCommenter middleware should set Sanic request attributes.""" + previous = {"route": "/outer"} + SQLCommenterContext.set(previous) + plugin = SQLSpecPlugin(MagicMock(configs={"default": _make_config(enable_sqlcommenter=True)})) + request = _make_request(path="/items/1", uri_template="/items/", endpoint="App.get_item") + response = SimpleNamespace(status=200, headers={}) + + try: + await plugin._on_request(request) # pyright: ignore[reportPrivateUsage] + + assert SQLCommenterContext.get() == { + "framework": "sanic", + "route": "/items/", + "action": "get_item", + } + + await plugin._on_response(request, response) # pyright: ignore[reportPrivateUsage] + + assert SQLCommenterContext.get() is previous + finally: + SQLCommenterContext.set(None) + + +def test_observability_registers_middleware_when_di_disabled() -> None: + """disable_di should not suppress enabled observability middleware.""" + app = Sanic(f"SQLSpecObservability{uuid4().hex}") + plugin = SQLSpecPlugin(MagicMock(configs={"default": _make_config(enable_correlation=True)})) + + plugin.init_app(app) + + assert len(app.request_middleware) == 1 + assert len(app.response_middleware) == 1 + + +def _make_config(*, enable_correlation: bool = False, enable_sqlcommenter: bool = False) -> MagicMock: + config = MagicMock() + config.supports_connection_pooling = True + config.extension_config = { + "sanic": { + "disable_di": True, + "enable_correlation_middleware": enable_correlation, + "enable_sqlcommenter_middleware": enable_sqlcommenter, + } + } + config.statement_config = SimpleNamespace(enable_sqlcommenter=enable_sqlcommenter) + return config + + +def _make_request( + *, + headers: dict[str, str] | None = None, + path: str = "/test", + uri_template: str | None = None, + endpoint: str | None = None, +) -> SimpleNamespace: + return SimpleNamespace( + app=SimpleNamespace(ctx=SimpleNamespace()), + ctx=SimpleNamespace(), + endpoint=endpoint, + headers=headers or {}, + path=path, + uri_template=uri_template, + ) From 00bf6393c75ab07c8b3da76159e9c08de57a5eee Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 26 Apr 2026 02:53:44 +0000 Subject: [PATCH 11/14] test(sanic): cover config correlation typing --- sqlspec/config.py | 12 ++++++++++++ tests/unit/extensions/test_sanic/test_extension.py | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/sqlspec/config.py b/sqlspec/config.py index aceb80e2..bcc12d25 100644 --- a/sqlspec/config.py +++ b/sqlspec/config.py @@ -461,6 +461,18 @@ class SanicConfig(TypedDict): managing the database lifecycle manually via their own DI solution. """ + enable_correlation_middleware: NotRequired[bool] + """Enable request correlation ID middleware. Default: False.""" + + correlation_header: NotRequired[str] + """HTTP header to read the request correlation ID from when middleware is enabled. Default: ``X-Request-ID``.""" + + correlation_headers: NotRequired[tuple[str, ...] | list[str]] + """Additional HTTP headers to read as correlation ID fallbacks.""" + + auto_trace_headers: NotRequired[bool] + """Read standard trace context headers as correlation ID fallbacks. Default: True.""" + enable_sqlcommenter_middleware: NotRequired[bool] """Control automatic SQLCommenter middleware registration. Default: True. When the driver's :class:`~sqlspec.core.statement.StatementConfig` has diff --git a/tests/unit/extensions/test_sanic/test_extension.py b/tests/unit/extensions/test_sanic/test_extension.py index 60c2c506..1e901f5c 100644 --- a/tests/unit/extensions/test_sanic/test_extension.py +++ b/tests/unit/extensions/test_sanic/test_extension.py @@ -13,6 +13,10 @@ def test_sanic_config_typing_is_exported() -> None: assert "pool_key" in SanicConfig.__annotations__ assert "session_key" in SanicConfig.__annotations__ assert "commit_mode" in SanicConfig.__annotations__ + assert "enable_correlation_middleware" in SanicConfig.__annotations__ + assert "correlation_header" in SanicConfig.__annotations__ + assert "correlation_headers" in SanicConfig.__annotations__ + assert "auto_trace_headers" in SanicConfig.__annotations__ def test_sanic_public_api_imports_without_sanic_dependency() -> None: From b73ee249e96ff184a106b586632ad58014a25eb7 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 26 Apr 2026 03:04:15 +0000 Subject: [PATCH 12/14] test(sanic): add integration coverage --- .../integration/extensions/sanic/__init__.py | 1 + .../extensions/sanic/test_integration.py | 319 ++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 tests/integration/extensions/sanic/__init__.py create mode 100644 tests/integration/extensions/sanic/test_integration.py diff --git a/tests/integration/extensions/sanic/__init__.py b/tests/integration/extensions/sanic/__init__.py new file mode 100644 index 00000000..459327b6 --- /dev/null +++ b/tests/integration/extensions/sanic/__init__.py @@ -0,0 +1 @@ +"""Sanic extension integration tests.""" diff --git a/tests/integration/extensions/sanic/test_integration.py b/tests/integration/extensions/sanic/test_integration.py new file mode 100644 index 00000000..fae424b7 --- /dev/null +++ b/tests/integration/extensions/sanic/test_integration.py @@ -0,0 +1,319 @@ +"""Integration tests for Sanic extension with real database requests.""" + +import tempfile + +import pytest +from sanic import Request, Sanic, response + +from sqlspec.adapters.aiosqlite import AiosqliteConfig +from sqlspec.adapters.sqlite import SqliteConfig +from sqlspec.base import SQLSpec +from sqlspec.extensions.sanic import SQLSpecPlugin +from sqlspec.utils.correlation import CorrelationContext + +pytestmark = pytest.mark.xdist_group("sqlite") + + +def test_sanic_basic_query() -> None: + """Sanic extension should execute a basic async SQLite query.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=True) as tmp: + sqlspec = SQLSpec() + config = AiosqliteConfig( + connection_config={"database": tmp.name}, + extension_config={"sanic": {"commit_mode": "manual", "session_key": "db"}}, + ) + sqlspec.add_config(config) + plugin = SQLSpecPlugin(sqlspec) + app = Sanic("SQLSpecSanicBasic") + + @app.get("/") + async def handler(request: Request): + db = plugin.get_session(request, "db") + result = await db.execute("SELECT 1 as value") + return response.json({"value": result.get_first()["value"]}) + + plugin.init_app(app) + + _, sanic_response = app.test_client.get("/") + + assert sanic_response.status == 200 + assert sanic_response.json == {"value": 1} + + +def test_sanic_autocommit_commits_success_and_rolls_back_error_status() -> None: + """Autocommit should commit 2xx responses and rollback error responses.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=True) as tmp: + sqlspec = SQLSpec() + config = AiosqliteConfig( + connection_config={"database": tmp.name}, + extension_config={"sanic": {"commit_mode": "autocommit", "session_key": "db"}}, + ) + sqlspec.add_config(config) + plugin = SQLSpecPlugin(sqlspec) + app = Sanic("SQLSpecSanicAutocommit") + + @app.post("/setup") + async def setup(request: Request): + db = plugin.get_session(request, "db") + await db.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)") + await db.execute("INSERT INTO test (name) VALUES (:name)", {"name": "committed"}) + return response.json({"created": True}) + + @app.post("/insert-error") + async def insert_error(request: Request): + db = plugin.get_session(request, "db") + await db.execute("INSERT INTO test (name) VALUES (:name)", {"name": "rolled-back"}) + return response.json({"error": "failed"}, status=500) + + @app.get("/data") + async def data(request: Request): + db = plugin.get_session(request, "db") + result = await db.execute("SELECT name FROM test ORDER BY id") + return response.json({"names": [row["name"] for row in result.all()]}) + + plugin.init_app(app) + + _, sanic_response = app.test_client.post("/setup") + assert sanic_response.status == 200 + + _, sanic_response = app.test_client.post("/insert-error") + assert sanic_response.status == 500 + + _, sanic_response = app.test_client.get("/data") + assert sanic_response.status == 200 + assert sanic_response.json == {"names": ["committed"]} + + +def test_sanic_autocommit_rolls_back_on_exception_response() -> None: + """Exceptions should produce rollback through Sanic's error response path.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=True) as tmp: + sqlspec = SQLSpec() + config = AiosqliteConfig( + connection_config={"database": tmp.name}, + extension_config={"sanic": {"commit_mode": "autocommit", "session_key": "db"}}, + ) + sqlspec.add_config(config) + plugin = SQLSpecPlugin(sqlspec) + app = Sanic("SQLSpecSanicExceptionRollback") + + @app.post("/setup") + async def setup(request: Request): + db = plugin.get_session(request, "db") + await db.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)") + await db.execute("INSERT INTO test (name) VALUES (:name)", {"name": "committed"}) + return response.json({"created": True}) + + @app.post("/explode") + async def explode(request: Request): + db = plugin.get_session(request, "db") + await db.execute("INSERT INTO test (name) VALUES (:name)", {"name": "rolled-back"}) + msg = "request failed" + raise RuntimeError(msg) + + @app.get("/data") + async def data(request: Request): + db = plugin.get_session(request, "db") + result = await db.execute("SELECT name FROM test ORDER BY id") + return response.json({"names": [row["name"] for row in result.all()]}) + + plugin.init_app(app) + + _, sanic_response = app.test_client.post("/setup") + assert sanic_response.status == 200 + + _, sanic_response = app.test_client.post("/explode", debug=False) + assert sanic_response.status == 500 + + _, sanic_response = app.test_client.get("/data") + assert sanic_response.status == 200 + assert sanic_response.json == {"names": ["committed"]} + + +def test_sanic_sync_sqlite_autocommit_commit_and_rollback() -> None: + """Sync SQLite should work through Sanic request middleware.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=True) as tmp: + sqlspec = SQLSpec() + config = SqliteConfig( + connection_config={"database": tmp.name}, + extension_config={"sanic": {"commit_mode": "autocommit", "session_key": "db"}}, + ) + sqlspec.add_config(config) + plugin = SQLSpecPlugin(sqlspec) + app = Sanic("SQLSpecSanicSyncSqlite") + + @app.post("/setup") + async def setup(request: Request): + db = plugin.get_session(request, "db") + db.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)") + db.execute("INSERT INTO test (name) VALUES (?)", ("committed",)) + return response.json({"created": True}) + + @app.post("/insert-error") + async def insert_error(request: Request): + db = plugin.get_session(request, "db") + db.execute("INSERT INTO test (name) VALUES (?)", ("rolled-back",)) + return response.json({"error": "failed"}, status=500) + + @app.get("/data") + async def data(request: Request): + db = plugin.get_session(request, "db") + rows = db.execute("SELECT name FROM test ORDER BY id").all() + return response.json({"names": [row["name"] for row in rows]}) + + plugin.init_app(app) + + _, sanic_response = app.test_client.post("/setup") + assert sanic_response.status == 200 + + _, sanic_response = app.test_client.post("/insert-error") + assert sanic_response.status == 500 + + _, sanic_response = app.test_client.get("/data") + assert sanic_response.status == 200 + assert sanic_response.json == {"names": ["committed"]} + + +def test_sanic_multi_database_sessions() -> None: + """Sanic plugin should support multiple configured databases.""" + with ( + tempfile.NamedTemporaryFile(suffix=".db", delete=True) as users_tmp, + tempfile.NamedTemporaryFile(suffix=".db", delete=True) as products_tmp, + ): + sqlspec = SQLSpec() + users_config = AiosqliteConfig( + bind_key="users", + connection_config={"database": users_tmp.name}, + extension_config={ + "sanic": { + "commit_mode": "autocommit", + "connection_key": "users_connection", + "pool_key": "users_pool", + "session_key": "users_db", + } + }, + ) + products_config = AiosqliteConfig( + bind_key="products", + connection_config={"database": products_tmp.name}, + extension_config={ + "sanic": { + "commit_mode": "autocommit", + "connection_key": "products_connection", + "pool_key": "products_pool", + "session_key": "products_db", + } + }, + ) + sqlspec.add_config(users_config) + sqlspec.add_config(products_config) + plugin = SQLSpecPlugin(sqlspec) + app = Sanic("SQLSpecSanicMultiDatabase") + + @app.post("/setup") + async def setup(request: Request): + users_db = plugin.get_session(request, "users_db") + products_db = plugin.get_session(request, "products_db") + await users_db.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)") + await products_db.execute("CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT)") + await users_db.execute("INSERT INTO users (name) VALUES (:name)", {"name": "Alice"}) + await products_db.execute("INSERT INTO products (name) VALUES (:name)", {"name": "Widget"}) + return response.json({"created": True}) + + @app.get("/counts") + async def counts(request: Request): + users_db = plugin.get_session(request, "users_db") + products_db = plugin.get_session(request, "products_db") + users_count = await users_db.select_value("SELECT COUNT(*) FROM users") + products_count = await products_db.select_value("SELECT COUNT(*) FROM products") + return response.json({"users": users_count, "products": products_count}) + + plugin.init_app(app) + + _, sanic_response = app.test_client.post("/setup") + assert sanic_response.status == 200 + + _, sanic_response = app.test_client.get("/counts") + assert sanic_response.status == 200 + assert sanic_response.json == {"users": 1, "products": 1} + + +def test_sanic_session_caching() -> None: + """Sanic plugin should cache sessions within one request.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=True) as tmp: + sqlspec = SQLSpec() + config = AiosqliteConfig( + connection_config={"database": tmp.name}, + extension_config={"sanic": {"commit_mode": "manual", "session_key": "db"}}, + ) + sqlspec.add_config(config) + plugin = SQLSpecPlugin(sqlspec) + app = Sanic("SQLSpecSanicSessionCaching") + + @app.get("/") + async def handler(request: Request): + session_one = plugin.get_session(request, "db") + session_two = plugin.get_session(request, "db") + return response.json({"same_session": session_one is session_two}) + + plugin.init_app(app) + + _, sanic_response = app.test_client.get("/") + + assert sanic_response.status == 200 + assert sanic_response.json == {"same_session": True} + + +def test_sanic_disable_di_preserves_pool_lifecycle() -> None: + """disable_di should skip request management while preserving app.ctx pools.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=True) as tmp: + sqlspec = SQLSpec() + config = AiosqliteConfig( + connection_config={"database": tmp.name}, + extension_config={"sanic": {"disable_di": True, "pool_key": "manual_pool"}}, + ) + sqlspec.add_config(config) + plugin = SQLSpecPlugin(sqlspec) + app = Sanic("SQLSpecSanicDisableDI") + + @app.get("/") + async def handler(request: Request): + pool = request.app.ctx.manual_pool + async with config.provide_connection(pool) as connection: + db = config.driver_type(connection=connection, statement_config=config.statement_config) + result = await db.execute("SELECT 1 as value") + return response.json({"value": result.get_first()["value"]}) + + plugin.init_app(app) + + _, sanic_response = app.test_client.get("/") + + assert sanic_response.status == 200 + assert sanic_response.json == {"value": 1} + + +def test_sanic_correlation_header_round_trip() -> None: + """Sanic correlation middleware should propagate request correlation IDs.""" + sqlspec = SQLSpec() + config = SqliteConfig( + connection_config={"database": ":memory:"}, + extension_config={"sanic": {"disable_di": True, "enable_correlation_middleware": True}}, + ) + sqlspec.add_config(config) + plugin = SQLSpecPlugin(sqlspec) + app = Sanic("SQLSpecSanicCorrelation") + seen_context: list[str | None] = [] + + @app.get("/") + async def handler(request: Request): + seen_context.append(CorrelationContext.get()) + return response.json({"correlation_id": request.ctx.correlation_id}) + + plugin.init_app(app) + + _, sanic_response = app.test_client.get("/", headers={"x-request-id": "sanic-cid"}) + + assert sanic_response.status == 200 + assert sanic_response.json == {"correlation_id": "sanic-cid"} + assert sanic_response.headers["X-Correlation-ID"] == "sanic-cid" + assert seen_context == ["sanic-cid"] + assert CorrelationContext.get() is None From 50865196fcbfab4a63849e9391470c25e8fea880 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 26 Apr 2026 03:12:05 +0000 Subject: [PATCH 13/14] docs(sanic): add framework integration guide --- README.md | 4 +- docs/PYPI_README.md | 4 +- docs/examples/README.md | 2 +- docs/examples/frameworks/sanic/__init__.py | 1 + docs/examples/frameworks/sanic/basic_setup.py | 37 +++++ .../examples/frameworks/sanic/commit_modes.py | 39 +++++ docs/examples/frameworks/sanic/disable_di.py | 37 +++++ .../frameworks/sanic/multi_database.py | 60 +++++++ .../frameworks/sanic/observability.py | 43 +++++ docs/reference/extensions/index.rst | 9 +- docs/reference/extensions/sanic.rst | 28 ++++ docs/usage/framework_integrations.rst | 7 + docs/usage/frameworks/sanic.rst | 150 ++++++++++++++++++ docs/usage/index.rst | 4 +- 14 files changed, 417 insertions(+), 8 deletions(-) create mode 100644 docs/examples/frameworks/sanic/__init__.py create mode 100644 docs/examples/frameworks/sanic/basic_setup.py create mode 100644 docs/examples/frameworks/sanic/commit_modes.py create mode 100644 docs/examples/frameworks/sanic/disable_di.py create mode 100644 docs/examples/frameworks/sanic/multi_database.py create mode 100644 docs/examples/frameworks/sanic/observability.py create mode 100644 docs/reference/extensions/sanic.rst create mode 100644 docs/usage/frameworks/sanic.rst diff --git a/README.md b/README.md index 13b4b6e5..53a219be 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ SQLSpec is a SQL execution layer for Python. You write the SQL -- as strings, through a builder API, or loaded from files -- and SQLSpec handles connections, parameter binding, SQL injection prevention, dialect translation, and mapping results back to typed Python objects. It uses [sqlglot](https://github.com/tobymao/sqlglot) under the hood to parse, validate, and optimize your queries before they hit the database. -It works with PostgreSQL (asyncpg, psycopg, psqlpy), SQLite (sqlite3, aiosqlite), DuckDB, MySQL (asyncmy, mysql-connector, pymysql), Oracle (oracledb), CockroachDB, BigQuery, Spanner, and anything ADBC-compatible. Sync or async, same API. It also includes a built-in storage layer, native and bridged Arrow support for all drivers, and integrations for Litestar, FastAPI, Flask, and Starlette. +It works with PostgreSQL (asyncpg, psycopg, psqlpy), SQLite (sqlite3, aiosqlite), DuckDB, MySQL (asyncmy, mysql-connector, pymysql), Oracle (oracledb), CockroachDB, BigQuery, Spanner, and anything ADBC-compatible. Sync or async, same API. It also includes a built-in storage layer, native and bridged Arrow support for all drivers, and integrations for Litestar, FastAPI, Flask, Sanic, and Starlette. ## Quick Start @@ -57,7 +57,7 @@ users = session.select( - **Parameter binding and dialect translation** -- powered by sqlglot, with a fluent query builder and `.sql` file loader - **Result mapping** -- map rows to Pydantic, msgspec, attrs, or dataclass models, or export to Arrow tables for pandas and Polars - **Storage layer** -- read and write Arrow tables to local files, fsspec, or object stores -- **Framework integrations** -- Litestar plugin with DI, Starlette/FastAPI middleware, Flask extension +- **Framework integrations** -- Litestar plugin with DI, Starlette/FastAPI/Sanic middleware, Flask extension - **Observability** -- OpenTelemetry and Prometheus instrumentation, structured logging with correlation IDs - **Event channels** -- LISTEN/NOTIFY, Oracle AQ, and a portable polling fallback - **Migrations** -- schema versioning CLI built on Alembic diff --git a/docs/PYPI_README.md b/docs/PYPI_README.md index 13b4b6e5..53a219be 100644 --- a/docs/PYPI_README.md +++ b/docs/PYPI_README.md @@ -7,7 +7,7 @@ SQLSpec is a SQL execution layer for Python. You write the SQL -- as strings, through a builder API, or loaded from files -- and SQLSpec handles connections, parameter binding, SQL injection prevention, dialect translation, and mapping results back to typed Python objects. It uses [sqlglot](https://github.com/tobymao/sqlglot) under the hood to parse, validate, and optimize your queries before they hit the database. -It works with PostgreSQL (asyncpg, psycopg, psqlpy), SQLite (sqlite3, aiosqlite), DuckDB, MySQL (asyncmy, mysql-connector, pymysql), Oracle (oracledb), CockroachDB, BigQuery, Spanner, and anything ADBC-compatible. Sync or async, same API. It also includes a built-in storage layer, native and bridged Arrow support for all drivers, and integrations for Litestar, FastAPI, Flask, and Starlette. +It works with PostgreSQL (asyncpg, psycopg, psqlpy), SQLite (sqlite3, aiosqlite), DuckDB, MySQL (asyncmy, mysql-connector, pymysql), Oracle (oracledb), CockroachDB, BigQuery, Spanner, and anything ADBC-compatible. Sync or async, same API. It also includes a built-in storage layer, native and bridged Arrow support for all drivers, and integrations for Litestar, FastAPI, Flask, Sanic, and Starlette. ## Quick Start @@ -57,7 +57,7 @@ users = session.select( - **Parameter binding and dialect translation** -- powered by sqlglot, with a fluent query builder and `.sql` file loader - **Result mapping** -- map rows to Pydantic, msgspec, attrs, or dataclass models, or export to Arrow tables for pandas and Polars - **Storage layer** -- read and write Arrow tables to local files, fsspec, or object stores -- **Framework integrations** -- Litestar plugin with DI, Starlette/FastAPI middleware, Flask extension +- **Framework integrations** -- Litestar plugin with DI, Starlette/FastAPI/Sanic middleware, Flask extension - **Observability** -- OpenTelemetry and Prometheus instrumentation, structured logging with correlation IDs - **Event channels** -- LISTEN/NOTIFY, Oracle AQ, and a portable polling fallback - **Migrations** -- schema versioning CLI built on Alembic diff --git a/docs/examples/README.md b/docs/examples/README.md index 260aaa0a..5ace7702 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -7,7 +7,7 @@ markers for Sphinx `literalinclude` directives. Structure overview: - `quickstart/`: First-time setup and configuration. -- `frameworks/`: Litestar, FastAPI, Flask, and Starlette integration examples. +- `frameworks/`: Litestar, FastAPI, Flask, Sanic, and Starlette integration examples. - `drivers/`: Adapter configuration and execution patterns. - `querying/`: Core SQL execution helpers. - `sql_files/`: SQL file loader and named query examples. diff --git a/docs/examples/frameworks/sanic/__init__.py b/docs/examples/frameworks/sanic/__init__.py new file mode 100644 index 00000000..68153289 --- /dev/null +++ b/docs/examples/frameworks/sanic/__init__.py @@ -0,0 +1 @@ +"""Sanic framework examples.""" diff --git a/docs/examples/frameworks/sanic/basic_setup.py b/docs/examples/frameworks/sanic/basic_setup.py new file mode 100644 index 00000000..c904d65d --- /dev/null +++ b/docs/examples/frameworks/sanic/basic_setup.py @@ -0,0 +1,37 @@ +import pytest + +__all__ = ("test_sanic_basic_setup",) + + +def test_sanic_basic_setup() -> None: + pytest.importorskip("sanic") + pytest.importorskip("aiosqlite") + # start-example + from sanic import Request, Sanic, response + from sanic.response import HTTPResponse + + from sqlspec import SQLSpec + from sqlspec.adapters.aiosqlite import AiosqliteConfig, AiosqliteDriver + from sqlspec.extensions.sanic import SQLSpecPlugin + + sqlspec = SQLSpec() + sqlspec.add_config( + AiosqliteConfig( + connection_config={"database": ":memory:"}, + extension_config={"sanic": {"commit_mode": "manual", "session_key": "db"}}, + ) + ) + + db_plugin = SQLSpecPlugin(sqlspec) + app = Sanic("SQLSpecSanicBasicExample") + + @app.get("/health") + async def health(request: Request) -> HTTPResponse: + db: AiosqliteDriver = db_plugin.get_session(request, "db") + result = await db.execute("select 1 as ok") + return response.json(result.one()) + + db_plugin.init_app(app) + # end-example + + assert app is not None diff --git a/docs/examples/frameworks/sanic/commit_modes.py b/docs/examples/frameworks/sanic/commit_modes.py new file mode 100644 index 00000000..61d09d3e --- /dev/null +++ b/docs/examples/frameworks/sanic/commit_modes.py @@ -0,0 +1,39 @@ +import pytest + +__all__ = ("test_sanic_commit_modes",) + + +def test_sanic_commit_modes() -> None: + pytest.importorskip("sanic") + pytest.importorskip("aiosqlite") + # start-example + from sanic import Request, Sanic, response + from sanic.response import HTTPResponse + + from sqlspec import SQLSpec + from sqlspec.adapters.aiosqlite import AiosqliteConfig + from sqlspec.extensions.sanic import SQLSpecPlugin + + sqlspec = SQLSpec() + sqlspec.add_config( + AiosqliteConfig( + connection_config={"database": "app.db"}, + extension_config={ + "sanic": {"commit_mode": "autocommit", "extra_rollback_statuses": {409}, "session_key": "db"} + }, + ) + ) + + db_plugin = SQLSpecPlugin(sqlspec) + app = Sanic("SQLSpecSanicCommitModesExample") + + @app.post("/users") + async def create_user(request: Request) -> HTTPResponse: + db = db_plugin.get_session(request, "db") + await db.execute("insert into users (name) values (:name)", {"name": request.json["name"]}) + return response.json({"created": True}, status=201) + + db_plugin.init_app(app) + # end-example + + assert app is not None diff --git a/docs/examples/frameworks/sanic/disable_di.py b/docs/examples/frameworks/sanic/disable_di.py new file mode 100644 index 00000000..4d2f269f --- /dev/null +++ b/docs/examples/frameworks/sanic/disable_di.py @@ -0,0 +1,37 @@ +import pytest + +__all__ = ("test_sanic_disable_di",) + + +def test_sanic_disable_di() -> None: + pytest.importorskip("sanic") + pytest.importorskip("aiosqlite") + # start-example + from sanic import Request, Sanic, response + from sanic.response import HTTPResponse + + from sqlspec import SQLSpec + from sqlspec.adapters.aiosqlite import AiosqliteConfig + from sqlspec.extensions.sanic import SQLSpecPlugin + + sqlspec = SQLSpec() + config = AiosqliteConfig( + connection_config={"database": "app.db"}, + extension_config={"sanic": {"disable_di": True, "pool_key": "db_pool"}}, + ) + sqlspec.add_config(config) + + db_plugin = SQLSpecPlugin(sqlspec) + app = Sanic("SQLSpecSanicDisableDIExample") + + @app.get("/health") + async def health(request: Request) -> HTTPResponse: + async with config.provide_connection(request.app.ctx.db_pool) as connection: + db = config.driver_type(connection=connection, statement_config=config.statement_config) + result = await db.execute("select 1 as ok") + return response.json(result.one()) + + db_plugin.init_app(app) + # end-example + + assert app is not None diff --git a/docs/examples/frameworks/sanic/multi_database.py b/docs/examples/frameworks/sanic/multi_database.py new file mode 100644 index 00000000..c6ef8104 --- /dev/null +++ b/docs/examples/frameworks/sanic/multi_database.py @@ -0,0 +1,60 @@ +import pytest + +__all__ = ("test_sanic_multi_database",) + + +def test_sanic_multi_database() -> None: + pytest.importorskip("sanic") + pytest.importorskip("aiosqlite") + # start-example + from sanic import Request, Sanic, response + from sanic.response import HTTPResponse + + from sqlspec import SQLSpec + from sqlspec.adapters.aiosqlite import AiosqliteConfig, AiosqliteDriver + from sqlspec.adapters.sqlite import SqliteConfig, SqliteDriver + from sqlspec.extensions.sanic import SQLSpecPlugin + + sqlspec = SQLSpec() + sqlspec.add_config( + AiosqliteConfig( + bind_key="primary", + connection_config={"database": "primary.db"}, + extension_config={ + "sanic": { + "connection_key": "primary_connection", + "pool_key": "primary_pool", + "session_key": "primary_db", + } + }, + ) + ) + sqlspec.add_config( + SqliteConfig( + bind_key="analytics", + connection_config={"database": "analytics.db"}, + extension_config={ + "sanic": { + "connection_key": "analytics_connection", + "pool_key": "analytics_pool", + "session_key": "analytics_db", + } + }, + ) + ) + + db_plugin = SQLSpecPlugin(sqlspec) + app = Sanic("SQLSpecSanicMultiDatabaseExample") + + @app.get("/report") + async def report(request: Request) -> HTTPResponse: + primary: AiosqliteDriver = db_plugin.get_session(request, "primary_db") + analytics: SqliteDriver = db_plugin.get_session(request, "analytics_db") + users = await primary.select("select 1 as id, 'Alice' as name") + metrics = analytics.select("select 'active_users' as name, 100 as value") + return response.json({"users": users, "metrics": metrics}) + + db_plugin.init_app(app) + # end-example + + assert app is not None diff --git a/docs/examples/frameworks/sanic/observability.py b/docs/examples/frameworks/sanic/observability.py new file mode 100644 index 00000000..ddeeabef --- /dev/null +++ b/docs/examples/frameworks/sanic/observability.py @@ -0,0 +1,43 @@ +import pytest + +__all__ = ("test_sanic_observability",) + + +def test_sanic_observability() -> None: + pytest.importorskip("sanic") + pytest.importorskip("aiosqlite") + # start-example + from sanic import Request, Sanic, response + from sanic.response import HTTPResponse + + from sqlspec import SQLSpec + from sqlspec.adapters.aiosqlite import AiosqliteConfig + from sqlspec.core import StatementConfig + from sqlspec.extensions.sanic import SQLSpecPlugin + + sqlspec = SQLSpec() + sqlspec.add_config( + AiosqliteConfig( + connection_config={"database": "app.db"}, + statement_config=StatementConfig(enable_sqlcommenter=True), + extension_config={ + "sanic": { + "enable_correlation_middleware": True, + "enable_sqlcommenter_middleware": True, + "session_key": "db", + } + }, + ) + ) + + db_plugin = SQLSpecPlugin(sqlspec) + app = Sanic("SQLSpecSanicObservabilityExample") + + @app.get("/health") + async def health(request: Request) -> HTTPResponse: + return response.json({"correlation_id": request.ctx.correlation_id}) + + db_plugin.init_app(app) + # end-example + + assert app is not None diff --git a/docs/reference/extensions/index.rst b/docs/reference/extensions/index.rst index 21c98370..5ddc3663 100644 --- a/docs/reference/extensions/index.rst +++ b/docs/reference/extensions/index.rst @@ -3,7 +3,7 @@ Extensions ========== SQLSpec extensions integrate the registry with frameworks, event systems, -and services such as Litestar, FastAPI, Flask, Starlette, and Google ADK. +and services such as Litestar, FastAPI, Flask, Sanic, Starlette, and Google ADK. .. grid:: 2 @@ -31,6 +31,12 @@ and services such as Litestar, FastAPI, Flask, Starlette, and Google ADK. Middleware-based session management and connection pooling lifecycle. + .. grid-item-card:: Sanic + :link: sanic + :link-type: doc + + App/request context integration with lifecycle listeners and middleware. + .. grid-item-card:: Google ADK :link: adk :link-type: doc @@ -49,6 +55,7 @@ and services such as Litestar, FastAPI, Flask, Starlette, and Google ADK. litestar fastapi flask + sanic starlette adk events diff --git a/docs/reference/extensions/sanic.rst b/docs/reference/extensions/sanic.rst new file mode 100644 index 00000000..4edf938a --- /dev/null +++ b/docs/reference/extensions/sanic.rst @@ -0,0 +1,28 @@ +===== +Sanic +===== + +Sanic extension providing app/request context integration, request-scoped +session management, transaction handling, correlation IDs, SQLCommenter, and +connection pool lifecycle management. + +Plugin +====== + +.. autoclass:: sqlspec.extensions.sanic.SQLSpecPlugin + :members: + :show-inheritance: + +State +===== + +.. autoclass:: sqlspec.extensions.sanic.SanicConfigState + :members: + :show-inheritance: + +Helpers +======= + +.. autofunction:: sqlspec.extensions.sanic.get_connection_from_request + +.. autofunction:: sqlspec.extensions.sanic.get_or_create_session diff --git a/docs/usage/framework_integrations.rst b/docs/usage/framework_integrations.rst index b848f4a9..d2e48e9a 100644 --- a/docs/usage/framework_integrations.rst +++ b/docs/usage/framework_integrations.rst @@ -41,6 +41,12 @@ Supported frameworks :align: center :alt: Flask + .. grid-item-card:: Sanic + :link: frameworks/sanic + :link-type: doc + + Sanic-native ``app.ctx`` pools and ``request.ctx`` sessions. + .. grid-item-card:: Starlette :link: frameworks/starlette :link-type: doc @@ -65,4 +71,5 @@ Supported frameworks frameworks/litestar/index frameworks/fastapi frameworks/flask + frameworks/sanic frameworks/starlette diff --git a/docs/usage/frameworks/sanic.rst b/docs/usage/frameworks/sanic.rst new file mode 100644 index 00000000..62b08d9f --- /dev/null +++ b/docs/usage/frameworks/sanic.rst @@ -0,0 +1,150 @@ +===== +Sanic +===== + +SQLSpec provides a Sanic extension that uses Sanic-native application and +request context. Connection pools are stored on ``app.ctx`` during the worker +lifecycle, and request-scoped connections and sessions are stored on +``request.ctx``. + +Installation +============ + +Install SQLSpec with the Sanic extra: + +.. tab-set:: + + .. tab-item:: uv + + .. code-block:: bash + + uv add "sqlspec[sanic]" + + .. tab-item:: pip + + .. code-block:: bash + + pip install "sqlspec[sanic]" + + .. tab-item:: Poetry + + .. code-block:: bash + + poetry add "sqlspec[sanic]" + + .. tab-item:: PDM + + .. code-block:: bash + + pdm add "sqlspec[sanic]" + +Basic Setup +=========== + +Create a SQLSpec instance, register your database config, and attach +``SQLSpecPlugin`` to your Sanic app. Configure Sanic-specific options under +``extension_config["sanic"]``. + +.. literalinclude:: /examples/frameworks/sanic/basic_setup.py + :language: python + :caption: ``sanic basic setup`` + :start-after: # start-example + :end-before: # end-example + :dedent: 4 + :no-upgrade: + +Transaction Modes +================= + +Sanic supports the same transaction modes as the other request-oriented +framework integrations: + +``manual`` + SQLSpec manages connection scope only. Your handler commits or rolls back. + +``autocommit`` + SQLSpec commits 2xx responses and rolls back other responses. + +``autocommit_include_redirect`` + SQLSpec commits 2xx and 3xx responses and rolls back other responses. + +Extra commit and rollback statuses are configured per database config. + +.. literalinclude:: /examples/frameworks/sanic/commit_modes.py + :language: python + :caption: ``sanic commit modes`` + :start-after: # start-example + :end-before: # end-example + :dedent: 4 + :no-upgrade: + +Multiple Databases +================== + +For multiple databases, give each config unique ``connection_key``, +``pool_key``, and ``session_key`` values. The plugin can then look up sessions +by session key. + +.. literalinclude:: /examples/frameworks/sanic/multi_database.py + :language: python + :caption: ``sanic multi database`` + :start-after: # start-example + :end-before: # end-example + :dedent: 4 + :no-upgrade: + +Request Access +============== + +Use ``plugin.get_session(request, key=None)`` for driver sessions and +``plugin.get_connection(request, key=None)`` for raw connection access. The +default key is the first registered config's ``session_key``; passing a key is +recommended when an app has multiple configs. + +``get_connection_from_request(request, config_state)`` and +``get_or_create_session(request, config_state)`` are lower-level helpers for +custom integrations. + +disable_di +========== + +Set ``disable_di=True`` when another dependency injection system owns +request-scoped connection management. SQLSpec still creates and closes pools +on Sanic startup and shutdown, but it does not put connections or sessions on +``request.ctx``. + +.. literalinclude:: /examples/frameworks/sanic/disable_di.py + :language: python + :caption: ``sanic disable di`` + :start-after: # start-example + :end-before: # end-example + :dedent: 4 + :no-upgrade: + +Correlation IDs and SQLCommenter +================================ + +Set ``enable_correlation_middleware=True`` to extract request correlation IDs +from headers and return ``X-Correlation-ID`` on the response. The active value +is also available from ``request.ctx.correlation_id`` and +``CorrelationContext`` during the request. + +SQLCommenter is enabled when the driver ``StatementConfig`` has +``enable_sqlcommenter=True`` and the Sanic config keeps +``enable_sqlcommenter_middleware=True``. Request attributes include +``framework="sanic"``, route, and action. + +.. literalinclude:: /examples/frameworks/sanic/observability.py + :language: python + :caption: ``sanic observability`` + :start-after: # start-example + :end-before: # end-example + :dedent: 4 + :no-upgrade: + +Related Guides +============== + +- :doc:`/usage/configuration` for detailed config options. +- :doc:`/usage/observability` for correlation IDs and SQLCommenter. +- :doc:`/reference/adapters` for adapter-specific settings. diff --git a/docs/usage/index.rst b/docs/usage/index.rst index d19c9c55..c5675774 100644 --- a/docs/usage/index.rst +++ b/docs/usage/index.rst @@ -58,7 +58,7 @@ Choose a topic :link: framework_integrations :link-type: doc - Plug SQLSpec into Litestar, FastAPI, Flask, or Starlette. + Plug SQLSpec into Litestar, FastAPI, Flask, Sanic, or Starlette. .. grid-item-card:: Observability :link: observability @@ -98,7 +98,7 @@ Recommended Path 3. **Execute queries** using :doc:`drivers_and_querying` for transaction and parameter patterns. 4. **Build queries safely** with the :doc:`query_builder` for programmatic SQL construction. 5. **Organize SQL** in files using :doc:`sql_files` when your project grows. -6. **Integrate with your framework** via :doc:`framework_integrations` for Litestar, FastAPI, Flask, or Starlette. +6. **Integrate with your framework** via :doc:`framework_integrations` for Litestar, FastAPI, Flask, Sanic, or Starlette. .. toctree:: :hidden: From c0a55e2352155867c171c922ed3bce32d6596ba8 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 26 Apr 2026 03:58:24 +0000 Subject: [PATCH 14/14] chore(frameworks): clean up quality gate typing --- sqlspec/extensions/fastapi/extension.py | 2 +- sqlspec/extensions/sanic/extension.py | 14 ++++++++------ sqlspec/extensions/starlette/extension.py | 6 +++--- sqlspec/extensions/starlette/middleware.py | 4 ++-- .../extensions/sanic/test_integration.py | 4 +++- tests/unit/test_package_metadata.py | 5 +++-- 6 files changed, 20 insertions(+), 15 deletions(-) diff --git a/sqlspec/extensions/fastapi/extension.py b/sqlspec/extensions/fastapi/extension.py index 06669034..a672f8e3 100644 --- a/sqlspec/extensions/fastapi/extension.py +++ b/sqlspec/extensions/fastapi/extension.py @@ -71,7 +71,7 @@ def __init__(self, sqlspec: SQLSpec, app: "FastAPI | None" = None) -> None: """ super().__init__(sqlspec, app) - def _extract_starlette_settings(self, config: Any) -> "dict[str, Any]": + def _extract_extension_settings(self, config: Any) -> "dict[str, Any]": """Extract FastAPI settings from config.extension_config. Args: diff --git a/sqlspec/extensions/sanic/extension.py b/sqlspec/extensions/sanic/extension.py index 6b5fb729..fa8257ff 100644 --- a/sqlspec/extensions/sanic/extension.py +++ b/sqlspec/extensions/sanic/extension.py @@ -193,7 +193,7 @@ def _add_request_middleware(self, app: "Sanic[Any, Any]") -> None: return app.on_request(self._on_request) - app.on_response(self._on_response) + app.on_response(self._on_response) # type: ignore[no-untyped-call] self._request_middleware_added = True async def _on_request(self, request: Any) -> None: @@ -388,13 +388,15 @@ def _request_action(self, request: Any) -> str | None: endpoint = getattr(request, "endpoint", None) if isinstance(endpoint, str) and endpoint: return endpoint.rsplit(".", 1)[-1] - if endpoint is not None and hasattr(endpoint, "__name__"): - return endpoint.__name__ + endpoint_name = getattr(endpoint, "__name__", None) + if isinstance(endpoint_name, str) and endpoint_name: + return endpoint_name route = getattr(request, "route", None) handler = getattr(route, "handler", None) - if handler is not None and hasattr(handler, "__name__"): - return handler.__name__ + handler_name = getattr(handler, "__name__", None) + if isinstance(handler_name, str) and handler_name: + return handler_name name = getattr(request, "name", None) if isinstance(name, str) and name: @@ -412,7 +414,7 @@ async def _acquire_request_connection(self, request: Any, config_state: SanicCon if config.supports_connection_pooling: pool = get_context_value(request.app.ctx, config_state.pool_key) - connection_manager = with_ensure_async_(config.provide_connection(pool)) # type: ignore[union-attr] + connection_manager = with_ensure_async_(config.provide_connection(pool)) connection = await connection_manager.__aenter__() set_context_value(request.ctx, self._connection_manager_key(config_state), connection_manager) else: diff --git a/sqlspec/extensions/starlette/extension.py b/sqlspec/extensions/starlette/extension.py index 18891bf0..83de15a5 100644 --- a/sqlspec/extensions/starlette/extension.py +++ b/sqlspec/extensions/starlette/extension.py @@ -79,7 +79,7 @@ def __init__(self, sqlspec: SQLSpec, app: "Starlette | None" = None) -> None: self._sqlcommenter_middleware_added = False for cfg in self._sqlspec.configs.values(): - settings = self._extract_starlette_settings(cfg) + settings = self._extract_extension_settings(cfg) state = self._create_config_state(cfg, settings) self._config_states.append(state) @@ -94,7 +94,7 @@ def __init__(self, sqlspec: SQLSpec, app: "Starlette | None" = None) -> None: config_count=len(self._config_states), ) - def _extract_starlette_settings(self, config: Any) -> "dict[str, Any]": + def _extract_extension_settings(self, config: Any) -> "dict[str, Any]": """Extract Starlette settings from config.extension_config. Args: @@ -141,7 +141,7 @@ def _create_config_state(self, config: Any, settings: "dict[str, Any]") -> SQLSp Args: config: Database configuration instance. - settings: Extracted Starlette settings. + settings: Extracted framework settings. Returns: Configuration state instance. diff --git a/sqlspec/extensions/starlette/middleware.py b/sqlspec/extensions/starlette/middleware.py index 6a207128..8519da55 100644 --- a/sqlspec/extensions/starlette/middleware.py +++ b/sqlspec/extensions/starlette/middleware.py @@ -53,7 +53,7 @@ async def dispatch(self, request: "Request", call_next: Any) -> Any: if config.supports_connection_pooling: pool = get_state_value(request.app.state, self.config_state.pool_key) - async with with_ensure_async_(config.provide_connection(pool)) as connection: # type: ignore[union-attr] + async with with_ensure_async_(config.provide_connection(pool)) as connection: set_state_value(request.state, connection_key, connection) try: return await call_next(request) @@ -102,7 +102,7 @@ async def dispatch(self, request: "Request", call_next: Any) -> Any: if config.supports_connection_pooling: pool = get_state_value(request.app.state, self.config_state.pool_key) - async with with_ensure_async_(config.provide_connection(pool)) as connection: # type: ignore[union-attr] + async with with_ensure_async_(config.provide_connection(pool)) as connection: set_state_value(request.state, connection_key, connection) try: response = await call_next(request) diff --git a/tests/integration/extensions/sanic/test_integration.py b/tests/integration/extensions/sanic/test_integration.py index fae424b7..1e0b41d7 100644 --- a/tests/integration/extensions/sanic/test_integration.py +++ b/tests/integration/extensions/sanic/test_integration.py @@ -281,7 +281,9 @@ async def handler(request: Request): async with config.provide_connection(pool) as connection: db = config.driver_type(connection=connection, statement_config=config.statement_config) result = await db.execute("SELECT 1 as value") - return response.json({"value": result.get_first()["value"]}) + data = result.get_first() + assert data is not None + return response.json({"value": data["value"]}) plugin.init_app(app) diff --git a/tests/unit/test_package_metadata.py b/tests/unit/test_package_metadata.py index 1b56c29e..7ef539bd 100644 --- a/tests/unit/test_package_metadata.py +++ b/tests/unit/test_package_metadata.py @@ -1,8 +1,9 @@ +import sys from pathlib import Path -try: +if sys.version_info >= (3, 11): import tomllib -except ImportError: # pragma: no cover +else: import tomli as tomllib