@@ -1399,6 +1399,245 @@ class AdapterDriverFeatures(TypedDict):
13991399- **Optional Dependency Handling**: See `sqlspec.typing` for detection constants
14001400- **Testing Standards**: See Testing Strategy section for general testing requirements
14011401
1402+ # # Flask Extension Pattern (Hook-Based)
1403+
1404+ # ## Overview
1405+
1406+ The Flask extension uses a **hook-based lifecycle** pattern instead of middleware, since Flask doesn't have native ASGI middleware like Starlette/FastAPI.
1407+
1408+ # ## Hook-Based Lifecycle Pattern
1409+
1410+ Flask uses three hooks for request lifecycle management :
1411+
1412+ ` ` ` python
1413+ def init_app(self, app: "Flask") -> None:
1414+ """Initialize Flask application with SQLSpec."""
1415+ # Register hooks for request lifecycle
1416+ app.before_request(self._before_request_handler)
1417+ app.after_request(self._after_request_handler)
1418+ app.teardown_appcontext(self._teardown_appcontext_handler)
1419+
1420+ def _before_request_handler(self) -> None:
1421+ """Acquire connection before request. Store in Flask g object."""
1422+ from flask import current_app, g
1423+
1424+ for config_state in self._config_states:
1425+ if config_state.config.supports_connection_pooling:
1426+ pool = current_app.extensions["sqlspec"]["pools"][config_state.session_key]
1427+ conn_ctx = config_state.config.provide_connection(pool)
1428+ # Acquire connection (via portal if async)
1429+ setattr(g, config_state.connection_key, connection)
1430+
1431+ def _after_request_handler(self, response: "Response") -> "Response":
1432+ """Handle transaction after request based on response status."""
1433+ from flask import g
1434+
1435+ for config_state in self._config_states:
1436+ if config_state.commit_mode == "manual":
1437+ continue
1438+ # Commit or rollback based on status code
1439+
1440+ return response # MUST return response unchanged
1441+
1442+ def _teardown_appcontext_handler(self, _exc: "Exception | None" = None) -> None:
1443+ """Clean up connections when request context ends."""
1444+ from flask import g
1445+ # Close connections and cleanup g object
1446+ ` ` `
1447+
1448+ **Key differences from Starlette/FastAPI**:
1449+ - **No middleware**: Use `before_request`, `after_request`, `teardown_appcontext` hooks
1450+ - **Flask g object**: Store connections/sessions on `g` (request-scoped global)
1451+ - **Must return response**: `after_request` hook must return the response unchanged
1452+ - **Nested imports required**: Import `flask.g` and `flask.current_app` inside hooks to access request context
1453+
1454+ # ## Portal Pattern for Sync Frameworks
1455+
1456+ Enable async adapters (asyncpg, asyncmy, aiosqlite) in sync WSGI frameworks :
1457+
1458+ ` ` ` python
1459+ class PortalProvider:
1460+ """Manages background thread with event loop for async operations."""
1461+
1462+ def __init__(self) -> None:
1463+ self._request_queue: queue.Queue = queue.Queue()
1464+ self._loop: asyncio.AbstractEventLoop | None = None
1465+ self._thread: threading.Thread | None = None
1466+ self._ready_event: threading.Event = threading.Event()
1467+
1468+ def start(self) -> None:
1469+ """Start daemon thread with event loop."""
1470+ self._thread = threading.Thread(target=self._run_event_loop, daemon=True)
1471+ self._thread.start()
1472+ self._ready_event.wait() # Block until loop ready
1473+
1474+ def call(self, func, *args, **kwargs):
1475+ """Execute async function from sync context."""
1476+ future = asyncio.run_coroutine_threadsafe(
1477+ self._async_caller(func, args, kwargs),
1478+ self._loop
1479+ )
1480+ result, exception = local_result_queue.get() # Block until done
1481+ if exception:
1482+ raise exception
1483+ return result
1484+ ` ` `
1485+
1486+ **Portal usage in extension**:
1487+
1488+ ` ` ` python
1489+ # Auto-detect async configs and create portal
1490+ if self._has_async_configs:
1491+ self._portal = PortalProvider()
1492+ self._portal.start()
1493+
1494+ # Use portal for async operations
1495+ if config_state.is_async:
1496+ pool = self._portal.portal.call(config_state.config.create_pool)
1497+ else:
1498+ pool = config_state.config.create_pool() # Direct sync call
1499+ ` ` `
1500+
1501+ **Performance**: ~1-2ms overhead per operation. **Recommended**: Use sync adapters for Flask.
1502+
1503+ # ## HTTP Status Code Constants
1504+
1505+ Avoid magic values by defining module-level constants :
1506+
1507+ ` ` ` python
1508+ # In _state.py
1509+ HTTP_SUCCESS_MIN = 200
1510+ HTTP_SUCCESS_MAX = 300
1511+ HTTP_REDIRECT_MAX = 400
1512+
1513+ @dataclass
1514+ class FlaskConfigState:
1515+ def should_commit(self, status_code: int) -> bool:
1516+ if self.commit_mode == "autocommit":
1517+ return HTTP_SUCCESS_MIN <= status_code < HTTP_SUCCESS_MAX
1518+ ` ` `
1519+
1520+ **Why**: Satisfies Ruff PLR2004, self-documenting, easy to update.
1521+
1522+ # ## Flask g Object Storage
1523+
1524+ Store connection state on Flask's `g` object for request-scoped access :
1525+
1526+ ` ` ` python
1527+ # Store with dynamic keys
1528+ setattr(g, config_state.connection_key, connection)
1529+ setattr(g, f"{config_state.connection_key}_ctx", conn_ctx)
1530+
1531+ # Retrieve
1532+ connection = getattr(g, config_state.connection_key)
1533+
1534+ # Clean up - always check hasattr first
1535+ if hasattr(g, config_state.connection_key):
1536+ delattr(g, config_state.connection_key)
1537+ ` ` `
1538+
1539+ # ## Portal in Utils (Implemented)
1540+
1541+ **Location**: `sqlspec/utils/portal.py` (moved from Flask extension for broader reusability)
1542+
1543+ Portal now available for any sync framework or utility needing async-to-sync bridging :
1544+
1545+ ` ` ` python
1546+ from sqlspec.utils.portal import Portal, PortalProvider, PortalManager, get_global_portal
1547+
1548+ # Option 1: Create dedicated portal (Flask extension pattern)
1549+ portal_provider = PortalProvider()
1550+ portal_provider.start()
1551+ result = portal_provider.portal.call(some_async_function, arg1, arg2)
1552+
1553+ # Option 2: Use global singleton portal (sync_tools pattern)
1554+ portal = get_global_portal()
1555+ result = portal.call(some_async_function, arg1, arg2)
1556+
1557+ # Option 3: Direct PortalManager access
1558+ manager = PortalManager()
1559+ portal = manager.get_or_create_portal()
1560+ result = portal.call(some_async_function, arg1, arg2)
1561+ ` ` `
1562+
1563+ **Benefits**:
1564+ - Available to Django, Bottle, or any sync framework
1565+ - Single import location for all portal functionality
1566+ - Thread-safe singleton for automatic portal management
1567+
1568+ # ## Portal + sync_tools Integration (Implemented)
1569+
1570+ The `await_()` function now automatically uses the global portal when no event loop exists :
1571+
1572+ ` ` ` python
1573+ from sqlspec.utils.sync_tools import await_
1574+
1575+ async def async_add(a: int, b: int) -> int:
1576+ await asyncio.sleep(0.01)
1577+ return a + b
1578+
1579+ # Automatically uses portal if no loop exists (default behavior)
1580+ sync_add = await_(async_add)
1581+ result = sync_add(5, 3) # Returns 8, using portal internally
1582+
1583+ # To disable portal and raise errors instead, use raise_sync_error=True
1584+ sync_add_strict = await_(async_add, raise_sync_error=True)
1585+ ` ` `
1586+
1587+ **How it works**:
1588+
1589+ ` ` ` python
1590+ def await_(async_function, raise_sync_error=False): # Portal enabled by default
1591+ @functools.wraps(async_function)
1592+ def wrapper(*args, **kwargs):
1593+ try:
1594+ loop = asyncio.get_running_loop()
1595+ except RuntimeError:
1596+ if raise_sync_error:
1597+ raise RuntimeError("Cannot run async function")
1598+ # Automatically use global portal (default behavior)
1599+ from sqlspec.utils.portal import get_global_portal
1600+ portal = get_global_portal()
1601+ return portal.call(async_function, *args, **kwargs)
1602+ # ... rest of implementation
1603+ ` ` `
1604+
1605+ **Benefits**:
1606+ - **Transparent async support by default** - no parameter needed
1607+ - No manual portal management required
1608+ - Global portal automatically created and reused
1609+ - Works across all sync frameworks (Flask, Django, Bottle, etc.)
1610+ - Opt-in to strict error mode with `raise_sync_error=True` if needed
1611+
1612+ **PortalManager Implementation**:
1613+
1614+ ` ` ` python
1615+ class PortalManager(metaclass=SingletonMeta):
1616+ """Singleton manager for global portal instance."""
1617+
1618+ def __init__(self) -> None:
1619+ self._provider: PortalProvider | None = None
1620+ self._lock = threading.Lock()
1621+
1622+ def get_or_create_portal(self) -> Portal:
1623+ """Get or create the global portal instance.
1624+
1625+ Lazily creates and starts the portal provider on first access.
1626+ Thread-safe via locking.
1627+ """
1628+ if self._provider is None:
1629+ with self._lock:
1630+ if self._provider is None:
1631+ self._provider = PortalProvider()
1632+ self._provider.start()
1633+ return self._provider.portal
1634+ ` ` `
1635+
1636+ **Key Features**:
1637+ - Uses `SingletonMeta` for thread-safe singleton pattern
1638+ - Double-checked locking for performance
1639+ - Lazy initialization - portal only created when needed
1640+ - Global portal shared across all `await_()` calls
14021641# # Framework Extension Pattern (Middleware-Based)
14031642
14041643# ## Overview
0 commit comments