Skip to content

Commit 67c1640

Browse files
authored
feat: Flask extension (#148)
Introduce a Flask extension for SQLSpec.
1 parent 7bb1e64 commit 67c1640

File tree

15 files changed

+2541
-94
lines changed

15 files changed

+2541
-94
lines changed

AGENTS.md

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)