Skip to content

Commit b00905a

Browse files
zboylesLaurentMnr95google-labs-jules[bot]kthota-g
authored
feat(db): support multiple SQL backends with optional extras (cont. #80) (#106)
* feat: postgresql task store * feat: postgresql task store * feat: postgresql task store * feat: postgresql task store * feat: postgresql task store * Update check-spelling metadata * feat: postgresql task store * Refactor TaskStore to be database-agnostic using SQLAlchemy This commit replaces the PostgreSQL-specific TaskStore with a generic `DatabaseTaskStore` that leverages SQLAlchemy for database interactions. This change allows your A2A server to support multiple database backends, including SQLite, PostgreSQL, and MySQL. Key changes include: - Definition of a SQLAlchemy model `TaskModel` for storing task data. - Implementation of `DatabaseTaskStore` that uses the `TaskStore` interface and SQLAlchemy for CRUD operations. - Update of example application configurations to use `DatabaseTaskStore` when a `DATABASE_URL` environment variable is provided, defaulting to `InMemoryTaskStore` otherwise. - Creation of parameterized unit tests for `DatabaseTaskStore`, designed to run against SQLite, PostgreSQL, and MySQL to ensure compatibility. - Removal of the old `PostgreSQLTaskStore` and its specific tests. - Addition of necessary dependencies: `sqlalchemy`, `aiosqlite`, `aiomysql`. The new implementation makes the task persistence layer more flexible and extensible, allowing you to choose a database backend that best suits your needs. * feat: postgresql task store * feat: postgresql task store * feat: postgresql task store * feat: postgresql task store * feat: postgresql task store * Update check-spelling metadata * feat: postgresql task store * Refactor TaskStore to be database-agnostic using SQLAlchemy This commit replaces the PostgreSQL-specific TaskStore with a generic `DatabaseTaskStore` that leverages SQLAlchemy for database interactions. This change allows your A2A server to support multiple database backends, including SQLite, PostgreSQL, and MySQL. Key changes include: - Definition of a SQLAlchemy model `TaskModel` for storing task data. - Implementation of `DatabaseTaskStore` that uses the `TaskStore` interface and SQLAlchemy for CRUD operations. - Update of example application configurations to use `DatabaseTaskStore` when a `DATABASE_URL` environment variable is provided, defaulting to `InMemoryTaskStore` otherwise. - Creation of parameterized unit tests for `DatabaseTaskStore`, designed to run against SQLite, PostgreSQL, and MySQL to ensure compatibility. - Removal of the old `PostgreSQLTaskStore` and its specific tests. - Addition of necessary dependencies: `sqlalchemy`, `aiosqlite`, `aiomysql`. The new implementation makes the task persistence layer more flexible and extensible, allowing you to choose a database backend that best suits your needs. * Refactor project to support multiple database backends This commit introduces optional dependencies for PostgreSQL, MySQL, and SQLite support in the A2A SDK. Key changes include: - Addition of optional dependencies in `pyproject.toml` for database drivers. - Updates to example applications to demonstrate installation and usage with different databases. - Refactoring of task management to utilize a generic `DatabaseTaskStore` for improved flexibility. - Enhancements to the README for clearer instructions on database support. These changes enhance the SDK's versatility, allowing users to choose their preferred database backend. * Add drivername, aiomysql, and DSNs to the list of expected words in the spelling action * GitHub Actions installs SQL dependencies for tests and database tests gracefully skip when SQLAlchemy isn't installed locally * feat(db): refactor database backend to be database-agnostic This allows users to choose their preferred database backend (PostgreSQL, MySQL, SQLite) while maintaining full compatibility and proper type safety. - Replace PostgreSQL-specific JSONB with generic JSON type in models - Implement SQLAlchemy 2.0 best practices with Mapped types and TypeDecorator - Add configurable table name support via create_task_model() factory - Updated metadata field mapping between Pydantic and SQLAlchemy using declared_attr - Add comprehensive tests for metadata field mapping including complex nested data - Update GitHub Actions to install SQL dependencies for tests - Add pytest.importorskip to gracefully skip database tests when SQLAlchemy not installed - Fix test_types.py to use 'id' instead of 'taskId' for JSON-RPC requests - Add automatic cleanup of SQLite file::memory: files after tests - Remove examples directory (moved to separate a2a-samples repo) - Update pyproject.toml to remove workspace members reference and add `nox` for testing * fix(models): correct variable name in TaskModel repr template * fix: use typing_extensions for override decorator for Python 3.10 compatibility The override decorator was added in Python 3.12. For compatibility with Python 3.10, we import it from typing_extensions when not available in the typing module. --------- Co-authored-by: MEUNIER Laurent <[email protected]> Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Zac <[email protected]> Co-authored-by: kthota-g <[email protected]>
1 parent 2a6ef10 commit b00905a

File tree

13 files changed

+1745
-493
lines changed

13 files changed

+1745
-493
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
AUser
22
excinfo
3+
fetchrow
4+
fetchval
35
GVsb
6+
initdb
7+
isready
48
notif
59
otherurl
10+
POSTGRES
11+
postgres
12+
postgresql
13+
drivername
14+
aiomysql
15+
DSNs

.github/workflows/spelling.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ jobs:
8080
cspell:sql/src/tsql.txt
8181
cspell:terraform/dict/terraform.txt
8282
cspell:typescript/dict/typescript.txt
83+
8384
check_extra_dictionaries: ""
8485
only_check_changed_files: true
8586
longest_word: "10"

.github/workflows/unit-tests.yml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ jobs:
1515
runs-on: ubuntu-latest
1616

1717
if: github.repository == 'google-a2a/a2a-python'
18+
services:
19+
postgres:
20+
image: postgres:15-alpine
21+
env:
22+
POSTGRES_USER: postgres
23+
POSTGRES_PASSWORD: postgres
24+
POSTGRES_DB: a2a_test
25+
ports:
26+
- 5432:5432
1827

1928
strategy:
2029
matrix:
@@ -28,6 +37,11 @@ jobs:
2837
uses: actions/setup-python@v5
2938
with:
3039
python-version: ${{ matrix.python-version }}
40+
- name: Set postgres for tests
41+
run: |
42+
sudo apt-get update && sudo apt-get install -y postgresql-client
43+
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d a2a_test -f ${{ github.workspace }}/docker/postgres/init.sql
44+
export POSTGRES_TEST_DSN="postgresql://postgres:postgres@localhost:5432/a2a_test"
3145
3246
- name: Install uv
3347
run: |
@@ -38,7 +52,7 @@ jobs:
3852
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
3953
4054
- name: Install dependencies
41-
run: uv sync --dev
55+
run: uv sync --dev --extra sql
4256

4357
- name: Run tests
4458
run: uv run pytest

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@ When you're working within a uv project or a virtual environment managed by uv,
3232
uv add a2a-sdk
3333
```
3434

35+
To install with database support:
36+
```bash
37+
# PostgreSQL support
38+
uv add "a2a-sdk[postgresql]"
39+
40+
# MySQL support
41+
uv add "a2a-sdk[mysql]"
42+
43+
# SQLite support
44+
uv add "a2a-sdk[sqlite]"
45+
46+
# All database drivers
47+
uv add "a2a-sdk[sql]"
48+
```
49+
3550
### Using `pip`
3651

3752
If you prefer to use pip, the standard Python package installer, you can install `a2a-sdk` as follows
@@ -40,6 +55,21 @@ If you prefer to use pip, the standard Python package installer, you can install
4055
pip install a2a-sdk
4156
```
4257

58+
To install with database support:
59+
```bash
60+
# PostgreSQL support
61+
pip install "a2a-sdk[postgresql]"
62+
63+
# MySQL support
64+
pip install "a2a-sdk[mysql]"
65+
66+
# SQLite support
67+
pip install "a2a-sdk[sqlite]"
68+
69+
# All database drivers
70+
pip install "a2a-sdk[sql]"
71+
```
72+
4373
## Examples
4474

4575
### [Helloworld Example](https://github.com/google-a2a/a2a-samples/tree/main/samples/python/agents/helloworld)

docker/postgres/docker-compose.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
version: "3.8"
2+
3+
services:
4+
postgres:
5+
image: postgres:15-alpine
6+
ports:
7+
- "5432:5432"
8+
environment:
9+
- POSTGRES_USER=postgres
10+
- POSTGRES_PASSWORD=postgres
11+
- POSTGRES_DB=a2a_test
12+
volumes:
13+
- postgres_data:/var/lib/postgresql/data
14+
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
15+
networks:
16+
- a2a-network
17+
healthcheck:
18+
test: ["CMD-SHELL", "pg_isready -U postgres"]
19+
interval: 5s
20+
timeout: 5s
21+
retries: 5
22+
23+
volumes:
24+
postgres_data:
25+
26+
networks:
27+
a2a-network:
28+
driver: bridge

docker/postgres/init.sql

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- Create a dedicated user for the application
2+
CREATE USER a2a WITH PASSWORD 'a2a_password';
3+
4+
-- Create the tasks database
5+
CREATE DATABASE a2a_tasks;
6+
7+
GRANT ALL PRIVILEGES ON DATABASE a2a_test TO a2a;
8+

pyproject.toml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,26 @@ classifiers = [
3030
"License :: OSI Approved :: Apache Software License",
3131
]
3232

33+
[project.optional-dependencies]
34+
postgresql = [
35+
"sqlalchemy>=2.0.0",
36+
"asyncpg>=0.30.0",
37+
]
38+
mysql = [
39+
"sqlalchemy>=2.0.0",
40+
"aiomysql>=0.2.0",
41+
]
42+
sqlite = [
43+
"sqlalchemy>=2.0.0",
44+
"aiosqlite>=0.19.0",
45+
]
46+
sql = [
47+
"sqlalchemy>=2.0.0",
48+
"asyncpg>=0.30.0",
49+
"aiomysql>=0.2.0",
50+
"aiosqlite>=0.19.0",
51+
]
52+
3353
[project.urls]
3454
homepage = "https://google.github.io/A2A/"
3555
repository = "https://github.com/google-a2a/a2a-python"
@@ -64,8 +84,11 @@ style = "pep440"
6484

6585
[dependency-groups]
6686
dev = [
87+
"asyncpg-stubs>=0.30.1",
6788
"datamodel-code-generator>=0.30.0",
89+
"greenlet>=3.2.2",
6890
"mypy>=1.15.0",
91+
"nox>=2025.5.1",
6992
"pytest>=8.3.5",
7093
"pytest-asyncio>=0.26.0",
7194
"pytest-cov>=6.1.1",

src/a2a/server/models.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
2+
3+
4+
if TYPE_CHECKING:
5+
from typing_extensions import override
6+
else:
7+
8+
def override(func): # noqa: D103
9+
return func
10+
11+
12+
from pydantic import BaseModel
13+
14+
from a2a.types import Artifact, Message, TaskStatus
15+
16+
17+
try:
18+
from sqlalchemy import JSON, Dialect, String
19+
from sqlalchemy.orm import (
20+
DeclarativeBase,
21+
Mapped,
22+
declared_attr,
23+
mapped_column,
24+
)
25+
from sqlalchemy.types import TypeDecorator
26+
except ImportError as e:
27+
raise ImportError(
28+
'Database models require SQLAlchemy. '
29+
'Install with one of: '
30+
"'pip install a2a-sdk[postgresql]', "
31+
"'pip install a2a-sdk[mysql]', "
32+
"'pip install a2a-sdk[sqlite]', "
33+
"or 'pip install a2a-sdk[sql]'"
34+
) from e
35+
36+
37+
T = TypeVar('T', bound=BaseModel)
38+
39+
40+
class PydanticType(TypeDecorator[T], Generic[T]):
41+
"""SQLAlchemy type that handles Pydantic model serialization."""
42+
43+
impl = JSON
44+
cache_ok = True
45+
46+
def __init__(self, pydantic_type: type[T], **kwargs: dict[str, Any]):
47+
self.pydantic_type = pydantic_type
48+
super().__init__(**kwargs)
49+
50+
@override
51+
def process_bind_param(
52+
self, value: T | None, dialect: Dialect
53+
) -> dict[str, Any] | None:
54+
if value is None:
55+
return None
56+
return (
57+
value.model_dump(mode='json')
58+
if isinstance(value, BaseModel)
59+
else value
60+
)
61+
62+
@override
63+
def process_result_value(
64+
self, value: dict[str, Any] | None, dialect: Dialect
65+
) -> T | None:
66+
if value is None:
67+
return None
68+
return self.pydantic_type.model_validate(value)
69+
70+
71+
class PydanticListType(TypeDecorator[list[T]], Generic[T]):
72+
"""SQLAlchemy type that handles lists of Pydantic models."""
73+
74+
impl = JSON
75+
cache_ok = True
76+
77+
def __init__(self, pydantic_type: type[T], **kwargs: dict[str, Any]):
78+
self.pydantic_type = pydantic_type
79+
super().__init__(**kwargs)
80+
81+
@override
82+
def process_bind_param(
83+
self, value: list[T] | None, dialect: Dialect
84+
) -> list[dict[str, Any]] | None:
85+
if value is None:
86+
return None
87+
return [
88+
item.model_dump(mode='json')
89+
if isinstance(item, BaseModel)
90+
else item
91+
for item in value
92+
]
93+
94+
@override
95+
def process_result_value(
96+
self, value: list[dict[str, Any]] | None, dialect: Dialect
97+
) -> list[T] | None:
98+
if value is None:
99+
return None
100+
return [self.pydantic_type.model_validate(item) for item in value]
101+
102+
103+
# Base class for all database models
104+
class Base(DeclarativeBase):
105+
"""Base class for declarative models in A2A SDK."""
106+
107+
108+
# TaskMixin that can be used with any table name
109+
class TaskMixin:
110+
"""Mixin providing standard task columns with proper type handling."""
111+
112+
id: Mapped[str] = mapped_column(String, primary_key=True, index=True)
113+
contextId: Mapped[str] = mapped_column(String, nullable=False) # noqa: N815
114+
kind: Mapped[str] = mapped_column(String, nullable=False, default='task')
115+
116+
# Properly typed Pydantic fields with automatic serialization
117+
status: Mapped[TaskStatus] = mapped_column(PydanticType(TaskStatus))
118+
artifacts: Mapped[list[Artifact] | None] = mapped_column(
119+
PydanticListType(Artifact), nullable=True
120+
)
121+
history: Mapped[list[Message] | None] = mapped_column(
122+
PydanticListType(Message), nullable=True
123+
)
124+
125+
# Using declared_attr to avoid conflict with Pydantic's metadata
126+
@declared_attr
127+
@classmethod
128+
def task_metadata(cls) -> Mapped[dict[str, Any] | None]:
129+
return mapped_column(JSON, nullable=True, name='metadata')
130+
131+
@override
132+
def __repr__(self) -> str:
133+
"""Return a string representation of the task."""
134+
repr_template = (
135+
'<{CLS}(id="{ID}", contextId="{CTX_ID}", status="{STATUS}")>'
136+
)
137+
return repr_template.format(
138+
CLS=self.__class__.__name__,
139+
ID=self.id,
140+
CTX_ID=self.contextId,
141+
STATUS=self.status,
142+
)
143+
144+
145+
def create_task_model(
146+
table_name: str = 'tasks', base: type[DeclarativeBase] = Base
147+
) -> type:
148+
"""Create a TaskModel class with a configurable table name.
149+
150+
Args:
151+
table_name: Name of the database table. Defaults to 'tasks'.
152+
base: Base declarative class to use. Defaults to the SDK's Base class.
153+
154+
Returns:
155+
TaskModel class with the specified table name.
156+
157+
Example:
158+
# Create a task model with default table name
159+
TaskModel = create_task_model()
160+
161+
# Create a task model with custom table name
162+
CustomTaskModel = create_task_model('my_tasks')
163+
164+
# Use with a custom base
165+
from myapp.database import Base as MyBase
166+
TaskModel = create_task_model('tasks', MyBase)
167+
"""
168+
169+
class TaskModel(TaskMixin, base):
170+
__tablename__ = table_name
171+
172+
@override
173+
def __repr__(self) -> str:
174+
"""Return a string representation of the task."""
175+
repr_template = '<TaskModel[{TABLE}](id="{ID}", contextId="{CTX_ID}", status="{STATUS}")>'
176+
return repr_template.format(
177+
TABLE=table_name,
178+
ID=self.id,
179+
CTX_ID=self.contextId,
180+
STATUS=self.status,
181+
)
182+
183+
# Set a dynamic name for better debugging
184+
TaskModel.__name__ = f'TaskModel_{table_name}'
185+
TaskModel.__qualname__ = f'TaskModel_{table_name}'
186+
187+
return TaskModel
188+
189+
190+
# Default TaskModel for backward compatibility
191+
class TaskModel(TaskMixin, Base):
192+
"""Default task model with standard table name."""
193+
194+
__tablename__ = 'tasks'

src/a2a/server/tasks/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Components for managing tasks within the A2A server."""
22

3+
from a2a.server.tasks.database_task_store import DatabaseTaskStore
34
from a2a.server.tasks.inmemory_push_notifier import InMemoryPushNotifier
45
from a2a.server.tasks.inmemory_task_store import InMemoryTaskStore
56
from a2a.server.tasks.push_notifier import PushNotifier
@@ -10,6 +11,7 @@
1011

1112

1213
__all__ = [
14+
'DatabaseTaskStore',
1315
'InMemoryPushNotifier',
1416
'InMemoryTaskStore',
1517
'PushNotifier',

0 commit comments

Comments
 (0)