From b7720908fe661127b0b606609f1f8fb93a244347 Mon Sep 17 00:00:00 2001 From: Krishna Thota Date: Thu, 10 Jul 2025 16:03:10 -0700 Subject: [PATCH 01/10] feat: Support for Database based Push Config Store --- pyproject.toml | 1 + src/a2a/server/models.py | 57 +++- .../tasks/base_push_notification_sender.py | 7 +- ...database_push_notification_config_store.py | 253 ++++++++++++++++++ 4 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 src/a2a/server/tasks/database_push_notification_config_store.py diff --git a/pyproject.toml b/pyproject.toml index e73df213..57dbc3ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ postgresql = ["sqlalchemy[asyncio,postgresql-asyncpg]>=2.0.0"] mysql = ["sqlalchemy[asyncio,aiomysql]>=2.0.0"] sqlite = ["sqlalchemy[asyncio,aiosqlite]>=2.0.0"] sql = ["sqlalchemy[asyncio,postgresql-asyncpg,aiomysql,aiosqlite]>=2.0.0"] +encryption = ["cryptography>=43.0.0"] [project.urls] homepage = "https://a2aproject.github.io/A2A/" diff --git a/src/a2a/server/models.py b/src/a2a/server/models.py index 639c0729..09db5641 100644 --- a/src/a2a/server/models.py +++ b/src/a2a/server/models.py @@ -16,7 +16,7 @@ def override(func): # noqa: ANN001, ANN201 try: - from sqlalchemy import JSON, Dialect, String + from sqlalchemy import JSON, Dialect, LargeBinary, String from sqlalchemy.orm import ( DeclarativeBase, Mapped, @@ -208,3 +208,58 @@ class TaskModel(TaskMixin, Base): """Default task model with standard table name.""" __tablename__ = 'tasks' + + +# PushNotificationConfigMixin that can be used with any table name +class PushNotificationConfigMixin: + """Mixin providing standard push notification config columns.""" + + task_id: Mapped[str] = mapped_column(String(36), primary_key=True) + config_id: Mapped[str] = mapped_column(String(255), primary_key=True) + config_data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + + @override + def __repr__(self) -> str: + """Return a string representation of the push notification config.""" + repr_template = '<{CLS}(task_id="{TID}", config_id="{CID}")>' + return repr_template.format( + CLS=self.__class__.__name__, + TID=self.task_id, + CID=self.config_id, + ) + + +def create_push_notification_config_model( + table_name: str = 'push_notification_configs', + base: type[DeclarativeBase] = Base, +) -> type: + """Create a PushNotificationConfigModel class with a configurable table name.""" + + class PushNotificationConfigModel(PushNotificationConfigMixin, base): + __tablename__ = table_name + + @override + def __repr__(self) -> str: + """Return a string representation of the push notification config.""" + repr_template = '' + return repr_template.format( + TABLE=table_name, + TID=self.task_id, + CID=self.config_id, + ) + + PushNotificationConfigModel.__name__ = ( + f'PushNotificationConfigModel_{table_name}' + ) + PushNotificationConfigModel.__qualname__ = ( + f'PushNotificationConfigModel_{table_name}' + ) + + return PushNotificationConfigModel + + +# Default PushNotificationConfigModel for backward compatibility +class PushNotificationConfigModel(PushNotificationConfigMixin, Base): + """Default push notification config model with standard table name.""" + + __tablename__ = 'push_notification_configs' diff --git a/src/a2a/server/tasks/base_push_notification_sender.py b/src/a2a/server/tasks/base_push_notification_sender.py index 308ed978..51558d42 100644 --- a/src/a2a/server/tasks/base_push_notification_sender.py +++ b/src/a2a/server/tasks/base_push_notification_sender.py @@ -52,8 +52,13 @@ async def _dispatch_notification( ) -> bool: url = push_info.url try: + headers = None + if push_info.token: + headers = {'X-A2A-Notification-Token': push_info.token} response = await self._client.post( - url, json=task.model_dump(mode='json', exclude_none=True) + url, + json=task.model_dump(mode='json', exclude_none=True), + headers=headers, ) response.raise_for_status() logger.info( diff --git a/src/a2a/server/tasks/database_push_notification_config_store.py b/src/a2a/server/tasks/database_push_notification_config_store.py new file mode 100644 index 00000000..f67670de --- /dev/null +++ b/src/a2a/server/tasks/database_push_notification_config_store.py @@ -0,0 +1,253 @@ +import json +import logging + +from typing import TYPE_CHECKING + + +try: + from sqlalchemy import ( + delete, + select, + ) + from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + ) +except ImportError as e: + raise ImportError( + 'DatabasePushNotificationConfigStore requires SQLAlchemy and a database driver. ' + 'Install with one of: ' + "'pip install a2a-sdk[postgresql]', " + "'pip install a2a-sdk[mysql]', " + "'pip install a2a-sdk[sqlite]', " + "or 'pip install a2a-sdk[sql]'" + ) from e + +from a2a.server.models import ( + Base, + PushNotificationConfigModel, + create_push_notification_config_model, +) +from a2a.server.tasks.push_notification_config_store import ( + PushNotificationConfigStore, +) +from a2a.types import PushNotificationConfig + + +if TYPE_CHECKING: + from cryptography.fernet import Fernet + + +logger = logging.getLogger(__name__) + + +class DatabasePushNotificationConfigStore(PushNotificationConfigStore): + """SQLAlchemy-based implementation of PushNotificationConfigStore. + + Stores push notification configurations in a database supported by SQLAlchemy. + """ + + engine: AsyncEngine + async_session_maker: async_sessionmaker[AsyncSession] + create_table: bool + _initialized: bool + config_model: type[PushNotificationConfigModel] + _fernet: 'Fernet | None' + + def __init__( + self, + engine: AsyncEngine, + create_table: bool = True, + table_name: str = 'push_notification_configs', + encryption_key: str | bytes | None = None, + ) -> None: + """Initializes the DatabasePushNotificationConfigStore. + + Args: + engine: An existing SQLAlchemy AsyncEngine to be used by the store. + create_table: If true, create the table on initialization. + table_name: Name of the database table. Defaults to 'push_notification_configs'. + encryption_key: A key for encrypting sensitive configuration data. + If provided, `config_data` will be encrypted in the database. + The key must be a URL-safe base64-encoded 32-byte key. + """ + logger.debug( + f'Initializing DatabasePushNotificationConfigStore with existing engine, table: {table_name}' + ) + self.engine = engine + self.async_session_maker = async_sessionmaker( + self.engine, expire_on_commit=False + ) + self.create_table = create_table + self._initialized = False + self.config_model = ( + PushNotificationConfigModel + if table_name == 'push_notification_configs' + else create_push_notification_config_model(table_name) + ) + self._fernet = None + + if encryption_key: + try: + from cryptography.fernet import Fernet # noqa: PLC0415 + except ImportError as e: + raise ImportError( + "DatabasePushNotificationConfigStore with encryption requires the 'cryptography' " + 'library. Install with: ' + "'pip install a2a-sdk[encryption]'" + ) from e + + if isinstance(encryption_key, str): + encryption_key = encryption_key.encode('utf-8') + self._fernet = Fernet(encryption_key) + logger.debug( + 'Encryption enabled for push notification config store.' + ) + + async def initialize(self) -> None: + """Initialize the database and create the table if needed.""" + if self._initialized: + return + + logger.debug( + 'Initializing database schema for push notification configs...' + ) + if self.create_table: + async with self.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + self._initialized = True + logger.debug( + 'Database schema for push notification configs initialized.' + ) + + async def _ensure_initialized(self) -> None: + """Ensure the database connection is initialized.""" + if not self._initialized: + await self.initialize() + + def _to_orm( + self, task_id: str, config: PushNotificationConfig + ) -> PushNotificationConfigModel: + """Maps a Pydantic PushNotificationConfig to a SQLAlchemy model instance. + + The config data is serialized to JSON bytes, and encrypted if a key is configured. + """ + json_payload = config.model_dump_json().encode('utf-8') + + if self._fernet: + data_to_store = self._fernet.encrypt(json_payload) + else: + data_to_store = json_payload + + return self.config_model( + task_id=task_id, + config_id=config.id, + config_data=data_to_store, + ) + + def _from_orm( + self, model_instance: PushNotificationConfigModel + ) -> PushNotificationConfig: + """Maps a SQLAlchemy model instance to a Pydantic PushNotificationConfig. + + Handles decryption if a key is configured. + """ + payload = model_instance.config_data + + if self._fernet: + from cryptography.fernet import InvalidToken # noqa: PLC0415 + + try: + decrypted_payload = self._fernet.decrypt(payload) + return PushNotificationConfig.model_validate_json( + decrypted_payload + ) + except InvalidToken: + # This could be unencrypted data if encryption was enabled after data was stored. + # We'll fall through and try to parse it as plain JSON. + logger.debug( + 'Could not decrypt config for task %s, config %s. ' + 'Attempting to parse as unencrypted JSON.', + model_instance.task_id, + model_instance.config_id, + ) + + # If no fernet or if decryption failed, try to parse as plain JSON. + try: + return PushNotificationConfig.model_validate_json(payload) + except json.JSONDecodeError as e: + if self._fernet: + raise ValueError( + 'Failed to decrypt data; incorrect key or corrupted data.' + ) from e + raise ValueError( + 'Failed to parse data; it may be encrypted but no key is configured.' + ) from e + + async def set_info( + self, task_id: str, notification_config: PushNotificationConfig + ) -> None: + """Sets or updates the push notification configuration for a task.""" + await self._ensure_initialized() + + config_to_save = notification_config.model_copy() + if config_to_save.id is None: + config_to_save.id = task_id + + db_config = self._to_orm(task_id, config_to_save) + async with self.async_session_maker.begin() as session: + await session.merge(db_config) + logger.debug( + f'Push notification config for task {task_id} with config id {config_to_save.id} saved/updated.' + ) + + async def get_info(self, task_id: str) -> list[PushNotificationConfig]: + """Retrieves all push notification configurations for a task.""" + await self._ensure_initialized() + async with self.async_session_maker() as session: + stmt = select(self.config_model).where( + self.config_model.task_id == task_id + ) + result = await session.execute(stmt) + models = result.scalars().all() + + configs = [] + for model in models: + try: + configs.append(self._from_orm(model)) + except ValueError as e: + logger.error( + 'Could not deserialize push notification config for task %s, config %s: %s', + model.task_id, + model.config_id, + e, + ) + return configs + + async def delete_info( + self, task_id: str, config_id: str | None = None + ) -> None: + """Deletes push notification configurations for a task. + + If config_id is provided, only that specific configuration is deleted. + If config_id is None, all configurations for the task are deleted. + """ + await self._ensure_initialized() + async with self.async_session_maker.begin() as session: + stmt = delete(self.config_model).where( + self.config_model.task_id == task_id + ) + if config_id is not None: + stmt = stmt.where(self.config_model.config_id == config_id) + + result = await session.execute(stmt) + + if result.rowcount > 0: + logger.info( + f'Deleted {result.rowcount} push notification config(s) for task {task_id}.' + ) + else: + logger.warning( + f'Attempted to delete non-existent push notification config for task {task_id} with config_id: {config_id}' + ) From d743fc6eb2b1338c50527d8922c4480b0081a227 Mon Sep 17 00:00:00 2001 From: Krishna Thota Date: Fri, 11 Jul 2025 13:36:19 -0700 Subject: [PATCH 02/10] Unit tests and update lock file --- .github/workflows/unit-tests.yml | 2 +- src/a2a/server/tasks/__init__.py | 4 + ...database_push_notification_config_store.py | 227 +++++++++++++++--- uv.lock | 119 ++++++++- 4 files changed, 313 insertions(+), 39 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index b07a0e0a..ef2a730b 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -43,7 +43,7 @@ jobs: run: | echo "$HOME/.cargo/bin" >> $GITHUB_PATH - name: Install dependencies - run: uv sync --dev --extra sql + run: uv sync --dev --extra sql --extra encryption - name: Run tests and check coverage run: uv run pytest --cov=a2a --cov-report=xml --cov-fail-under=89 - name: Show coverage summary in log diff --git a/src/a2a/server/tasks/__init__.py b/src/a2a/server/tasks/__init__.py index f46ef2cb..8474b165 100644 --- a/src/a2a/server/tasks/__init__.py +++ b/src/a2a/server/tasks/__init__.py @@ -3,6 +3,9 @@ from a2a.server.tasks.base_push_notification_sender import ( BasePushNotificationSender, ) +from a2a.server.tasks.database_push_notification_config_store import ( + DatabasePushNotificationConfigStore, +) from a2a.server.tasks.database_task_store import DatabaseTaskStore from a2a.server.tasks.inmemory_push_notification_config_store import ( InMemoryPushNotificationConfigStore, @@ -20,6 +23,7 @@ __all__ = [ 'BasePushNotificationSender', + 'DatabasePushNotificationConfigStore', 'DatabaseTaskStore', 'InMemoryPushNotificationConfigStore', 'InMemoryTaskStore', diff --git a/tests/server/tasks/test_database_push_notification_config_store.py b/tests/server/tasks/test_database_push_notification_config_store.py index 56520994..e235d1b2 100644 --- a/tests/server/tasks/test_database_push_notification_config_store.py +++ b/tests/server/tasks/test_database_push_notification_config_store.py @@ -6,19 +6,21 @@ import pytest_asyncio from _pytest.mark.structures import ParameterSet -# from sqlalchemy.ext.asyncio import ( -# AsyncSession, -# async_sessionmaker, -# create_async_engine, -# ) -# from sqlalchemy import select +from sqlalchemy.ext.asyncio import ( + async_sessionmaker, + create_async_engine, +) +from sqlalchemy import select # Skip entire test module if SQLAlchemy is not installed pytest.importorskip('sqlalchemy', reason='Database tests require SQLAlchemy') +pytest.importorskip( + 'cryptography', + reason='Database tests require Cryptography. Install extra encryption', +) # Now safe to import SQLAlchemy-dependent modules from sqlalchemy.inspection import inspect -from sqlalchemy.ext.asyncio import create_async_engine from a2a.server.models import ( Base, PushNotificationConfigModel, @@ -289,33 +291,184 @@ async def test_delete_info_not_found( await db_store_parameterized.delete_info('task-1', 'non-existent-config') -# @pytest.mark.asyncio -# async def test_data_is_encrypted_in_db( -# db_store_parameterized: DatabasePushNotificationConfigStore, -# ): -# """Verify that the data stored in the database is actually encrypted.""" -# task_id = 'encrypted-task' -# config = PushNotificationConfig( -# id='config-1', url='http://secret.url', token='secret-token' -# ) -# plain_json = config.model_dump_json() - -# await db_store_parameterized.set_info(task_id, config) - -# # Directly query the database to inspect the raw data -# async_session = async_sessionmaker( -# db_store_parameterized.engine, expire_on_commit=False -# ) -# async with async_session() as session: -# stmt = select(PushNotificationConfigModel).where( -# PushNotificationConfigModel.task_id == task_id -# ) -# result = await session.execute(stmt) -# db_model = result.scalar_one() - -# assert db_model.config_data != plain_json.encode('utf-8') - -# fernet = db_store_parameterized._fernet - -# decrypted_data = fernet.decrypt(db_model.config_data) # type: ignore -# assert decrypted_data.decode('utf-8') == plain_json +@pytest.mark.asyncio +async def test_data_is_encrypted_in_db( + db_store_parameterized: DatabasePushNotificationConfigStore, +): + """Verify that the data stored in the database is actually encrypted.""" + task_id = 'encrypted-task' + config = PushNotificationConfig( + id='config-1', url='http://secret.url', token='secret-token' + ) + plain_json = config.model_dump_json() + + await db_store_parameterized.set_info(task_id, config) + + # Directly query the database to inspect the raw data + async_session = async_sessionmaker( + db_store_parameterized.engine, expire_on_commit=False + ) + async with async_session() as session: + stmt = select(PushNotificationConfigModel).where( + PushNotificationConfigModel.task_id == task_id + ) + result = await session.execute(stmt) + db_model = result.scalar_one() + + assert db_model.config_data != plain_json.encode('utf-8') + + fernet = db_store_parameterized._fernet + + decrypted_data = fernet.decrypt(db_model.config_data) # type: ignore + assert decrypted_data.decode('utf-8') == plain_json + + +@pytest.mark.asyncio +async def test_decryption_error_with_wrong_key( + db_store_parameterized: DatabasePushNotificationConfigStore, +): + """Test that using the wrong key to decrypt raises a ValueError.""" + # 1. Store with one key + + task_id = 'wrong-key-task' + config = PushNotificationConfig(id='config-1', url='http://secret.url') + await db_store_parameterized.set_info(task_id, config) + + # 2. Try to read with a different key + # Directly query the database to inspect the raw data + wrong_key = Fernet.generate_key() + store2 = DatabasePushNotificationConfigStore( + db_store_parameterized.engine, encryption_key=wrong_key + ) + + retrieved_configs = await store2.get_info(task_id) + assert retrieved_configs == [] + + # _from_orm should raise a ValueError + async_session = async_sessionmaker( + db_store_parameterized.engine, expire_on_commit=False + ) + async with async_session() as session: + db_model = await session.get( + PushNotificationConfigModel, (task_id, 'config-1') + ) + + with pytest.raises(ValueError) as exc_info: + store2._from_orm(db_model) # type: ignore + + +@pytest.mark.asyncio +async def test_decryption_error_with_no_key( + db_store_parameterized: DatabasePushNotificationConfigStore, +): + """Test that using the wrong key to decrypt raises a ValueError.""" + # 1. Store with one key + + task_id = 'wrong-key-task' + config = PushNotificationConfig(id='config-1', url='http://secret.url') + await db_store_parameterized.set_info(task_id, config) + + # 2. Try to read with no key set + # Directly query the database to inspect the raw data + store2 = DatabasePushNotificationConfigStore(db_store_parameterized.engine) + + retrieved_configs = await store2.get_info(task_id) + assert retrieved_configs == [] + + # _from_orm should raise a ValueError + async_session = async_sessionmaker( + db_store_parameterized.engine, expire_on_commit=False + ) + async with async_session() as session: + db_model = await session.get( + PushNotificationConfigModel, (task_id, 'config-1') + ) + + with pytest.raises(ValueError) as exc_info: + store2._from_orm(db_model) # type: ignore + + +@pytest.mark.asyncio +async def test_custom_table_name( + db_store_parameterized: DatabasePushNotificationConfigStore, +): + """Test that the store works correctly with a custom table name.""" + table_name = 'my_custom_push_configs' + + task_id = 'custom-table-task' + config = PushNotificationConfig(id='config-1', url='http://custom.url') + + # This will create the table on first use + await db_store_parameterized.set_info(task_id, config) + retrieved_configs = await db_store_parameterized.get_info(task_id) + + assert len(retrieved_configs) == 1 + assert retrieved_configs[0] == config + + # Verify the custom table exists and has data + async with db_store_parameterized.engine.connect() as conn: + result = await conn.execute( + select(db_store_parameterized.config_model).where( + db_store_parameterized.config_model.task_id == task_id + ) + ) + assert result.scalar_one_or_none() is not None + + +@pytest.mark.asyncio +async def test_set_and_get_info_multiple_configs_no_key( + db_store_parameterized: DatabasePushNotificationConfigStore, +): + """Test setting and retrieving multiple configurations for a single task.""" + + store = DatabasePushNotificationConfigStore( + engine=db_store_parameterized.engine, + create_table=False, + encryption_key=None, # No encryption key + ) + await store.initialize() + + task_id = 'task-1' + config1 = PushNotificationConfig(id='config-1', url='http://example.com/1') + config2 = PushNotificationConfig(id='config-2', url='http://example.com/2') + + await db_store_parameterized.set_info(task_id, config1) + await db_store_parameterized.set_info(task_id, config2) + retrieved_configs = await db_store_parameterized.get_info(task_id) + + assert len(retrieved_configs) == 2 + assert config1 in retrieved_configs + assert config2 in retrieved_configs + + +@pytest.mark.asyncio +async def test_data_is_not_encrypted_in_db_if_no_key_is_set( + db_store_parameterized: DatabasePushNotificationConfigStore, +): + """Test data is not encrypted when no encryption key is set.""" + + store = DatabasePushNotificationConfigStore( + engine=db_store_parameterized.engine, + create_table=False, + encryption_key=None, # No encryption key + ) + await store.initialize() + + task_id = 'task-1' + config = PushNotificationConfig(id='config-1', url='http://example.com/1') + plain_json = config.model_dump_json() + + await store.set_info(task_id, config) + + # Directly query the database to inspect the raw data + async_session = async_sessionmaker( + db_store_parameterized.engine, expire_on_commit=False + ) + async with async_session() as session: + stmt = select(PushNotificationConfigModel).where( + PushNotificationConfigModel.task_id == task_id + ) + result = await session.execute(stmt) + db_model = result.scalar_one() + + assert db_model.config_data == plain_json.encode('utf-8') diff --git a/uv.lock b/uv.lock index 453d33d5..70a6421e 100644 --- a/uv.lock +++ b/uv.lock @@ -26,6 +26,9 @@ dependencies = [ ] [package.optional-dependencies] +encryption = [ + { name = "cryptography" }, +] mysql = [ { name = "sqlalchemy", extra = ["aiomysql", "asyncio"] }, ] @@ -57,6 +60,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "cryptography", marker = "extra == 'encryption'", specifier = ">=43.0.0" }, { name = "fastapi", specifier = ">=0.115.2" }, { name = "google-api-core", specifier = ">=1.26.0" }, { name = "grpcio", specifier = ">=1.60" }, @@ -75,7 +79,7 @@ requires-dist = [ { name = "sse-starlette" }, { name = "starlette" }, ] -provides-extras = ["mysql", "postgresql", "sql", "sqlite"] +provides-extras = ["encryption", "mysql", "postgresql", "sql", "sqlite"] [package.metadata.requires-dev] dev = [ @@ -254,6 +258,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650 }, ] +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 }, + { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 }, + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +] + [[package]] name = "cfgv" version = "3.4.0" @@ -414,6 +475,53 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "45.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/fb/09e28bc0c46d2c547085e60897fea96310574c70fb21cd58a730a45f3403/cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8", size = 7043092 }, + { url = "https://files.pythonhosted.org/packages/b1/05/2194432935e29b91fb649f6149c1a4f9e6d3d9fc880919f4ad1bcc22641e/cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d", size = 4205926 }, + { url = "https://files.pythonhosted.org/packages/07/8b/9ef5da82350175e32de245646b1884fc01124f53eb31164c77f95a08d682/cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5", size = 4429235 }, + { url = "https://files.pythonhosted.org/packages/7c/e1/c809f398adde1994ee53438912192d92a1d0fc0f2d7582659d9ef4c28b0c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57", size = 4209785 }, + { url = "https://files.pythonhosted.org/packages/d0/8b/07eb6bd5acff58406c5e806eff34a124936f41a4fb52909ffa4d00815f8c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0", size = 3893050 }, + { url = "https://files.pythonhosted.org/packages/ec/ef/3333295ed58d900a13c92806b67e62f27876845a9a908c939f040887cca9/cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d", size = 4457379 }, + { url = "https://files.pythonhosted.org/packages/d9/9d/44080674dee514dbb82b21d6fa5d1055368f208304e2ab1828d85c9de8f4/cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9", size = 4209355 }, + { url = "https://files.pythonhosted.org/packages/c9/d8/0749f7d39f53f8258e5c18a93131919ac465ee1f9dccaf1b3f420235e0b5/cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27", size = 4456087 }, + { url = "https://files.pythonhosted.org/packages/09/d7/92acac187387bf08902b0bf0699816f08553927bdd6ba3654da0010289b4/cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e", size = 4332873 }, + { url = "https://files.pythonhosted.org/packages/03/c2/840e0710da5106a7c3d4153c7215b2736151bba60bf4491bdb421df5056d/cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174", size = 4564651 }, + { url = "https://files.pythonhosted.org/packages/2e/92/cc723dd6d71e9747a887b94eb3827825c6c24b9e6ce2bb33b847d31d5eaa/cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9", size = 2929050 }, + { url = "https://files.pythonhosted.org/packages/1f/10/197da38a5911a48dd5389c043de4aec4b3c94cb836299b01253940788d78/cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63", size = 3403224 }, + { url = "https://files.pythonhosted.org/packages/fe/2b/160ce8c2765e7a481ce57d55eba1546148583e7b6f85514472b1d151711d/cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8", size = 7017143 }, + { url = "https://files.pythonhosted.org/packages/c2/e7/2187be2f871c0221a81f55ee3105d3cf3e273c0a0853651d7011eada0d7e/cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd", size = 4197780 }, + { url = "https://files.pythonhosted.org/packages/b9/cf/84210c447c06104e6be9122661159ad4ce7a8190011669afceeaea150524/cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e", size = 4420091 }, + { url = "https://files.pythonhosted.org/packages/3e/6a/cb8b5c8bb82fafffa23aeff8d3a39822593cee6e2f16c5ca5c2ecca344f7/cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0", size = 4198711 }, + { url = "https://files.pythonhosted.org/packages/04/f7/36d2d69df69c94cbb2473871926daf0f01ad8e00fe3986ac3c1e8c4ca4b3/cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135", size = 3883299 }, + { url = "https://files.pythonhosted.org/packages/82/c7/f0ea40f016de72f81288e9fe8d1f6748036cb5ba6118774317a3ffc6022d/cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7", size = 4450558 }, + { url = "https://files.pythonhosted.org/packages/06/ae/94b504dc1a3cdf642d710407c62e86296f7da9e66f27ab12a1ee6fdf005b/cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42", size = 4198020 }, + { url = "https://files.pythonhosted.org/packages/05/2b/aaf0adb845d5dabb43480f18f7ca72e94f92c280aa983ddbd0bcd6ecd037/cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492", size = 4449759 }, + { url = "https://files.pythonhosted.org/packages/91/e4/f17e02066de63e0100a3a01b56f8f1016973a1d67551beaf585157a86b3f/cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0", size = 4319991 }, + { url = "https://files.pythonhosted.org/packages/f2/2e/e2dbd629481b499b14516eed933f3276eb3239f7cee2dcfa4ee6b44d4711/cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a", size = 4554189 }, + { url = "https://files.pythonhosted.org/packages/f8/ea/a78a0c38f4c8736287b71c2ea3799d173d5ce778c7d6e3c163a95a05ad2a/cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f", size = 2911769 }, + { url = "https://files.pythonhosted.org/packages/79/b3/28ac139109d9005ad3f6b6f8976ffede6706a6478e21c889ce36c840918e/cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97", size = 3390016 }, + { url = "https://files.pythonhosted.org/packages/f8/8b/34394337abe4566848a2bd49b26bcd4b07fd466afd3e8cce4cb79a390869/cryptography-45.0.5-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:206210d03c1193f4e1ff681d22885181d47efa1ab3018766a7b32a7b3d6e6afd", size = 3575762 }, + { url = "https://files.pythonhosted.org/packages/8b/5d/a19441c1e89afb0f173ac13178606ca6fab0d3bd3ebc29e9ed1318b507fc/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c648025b6840fe62e57107e0a25f604db740e728bd67da4f6f060f03017d5097", size = 4140906 }, + { url = "https://files.pythonhosted.org/packages/4b/db/daceb259982a3c2da4e619f45b5bfdec0e922a23de213b2636e78ef0919b/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b8fa8b0a35a9982a3c60ec79905ba5bb090fc0b9addcfd3dc2dd04267e45f25e", size = 4374411 }, + { url = "https://files.pythonhosted.org/packages/6a/35/5d06ad06402fc522c8bf7eab73422d05e789b4e38fe3206a85e3d6966c11/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:14d96584701a887763384f3c47f0ca7c1cce322aa1c31172680eb596b890ec30", size = 4140942 }, + { url = "https://files.pythonhosted.org/packages/65/79/020a5413347e44c382ef1f7f7e7a66817cd6273e3e6b5a72d18177b08b2f/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57c816dfbd1659a367831baca4b775b2a5b43c003daf52e9d57e1d30bc2e1b0e", size = 4374079 }, + { url = "https://files.pythonhosted.org/packages/9b/c5/c0e07d84a9a2a8a0ed4f865e58f37c71af3eab7d5e094ff1b21f3f3af3bc/cryptography-45.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b9e38e0a83cd51e07f5a48ff9691cae95a79bea28fe4ded168a8e5c6c77e819d", size = 3321362 }, + { url = "https://files.pythonhosted.org/packages/c0/71/9bdbcfd58d6ff5084687fe722c58ac718ebedbc98b9f8f93781354e6d286/cryptography-45.0.5-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8c4a6ff8a30e9e3d38ac0539e9a9e02540ab3f827a3394f8852432f6b0ea152e", size = 3587878 }, + { url = "https://files.pythonhosted.org/packages/f0/63/83516cfb87f4a8756eaa4203f93b283fda23d210fc14e1e594bd5f20edb6/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6", size = 4152447 }, + { url = "https://files.pythonhosted.org/packages/22/11/d2823d2a5a0bd5802b3565437add16f5c8ce1f0778bf3822f89ad2740a38/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18", size = 4386778 }, + { url = "https://files.pythonhosted.org/packages/5f/38/6bf177ca6bce4fe14704ab3e93627c5b0ca05242261a2e43ef3168472540/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:12e55281d993a793b0e883066f590c1ae1e802e3acb67f8b442e721e475e6463", size = 4151627 }, + { url = "https://files.pythonhosted.org/packages/38/6a/69fc67e5266bff68a91bcb81dff8fb0aba4d79a78521a08812048913e16f/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:5aa1e32983d4443e310f726ee4b071ab7569f58eedfdd65e9675484a4eb67bd1", size = 4385593 }, + { url = "https://files.pythonhosted.org/packages/f6/34/31a1604c9a9ade0fdab61eb48570e09a796f4d9836121266447b0eaf7feb/cryptography-45.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e357286c1b76403dd384d938f93c46b2b058ed4dfcdce64a770f0537ed3feb6f", size = 3331106 }, +] + [[package]] name = "datamodel-code-generator" version = "0.31.1" @@ -1105,6 +1213,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 }, ] +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + [[package]] name = "pydantic" version = "2.11.7" From 78713799c8734178b7351056763162b9937a8e3f Mon Sep 17 00:00:00 2001 From: Krishna Thota Date: Fri, 11 Jul 2025 14:42:52 -0700 Subject: [PATCH 03/10] Fix for custom table names --- ...database_push_notification_config_store.py | 61 +++++++-- src/a2a/server/tasks/database_task_store.py | 12 +- ...database_push_notification_config_store.py | 122 +++++++++++++++--- 3 files changed, 162 insertions(+), 33 deletions(-) diff --git a/src/a2a/server/tasks/database_push_notification_config_store.py b/src/a2a/server/tasks/database_push_notification_config_store.py index f67670de..ecc66541 100644 --- a/src/a2a/server/tasks/database_push_notification_config_store.py +++ b/src/a2a/server/tasks/database_push_notification_config_store.py @@ -3,9 +3,12 @@ from typing import TYPE_CHECKING +from pydantic import ValidationError + try: from sqlalchemy import ( + Table, delete, select, ) @@ -14,6 +17,7 @@ AsyncSession, async_sessionmaker, ) + from sqlalchemy.orm import class_mapper except ImportError as e: raise ImportError( 'DatabasePushNotificationConfigStore requires SQLAlchemy and a database driver. ' @@ -115,7 +119,13 @@ async def initialize(self) -> None: ) if self.create_table: async with self.engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) + mapper = class_mapper(self.config_model) + tables_to_create = [ + table for table in mapper.tables if isinstance(table, Table) + ] + await conn.run_sync( + Base.metadata.create_all, tables=tables_to_create + ) self._initialized = True logger.debug( 'Database schema for push notification configs initialized.' @@ -151,7 +161,7 @@ def _from_orm( ) -> PushNotificationConfig: """Maps a SQLAlchemy model instance to a Pydantic PushNotificationConfig. - Handles decryption if a key is configured. + Handles decryption if a key is configured, with a fallback to plain JSON. """ payload = model_instance.config_data @@ -163,26 +173,51 @@ def _from_orm( return PushNotificationConfig.model_validate_json( decrypted_payload ) + except (json.JSONDecodeError, ValidationError) as e: + logger.error( + 'Failed to parse decrypted push notification config for task %s, config %s. ' + 'Data is corrupted or not valid JSON after decryption.', + model_instance.task_id, + model_instance.config_id, + ) + raise ValueError( + 'Failed to parse decrypted push notification config data' + ) from e except InvalidToken: - # This could be unencrypted data if encryption was enabled after data was stored. - # We'll fall through and try to parse it as plain JSON. - logger.debug( - 'Could not decrypt config for task %s, config %s. ' - 'Attempting to parse as unencrypted JSON.', + # Decryption failed. This could be because the data is not encrypted. + # We'll log a warning and try to parse it as plain JSON as a fallback. + logger.warning( + 'Failed to decrypt push notification config for task %s, config %s. ' + 'Attempting to parse as unencrypted JSON. ' + 'This may indicate an incorrect encryption key or unencrypted data in the database.', model_instance.task_id, model_instance.config_id, ) + # Fall through to the unencrypted parsing logic below. - # If no fernet or if decryption failed, try to parse as plain JSON. + # Try to parse as plain JSON. try: return PushNotificationConfig.model_validate_json(payload) - except json.JSONDecodeError as e: + except (json.JSONDecodeError, ValidationError) as e: if self._fernet: - raise ValueError( - 'Failed to decrypt data; incorrect key or corrupted data.' - ) from e + logger.error( + 'Failed to parse push notification config for task %s, config %s. ' + 'Decryption failed and the data is not valid JSON. ' + 'This likely indicates the data is corrupted or encrypted with a different key.', + model_instance.task_id, + model_instance.config_id, + ) + else: + # if no key is configured and the payload is not valid JSON. + logger.error( + 'Failed to parse push notification config for task %s, config %s. ' + 'Data is not valid JSON and no encryption key is configured.', + model_instance.task_id, + model_instance.config_id, + ) raise ValueError( - 'Failed to parse data; it may be encrypted but no key is configured.' + 'Failed to parse push notification config data. ' + 'Data is not valid JSON, or it is encrypted with the wrong key.' ) from e async def set_info( diff --git a/src/a2a/server/tasks/database_task_store.py b/src/a2a/server/tasks/database_task_store.py index 7e85ddd4..70b02e10 100644 --- a/src/a2a/server/tasks/database_task_store.py +++ b/src/a2a/server/tasks/database_task_store.py @@ -2,12 +2,13 @@ try: - from sqlalchemy import delete, select + from sqlalchemy import Table, delete, select from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, async_sessionmaker, ) + from sqlalchemy.orm import class_mapper except ImportError as e: raise ImportError( 'DatabaseTaskStore requires SQLAlchemy and a database driver. ' @@ -75,8 +76,13 @@ async def initialize(self) -> None: logger.debug('Initializing database schema...') if self.create_table: async with self.engine.begin() as conn: - # This will create the 'tasks' table based on TaskModel's definition - await conn.run_sync(Base.metadata.create_all) + mapper = class_mapper(self.task_model) + tables_to_create = [ + table for table in mapper.tables if isinstance(table, Table) + ] + await conn.run_sync( + Base.metadata.create_all, tables=tables_to_create + ) self._initialized = True logger.debug('Database schema initialized.') diff --git a/tests/server/tasks/test_database_push_notification_config_store.py b/tests/server/tasks/test_database_push_notification_config_store.py index e235d1b2..b4971c84 100644 --- a/tests/server/tasks/test_database_push_notification_config_store.py +++ b/tests/server/tasks/test_database_push_notification_config_store.py @@ -394,25 +394,47 @@ async def test_custom_table_name( ): """Test that the store works correctly with a custom table name.""" table_name = 'my_custom_push_configs' + engine = db_store_parameterized.engine + custom_store = None + try: + # Use a new store with a custom table name + custom_store = DatabasePushNotificationConfigStore( + engine=engine, + create_table=True, + table_name=table_name, + encryption_key=Fernet.generate_key(), + ) - task_id = 'custom-table-task' - config = PushNotificationConfig(id='config-1', url='http://custom.url') + task_id = 'custom-table-task' + config = PushNotificationConfig(id='config-1', url='http://custom.url') - # This will create the table on first use - await db_store_parameterized.set_info(task_id, config) - retrieved_configs = await db_store_parameterized.get_info(task_id) + # This will create the table on first use + await custom_store.set_info(task_id, config) + retrieved_configs = await custom_store.get_info(task_id) - assert len(retrieved_configs) == 1 - assert retrieved_configs[0] == config + assert len(retrieved_configs) == 1 + assert retrieved_configs[0] == config - # Verify the custom table exists and has data - async with db_store_parameterized.engine.connect() as conn: - result = await conn.execute( - select(db_store_parameterized.config_model).where( - db_store_parameterized.config_model.task_id == task_id + # Verify the custom table exists and has data + async with custom_store.engine.connect() as conn: + + def has_table_sync(sync_conn): + inspector = inspect(sync_conn) + return inspector.has_table(table_name) + + assert await conn.run_sync(has_table_sync) + + result = await conn.execute( + select(custom_store.config_model).where( + custom_store.config_model.task_id == task_id + ) ) - ) - assert result.scalar_one_or_none() is not None + assert result.scalar_one_or_none() is not None + finally: + if custom_store: + # Clean up the dynamically created table from the metadata + # to prevent errors in subsequent parameterized test runs. + Base.metadata.remove(custom_store.config_model.__table__) # type: ignore @pytest.mark.asyncio @@ -432,9 +454,9 @@ async def test_set_and_get_info_multiple_configs_no_key( config1 = PushNotificationConfig(id='config-1', url='http://example.com/1') config2 = PushNotificationConfig(id='config-2', url='http://example.com/2') - await db_store_parameterized.set_info(task_id, config1) - await db_store_parameterized.set_info(task_id, config2) - retrieved_configs = await db_store_parameterized.get_info(task_id) + await store.set_info(task_id, config1) + await store.set_info(task_id, config2) + retrieved_configs = await store.get_info(task_id) assert len(retrieved_configs) == 2 assert config1 in retrieved_configs @@ -472,3 +494,69 @@ async def test_data_is_not_encrypted_in_db_if_no_key_is_set( db_model = result.scalar_one() assert db_model.config_data == plain_json.encode('utf-8') + + +@pytest.mark.asyncio +async def test_decryption_fallback_for_unencrypted_data( + db_store_parameterized: DatabasePushNotificationConfigStore, +): + """Test reading unencrypted data with an encryption-enabled store.""" + # 1. Store unencrypted data using a new store instance without a key + unencrypted_store = DatabasePushNotificationConfigStore( + engine=db_store_parameterized.engine, + create_table=False, # Table already exists from fixture + encryption_key=None, + ) + await unencrypted_store.initialize() + + task_id = 'mixed-encryption-task' + config = PushNotificationConfig(id='config-1', url='http://plain.url') + await unencrypted_store.set_info(task_id, config) + + # 2. Try to read with the encryption-enabled store from the fixture + retrieved_configs = await db_store_parameterized.get_info(task_id) + + # Should fall back to parsing as plain JSON and not fail + assert len(retrieved_configs) == 1 + assert retrieved_configs[0] == config + + +@pytest.mark.asyncio +async def test_parsing_error_after_successful_decryption( + db_store_parameterized: DatabasePushNotificationConfigStore, +): + """Test that a parsing error after successful decryption is handled.""" + + task_id = 'corrupted-data-task' + config_id = 'config-1' + + # 1. Encrypt data that is NOT valid JSON + fernet = Fernet(Fernet.generate_key()) + corrupted_payload = b'this is not valid json' + encrypted_data = fernet.encrypt(corrupted_payload) + + # 2. Manually insert this corrupted data into the DB + async_session = async_sessionmaker( + db_store_parameterized.engine, expire_on_commit=False + ) + async with async_session() as session: + db_model = PushNotificationConfigModel( + task_id=task_id, + config_id=config_id, + config_data=encrypted_data, + ) + session.add(db_model) + await session.commit() + + # 3. get_info should log an error and return an empty list + retrieved_configs = await db_store_parameterized.get_info(task_id) + assert retrieved_configs == [] + + # 4. _from_orm should raise a ValueError + async with async_session() as session: + db_model_retrieved = await session.get( + PushNotificationConfigModel, (task_id, config_id) + ) + + with pytest.raises(ValueError) as exc_info: + db_store_parameterized._from_orm(db_model_retrieved) # type: ignore From 07dc4e4ad364ab00ca7ce92ee7bb9b069b65669c Mon Sep 17 00:00:00 2001 From: Krishna Thota Date: Fri, 11 Jul 2025 14:46:36 -0700 Subject: [PATCH 04/10] Spelling fixes --- .github/actions/spelling/allow.txt | 1 + src/a2a/server/tasks/database_push_notification_config_store.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 6f8229ad..d7a44dcc 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -72,3 +72,4 @@ taskupdate testuuid typeerror vulnz +fernet diff --git a/src/a2a/server/tasks/database_push_notification_config_store.py b/src/a2a/server/tasks/database_push_notification_config_store.py index ecc66541..21e38069 100644 --- a/src/a2a/server/tasks/database_push_notification_config_store.py +++ b/src/a2a/server/tasks/database_push_notification_config_store.py @@ -284,5 +284,5 @@ async def delete_info( ) else: logger.warning( - f'Attempted to delete non-existent push notification config for task {task_id} with config_id: {config_id}' + f'Attempted to delete push notification config for task {task_id} with config_id: {config_id} that does not exist.' ) From a4572356b167875e5f2d05b263285cc665416723 Mon Sep 17 00:00:00 2001 From: Krishna Thota Date: Mon, 14 Jul 2025 10:19:14 -0700 Subject: [PATCH 05/10] Refactor to optionally load DatabasePushNotificationConfigStore --- src/a2a/server/tasks/__init__.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/a2a/server/tasks/__init__.py b/src/a2a/server/tasks/__init__.py index f090a2f4..641195ea 100644 --- a/src/a2a/server/tasks/__init__.py +++ b/src/a2a/server/tasks/__init__.py @@ -5,9 +5,6 @@ from a2a.server.tasks.base_push_notification_sender import ( BasePushNotificationSender, ) -from a2a.server.tasks.database_push_notification_config_store import ( - DatabasePushNotificationConfigStore, -) from a2a.server.tasks.inmemory_push_notification_config_store import ( InMemoryPushNotificationConfigStore, ) @@ -46,6 +43,28 @@ def __init__(self, *args, **kwargs): ) from _original_error +try: + from a2a.server.tasks.database_push_notification_config_store import ( + DatabasePushNotificationConfigStore, # type: ignore + ) +except ImportError as e: + _original_error = e + # If the database push notification config store is not available, we can still use in-memory stores. + logger.debug( + 'DatabasePushNotificationConfigStore not loaded. This is expected if database dependencies are not installed. Error: %s', + e, + ) + + class DatabasePushNotificationConfigStore: # type: ignore + """Placeholder for DatabasePushNotificationConfigStore when dependencies are not installed.""" + + def __init__(self, *args, **kwargs): + raise ImportError( + 'To use DatabasePushNotificationConfigStore, its dependencies must be installed. ' + 'You can install them with \'pip install "a2a-sdk[sql]"\'' + ) from _original_error + + __all__ = [ 'BasePushNotificationSender', 'DatabasePushNotificationConfigStore', From b308f91d5a2dbe0ece539d073de0b11ca518ddf5 Mon Sep 17 00:00:00 2001 From: Krishna Thota Date: Mon, 14 Jul 2025 10:24:15 -0700 Subject: [PATCH 06/10] Fix exporting POSTGRES_TEST_DSN for CI --- .github/workflows/unit-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 6e46a815..55f72d2c 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -35,7 +35,7 @@ jobs: run: | sudo apt-get update && sudo apt-get install -y postgresql-client PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d a2a_test -f ${{ github.workspace }}/tests/docker/postgres/init.sql - export POSTGRES_TEST_DSN="postgresql+asyncpg://postgres:postgres@localhost:5432/a2a_test" + echo "POSTGRES_TEST_DSN=postgresql+asyncpg://postgres:postgres@localhost:5432/a2a_test" >> $GITHUB_ENV - name: Install uv uses: astral-sh/setup-uv@v6 From 7b5bc976dd28a7492d4f7311e1ee75ab563dc166 Mon Sep 17 00:00:00 2001 From: Krishna Thota Date: Mon, 14 Jul 2025 10:42:29 -0700 Subject: [PATCH 07/10] Enable Mysql tests --- .github/workflows/unit-tests.yml | 22 ++++++++++++++++------ tests/docker/mysql/init.sql | 8 ++++++++ tests/docker/postgres/init.sql | 2 +- 3 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 tests/docker/mysql/init.sql diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 55f72d2c..7b95fd95 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -17,9 +17,20 @@ jobs: env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres - POSTGRES_DB: a2a_test ports: - 5432:5432 + volumes: + - ${{ github.workspace }}/tests/docker/postgres:/docker-entrypoint-initdb.d + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + volumes: + - ${{ github.workspace }}/tests/docker/mysql:/docker-entrypoint-initdb.d + options: >- + --health-cmd="mysqladmin ping -h localhost -u root -proot" --health-interval=10s --health-timeout=5s --health-retries=5 strategy: matrix: @@ -31,11 +42,10 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Set postgres for tests - run: | - sudo apt-get update && sudo apt-get install -y postgresql-client - PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d a2a_test -f ${{ github.workspace }}/tests/docker/postgres/init.sql - echo "POSTGRES_TEST_DSN=postgresql+asyncpg://postgres:postgres@localhost:5432/a2a_test" >> $GITHUB_ENV + - name: Set up databases for tests + run: | + echo "POSTGRES_TEST_DSN=postgresql+asyncpg://a2a:a2a_password@localhost:5432/a2a_test" >> $GITHUB_ENV + echo "MYSQL_TEST_DSN=mysql+aiomysql://a2a:a2a_password@localhost:3306/a2a_test" >> $GITHUB_ENV - name: Install uv uses: astral-sh/setup-uv@v6 diff --git a/tests/docker/mysql/init.sql b/tests/docker/mysql/init.sql new file mode 100644 index 00000000..f60d66fe --- /dev/null +++ b/tests/docker/mysql/init.sql @@ -0,0 +1,8 @@ +-- Create a dedicated user for the application +CREATE USER a2a WITH PASSWORD 'a2a_password'; + +-- Create the tasks database +CREATE DATABASE a2a_test; + +GRANT ALL PRIVILEGES ON DATABASE a2a_test TO a2a; + diff --git a/tests/docker/postgres/init.sql b/tests/docker/postgres/init.sql index 86b1dd02..f60d66fe 100644 --- a/tests/docker/postgres/init.sql +++ b/tests/docker/postgres/init.sql @@ -2,7 +2,7 @@ CREATE USER a2a WITH PASSWORD 'a2a_password'; -- Create the tasks database -CREATE DATABASE a2a_tasks; +CREATE DATABASE a2a_test; GRANT ALL PRIVILEGES ON DATABASE a2a_test TO a2a; From 5441e01e333b0e143970d7593c33e179cebf35cd Mon Sep 17 00:00:00 2001 From: Krishna Thota Date: Mon, 14 Jul 2025 10:49:56 -0700 Subject: [PATCH 08/10] Fix for CI tests --- .github/workflows/unit-tests.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 7b95fd95..88beeb1c 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -15,20 +15,22 @@ jobs: postgres: image: postgres:15-alpine env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres + POSTGRES_USER: a2a + POSTGRES_PASSWORD: a2a_password + POSTGRES_DB: a2a_test ports: - 5432:5432 - volumes: - - ${{ github.workspace }}/tests/docker/postgres:/docker-entrypoint-initdb.d + options: >- + --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 mysql: image: mysql:8.0 env: MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: a2a_test + MYSQL_USER: a2a + MYSQL_PASSWORD: a2a_password ports: - 3306:3306 - volumes: - - ${{ github.workspace }}/tests/docker/mysql:/docker-entrypoint-initdb.d options: >- --health-cmd="mysqladmin ping -h localhost -u root -proot" --health-interval=10s --health-timeout=5s --health-retries=5 @@ -42,7 +44,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Set up databases for tests + - name: Set up test environment variables run: | echo "POSTGRES_TEST_DSN=postgresql+asyncpg://a2a:a2a_password@localhost:5432/a2a_test" >> $GITHUB_ENV echo "MYSQL_TEST_DSN=mysql+aiomysql://a2a:a2a_password@localhost:3306/a2a_test" >> $GITHUB_ENV From 27845a0790e85707b8050e8c5b74e281cc558f89 Mon Sep 17 00:00:00 2001 From: Krishna Thota Date: Mon, 14 Jul 2025 10:52:21 -0700 Subject: [PATCH 09/10] Remove init.sql --- tests/docker/mysql/init.sql | 8 -------- tests/docker/postgres/init.sql | 8 -------- 2 files changed, 16 deletions(-) delete mode 100644 tests/docker/mysql/init.sql delete mode 100644 tests/docker/postgres/init.sql diff --git a/tests/docker/mysql/init.sql b/tests/docker/mysql/init.sql deleted file mode 100644 index f60d66fe..00000000 --- a/tests/docker/mysql/init.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Create a dedicated user for the application -CREATE USER a2a WITH PASSWORD 'a2a_password'; - --- Create the tasks database -CREATE DATABASE a2a_test; - -GRANT ALL PRIVILEGES ON DATABASE a2a_test TO a2a; - diff --git a/tests/docker/postgres/init.sql b/tests/docker/postgres/init.sql deleted file mode 100644 index f60d66fe..00000000 --- a/tests/docker/postgres/init.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Create a dedicated user for the application -CREATE USER a2a WITH PASSWORD 'a2a_password'; - --- Create the tasks database -CREATE DATABASE a2a_test; - -GRANT ALL PRIVILEGES ON DATABASE a2a_test TO a2a; - From 05a64e320273423b2da9c36b0a6007ff1129e603 Mon Sep 17 00:00:00 2001 From: Holt Skinner Date: Tue, 15 Jul 2025 17:01:48 +0100 Subject: [PATCH 10/10] Formatting --- .github/actions/spelling/allow.txt | 2 +- .../tasks/test_database_push_notification_config_store.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index d7a44dcc..d7595c1a 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -38,6 +38,7 @@ drivername dunders euo excinfo +fernet fetchrow fetchval genai @@ -72,4 +73,3 @@ taskupdate testuuid typeerror vulnz -fernet diff --git a/tests/server/tasks/test_database_push_notification_config_store.py b/tests/server/tasks/test_database_push_notification_config_store.py index b4971c84..95222558 100644 --- a/tests/server/tasks/test_database_push_notification_config_store.py +++ b/tests/server/tasks/test_database_push_notification_config_store.py @@ -6,11 +6,12 @@ import pytest_asyncio from _pytest.mark.structures import ParameterSet +from sqlalchemy import select from sqlalchemy.ext.asyncio import ( async_sessionmaker, create_async_engine, ) -from sqlalchemy import select + # Skip entire test module if SQLAlchemy is not installed pytest.importorskip('sqlalchemy', reason='Database tests require SQLAlchemy') @@ -20,19 +21,20 @@ ) # Now safe to import SQLAlchemy-dependent modules +from cryptography.fernet import Fernet from sqlalchemy.inspection import inspect + from a2a.server.models import ( Base, PushNotificationConfigModel, ) # Important: To get Base.metadata from a2a.server.tasks import DatabasePushNotificationConfigStore from a2a.types import ( + PushNotificationConfig, Task, TaskState, TaskStatus, - PushNotificationConfig, ) -from cryptography.fernet import Fernet # DSNs for different databases