From a32a6ddbf0af483bf306c3d75378987a70b8addf Mon Sep 17 00:00:00 2001 From: Sathvik Bhagavan Date: Sat, 25 Oct 2025 18:55:10 +0200 Subject: [PATCH 1/6] refactor: consume AgentCard as well in A2AClient --- fasta2a/client.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/fasta2a/client.py b/fasta2a/client.py index cd84499..197df48 100644 --- a/fasta2a/client.py +++ b/fasta2a/client.py @@ -6,6 +6,7 @@ import pydantic from .schema import ( + AgentCard, GetTaskRequest, GetTaskResponse, Message, @@ -31,7 +32,23 @@ class A2AClient: """A client for the A2A protocol.""" - def __init__(self, base_url: str = 'http://localhost:8000', http_client: httpx.AsyncClient | None = None) -> None: + def __init__( + self, + agent: str | AgentCard, + http_client: httpx.AsyncClient | None = None, + fetch_card: bool = False, + relative_card_path: str | None = None, + ) -> None: + self.agent_card = None + if fetch_card and isinstance(agent, str): + if relative_card_path is None: + relative_card_path = "/.well-known/agent-card.json" + agent_url = agent.rstrip("/") + relative_card_path + response = httpx.get(agent_url) + response.raise_for_status() + agent = AgentCard(**response.json()) + self.agent_card = agent + base_url = agent if isinstance(agent, str) else agent['url'] if http_client is None: self.http_client = httpx.AsyncClient(base_url=base_url) else: From 5e469fb55459be61abf460eb326cf1a93ce32836 Mon Sep 17 00:00:00 2001 From: Sathvik Bhagavan Date: Sat, 25 Oct 2025 18:55:29 +0200 Subject: [PATCH 2/6] test: add tests for A2AClient --- tests/test_client.py | 95 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/test_client.py diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..df94f78 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,95 @@ +import asyncio +import socket +import threading + +import httpx +import pytest +import pytest_asyncio +import uvicorn + +from fasta2a.applications import FastA2A +from fasta2a.broker import InMemoryBroker +from fasta2a.client import A2AClient +from fasta2a.storage import InMemoryStorage + +SERVER_HOST = "127.0.0.1" + + +def get_free_port() -> int: + """Ask OS for a free port.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + return s.getsockname()[1] + + +async def _wait_server(url: str, retries: int = 100, delay: float = 0.1): + """Wait until the server responds (any response, even 404).""" + async with httpx.AsyncClient() as client: + for _ in range(retries): + try: + await client.get(url) + return + except httpx.RequestError: + await asyncio.sleep(delay) + raise RuntimeError(f'Server at {url} did not start in time') + + +def _start_server_in_thread(app, host: str, port: int): + """Run uvicorn server in a background thread.""" + + def _run_uvicorn(): + uvicorn.run(app, host=host, port=port, log_level='error') + + thread = threading.Thread(target=_run_uvicorn, daemon=True) + thread.start() + + +@pytest_asyncio.fixture(scope='function') +async def run_server(request): + params = getattr(request, 'param', {}) + port = get_free_port() + url = f'http://{SERVER_HOST}:{port}' + + app = FastA2A( + storage=InMemoryStorage(), + broker=InMemoryBroker(), + url=url, + name=params.get('name'), + description=params.get('description'), + ) + + _start_server_in_thread(app, SERVER_HOST, port) + await _wait_server(url) + yield url + + +# ---------------------- +# Tests +# ---------------------- + + +@pytest.mark.asyncio +async def test_client_basic(run_server): + client = A2AClient(agent=run_server) + assert str(client.http_client.base_url) == run_server + + +@pytest.mark.asyncio +async def test_client_fetch_card(run_server): + client = A2AClient(agent=run_server, fetch_card=True) + assert client._agent_card is not None + assert client.http_client.base_url == run_server + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'run_server', + [{'name': 'Test Agent', 'description': 'A test agent for unit tests.'}], + indirect=True, +) +async def test_client_check_agent_card(run_server): + client = A2AClient(agent=run_server, fetch_card=True) + assert client.http_client.base_url == run_server + assert client._agent_card is not None + assert client._agent_card['name'] == 'Test Agent' + assert client._agent_card['description'] == 'A test agent for unit tests.' From 6aab7bba6a79829fb4e3ae520aff2333171303b2 Mon Sep 17 00:00:00 2001 From: Sathvik Bhagavan Date: Sat, 25 Oct 2025 18:58:55 +0200 Subject: [PATCH 3/6] chore: format --- fasta2a/client.py | 6 +-- tests/test_client.py | 88 +++++++++++++++++++++++++------------------- 2 files changed, 54 insertions(+), 40 deletions(-) diff --git a/fasta2a/client.py b/fasta2a/client.py index 197df48..0198f66 100644 --- a/fasta2a/client.py +++ b/fasta2a/client.py @@ -42,12 +42,12 @@ def __init__( self.agent_card = None if fetch_card and isinstance(agent, str): if relative_card_path is None: - relative_card_path = "/.well-known/agent-card.json" - agent_url = agent.rstrip("/") + relative_card_path + relative_card_path = '/.well-known/agent-card.json' + agent_url = agent.rstrip('/') + relative_card_path response = httpx.get(agent_url) response.raise_for_status() agent = AgentCard(**response.json()) - self.agent_card = agent + self._agent_card = agent base_url = agent if isinstance(agent, str) else agent['url'] if http_client is None: self.http_client = httpx.AsyncClient(base_url=base_url) diff --git a/tests/test_client.py b/tests/test_client.py index df94f78..3948641 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -12,54 +12,73 @@ from fasta2a.client import A2AClient from fasta2a.storage import InMemoryStorage -SERVER_HOST = "127.0.0.1" +SERVER_HOST = '127.0.0.1' def get_free_port() -> int: """Ask OS for a free port.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("", 0)) + s.bind(('', 0)) return s.getsockname()[1] -async def _wait_server(url: str, retries: int = 100, delay: float = 0.1): - """Wait until the server responds (any response, even 404).""" +@pytest_asyncio.fixture(scope='function') +async def run_server_1(): + """Run FastA2A in a background thread and wait until it responds.""" + port = get_free_port() + url = f'http://{SERVER_HOST}:{port}' + app = FastA2A(storage=InMemoryStorage(), broker=InMemoryBroker(), url=url) + + # Start server in background thread + def _run_uvicorn(): + uvicorn.run(app, host=SERVER_HOST, port=port, log_level='error') + + thread = threading.Thread(target=_run_uvicorn, daemon=True) + thread.start() + # Wait until the server responds to requests async with httpx.AsyncClient() as client: for _ in range(retries): try: await client.get(url) return except httpx.RequestError: - await asyncio.sleep(delay) - raise RuntimeError(f'Server at {url} did not start in time') - - -def _start_server_in_thread(app, host: str, port: int): - """Run uvicorn server in a background thread.""" - - def _run_uvicorn(): - uvicorn.run(app, host=host, port=port, log_level='error') - - thread = threading.Thread(target=_run_uvicorn, daemon=True) - thread.start() + await asyncio.sleep(0.1) # Server not ready, wait and retry + else: + raise RuntimeError('Server did not start in time') + yield url @pytest_asyncio.fixture(scope='function') -async def run_server(request): - params = getattr(request, 'param', {}) +async def run_server_2(): + """Run FastA2A in a background thread and wait until it responds.""" port = get_free_port() url = f'http://{SERVER_HOST}:{port}' - app = FastA2A( storage=InMemoryStorage(), broker=InMemoryBroker(), url=url, - name=params.get('name'), - description=params.get('description'), + name='Test Agent', + description='A test agent for unit tests.', ) - _start_server_in_thread(app, SERVER_HOST, port) - await _wait_server(url) + # Start server in background thread + def _run_uvicorn(): + uvicorn.run(app, host=SERVER_HOST, port=port, log_level='error') + + thread = threading.Thread(target=_run_uvicorn, daemon=True) + thread.start() + # Wait until the server responds to requests + async with httpx.AsyncClient() as client: + for _ in range(100): + try: + # Ping the root. Any response (even 404) means the server is up. + # The RequestError exception will catch connection-refused. + await client.get(url) + break # Server is up and responding + except httpx.RequestError: + await asyncio.sleep(0.1) # Server not ready, wait and retry + else: + raise RuntimeError('Server did not start in time') yield url @@ -69,9 +88,9 @@ async def run_server(request): @pytest.mark.asyncio -async def test_client_basic(run_server): - client = A2AClient(agent=run_server) - assert str(client.http_client.base_url) == run_server +async def test_client_basic(run_server_1): + a2a_client = A2AClient(agent=run_server_1) + assert str(a2a_client.http_client.base_url) == run_server_1 @pytest.mark.asyncio @@ -82,14 +101,9 @@ async def test_client_fetch_card(run_server): @pytest.mark.asyncio -@pytest.mark.parametrize( - 'run_server', - [{'name': 'Test Agent', 'description': 'A test agent for unit tests.'}], - indirect=True, -) -async def test_client_check_agent_card(run_server): - client = A2AClient(agent=run_server, fetch_card=True) - assert client.http_client.base_url == run_server - assert client._agent_card is not None - assert client._agent_card['name'] == 'Test Agent' - assert client._agent_card['description'] == 'A test agent for unit tests.' +async def test_client_check_agent_card(run_server_2): + a2a_client = A2AClient(agent=run_server_2, fetch_card=True) + assert a2a_client.http_client.base_url == run_server_2 + assert a2a_client._agent_card is not None + assert a2a_client._agent_card['name'] == 'Test Agent' + assert a2a_client._agent_card['description'] == 'A test agent for unit tests.' From e944e86bfa079933e1be3d3ad5ebcfa85a549757 Mon Sep 17 00:00:00 2001 From: Sathvik Bhagavan Date: Sat, 25 Oct 2025 19:07:31 +0200 Subject: [PATCH 4/6] test: clean up tests for A2AClient --- tests/test_client.py | 98 +++++++++++++++++++++----------------------- 1 file changed, 47 insertions(+), 51 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 3948641..02f4877 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,7 @@ import asyncio import socket import threading +from collections.abc import AsyncGenerator import httpx import pytest @@ -22,63 +23,51 @@ def get_free_port() -> int: return s.getsockname()[1] -@pytest_asyncio.fixture(scope='function') -async def run_server_1(): - """Run FastA2A in a background thread and wait until it responds.""" - port = get_free_port() - url = f'http://{SERVER_HOST}:{port}' - app = FastA2A(storage=InMemoryStorage(), broker=InMemoryBroker(), url=url) - - # Start server in background thread - def _run_uvicorn(): - uvicorn.run(app, host=SERVER_HOST, port=port, log_level='error') - - thread = threading.Thread(target=_run_uvicorn, daemon=True) - thread.start() - # Wait until the server responds to requests +async def _wait_server(url: str, retries: int = 100, delay: float = 0.1): + """Wait until the server responds (any response, even 404).""" async with httpx.AsyncClient() as client: for _ in range(retries): try: await client.get(url) return except httpx.RequestError: - await asyncio.sleep(0.1) # Server not ready, wait and retry - else: - raise RuntimeError('Server did not start in time') - yield url + await asyncio.sleep(delay) + raise RuntimeError(f'Server at {url} did not start in time') + + +def _start_server_in_thread(app: FastA2A, host: str, port: int): + """Run uvicorn server in a background thread.""" + + def _run_uvicorn(): + uvicorn.run(app, host=host, port=port, log_level='error') + + thread = threading.Thread(target=_run_uvicorn, daemon=True) + thread.start() @pytest_asyncio.fixture(scope='function') -async def run_server_2(): - """Run FastA2A in a background thread and wait until it responds.""" +async def run_server(request: pytest.FixtureRequest) -> AsyncGenerator[str, None]: + """ + Generic fixture to run a FastA2A server. + + Accepts optional parameters via `request.param`: + - name: agent name + - description: agent description + """ + params = getattr(request, 'param', {}) port = get_free_port() url = f'http://{SERVER_HOST}:{port}' + app = FastA2A( storage=InMemoryStorage(), broker=InMemoryBroker(), url=url, - name='Test Agent', - description='A test agent for unit tests.', + name=params.get('name'), + description=params.get('description'), ) - # Start server in background thread - def _run_uvicorn(): - uvicorn.run(app, host=SERVER_HOST, port=port, log_level='error') - - thread = threading.Thread(target=_run_uvicorn, daemon=True) - thread.start() - # Wait until the server responds to requests - async with httpx.AsyncClient() as client: - for _ in range(100): - try: - # Ping the root. Any response (even 404) means the server is up. - # The RequestError exception will catch connection-refused. - await client.get(url) - break # Server is up and responding - except httpx.RequestError: - await asyncio.sleep(0.1) # Server not ready, wait and retry - else: - raise RuntimeError('Server did not start in time') + _start_server_in_thread(app, SERVER_HOST, port) + await _wait_server(url) yield url @@ -88,22 +77,29 @@ def _run_uvicorn(): @pytest.mark.asyncio -async def test_client_basic(run_server_1): - a2a_client = A2AClient(agent=run_server_1) - assert str(a2a_client.http_client.base_url) == run_server_1 +async def test_client_basic(run_server: str): + client = A2AClient(agent=run_server) + assert str(client.http_client.base_url) == run_server @pytest.mark.asyncio -async def test_client_fetch_card(run_server): +async def test_client_fetch_card(run_server: str): client = A2AClient(agent=run_server, fetch_card=True) - assert client._agent_card is not None + assert hasattr(client, 'agent_card') and client.agent_card is not None assert client.http_client.base_url == run_server +# Parameterize the fixture for tests needing a named agent @pytest.mark.asyncio -async def test_client_check_agent_card(run_server_2): - a2a_client = A2AClient(agent=run_server_2, fetch_card=True) - assert a2a_client.http_client.base_url == run_server_2 - assert a2a_client._agent_card is not None - assert a2a_client._agent_card['name'] == 'Test Agent' - assert a2a_client._agent_card['description'] == 'A test agent for unit tests.' +@pytest.mark.parametrize( + 'run_server', + [{'name': 'Test Agent', 'description': 'A test agent for unit tests.'}], + indirect=True, +) +async def test_client_check_agent_card(run_server: str): + client = A2AClient(agent=run_server, fetch_card=True) + assert client.http_client.base_url == run_server + assert hasattr(client, 'agent_card') and client.agent_card is not None + card = getattr(client, 'agent_card', {}) + assert card.get('name') == 'Test Agent' + assert card.get('description') == 'A test agent for unit tests.' From 1cb58739ff72647c3c70228fe8d594559a714433 Mon Sep 17 00:00:00 2001 From: Sathvik Bhagavan Date: Mon, 27 Oct 2025 13:43:41 +0100 Subject: [PATCH 5/6] build: add missing test deps --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2af3b7d..0f4b669 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,8 @@ dev = [ "pytest", "ruff", "pyright", + "pytest_asyncio", + "uvicorn" ] docs = [ "mkdocs-material[imaging]", From e52d62f1ccb5e4c888a45faae29ebda955db1754 Mon Sep 17 00:00:00 2001 From: Sathvik Bhagavan Date: Mon, 27 Oct 2025 14:08:42 +0100 Subject: [PATCH 6/6] chore: fix typo --- fasta2a/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fasta2a/client.py b/fasta2a/client.py index 0198f66..e4a2071 100644 --- a/fasta2a/client.py +++ b/fasta2a/client.py @@ -47,7 +47,7 @@ def __init__( response = httpx.get(agent_url) response.raise_for_status() agent = AgentCard(**response.json()) - self._agent_card = agent + self.agent_card = agent base_url = agent if isinstance(agent, str) else agent['url'] if http_client is None: self.http_client = httpx.AsyncClient(base_url=base_url)