Skip to content

Commit 0b4a5a7

Browse files
authored
Merge branch '1.0-dev' into bartekw-compat-client
2 parents ca70e7d + b1339c8 commit 0b4a5a7

19 files changed

Lines changed: 799 additions & 823 deletions

.github/workflows/linter.yaml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,7 @@ jobs:
4545
- name: Run Pyright (Pylance equivalent)
4646
id: pyright
4747
continue-on-error: true
48-
uses: jakebailey/pyright-action@v2
49-
with:
50-
pylance-version: latest-release
48+
run: uv run pyright src
5149

5250
- name: Run JSCPD for copy-paste detection
5351
id: jscpd

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ dev = [
127127
"trio",
128128
"uvicorn>=0.35.0",
129129
"pytest-timeout>=2.4.0",
130+
"pyright",
130131
"a2a-sdk[all]",
131132
]
132133

scripts/lint.sh

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#!/bin/bash
2+
# Local replica of .github/workflows/linter.yaml (excluding jscpd copy-paste check)
3+
4+
# ANSI color codes for premium output
5+
RED='\033[0;31m'
6+
GREEN='\033[0;32m'
7+
YELLOW='\033[1;33m'
8+
BLUE='\033[0;34m'
9+
BOLD='\033[1m'
10+
NC='\033[0m' # No Color
11+
12+
FAILED=0
13+
14+
echo -e "${BLUE}${BOLD}=== A2A Python Fixed-and-Lint Suite ===${NC}"
15+
echo -e "Fixing formatting and linting issues, then verifying types...\n"
16+
17+
# 1. Ruff Linter (with fix)
18+
echo -e "${YELLOW}${BOLD}--- [1/4] Running Ruff Linter (fix) ---${NC}"
19+
if uv run ruff check --fix; then
20+
echo -e "${GREEN}✓ Ruff Linter passed (and fixed what it could)${NC}"
21+
else
22+
echo -e "${RED}✗ Ruff Linter failed${NC}"
23+
FAILED=1
24+
fi
25+
26+
# 2. Ruff Formatter
27+
echo -e "\n${YELLOW}${BOLD}--- [2/4] Running Ruff Formatter (apply) ---${NC}"
28+
if uv run ruff format; then
29+
echo -e "${GREEN}✓ Ruff Formatter applied${NC}"
30+
else
31+
echo -e "${RED}✗ Ruff Formatter failed${NC}"
32+
FAILED=1
33+
fi
34+
35+
# 3. MyPy Type Checker
36+
echo -e "\n${YELLOW}${BOLD}--- [3/4] Running MyPy Type Checker ---${NC}"
37+
if uv run mypy src; then
38+
echo -e "${GREEN}✓ MyPy passed${NC}"
39+
else
40+
echo -e "${RED}✗ MyPy failed${NC}"
41+
FAILED=1
42+
fi
43+
44+
# 4. Pyright Type Checker
45+
echo -e "\n${YELLOW}${BOLD}--- [4/4] Running Pyright ---${NC}"
46+
if uv run pyright; then
47+
echo -e "${GREEN}✓ Pyright passed${NC}"
48+
else
49+
echo -e "${RED}✗ Pyright failed${NC}"
50+
FAILED=1
51+
fi
52+
53+
echo -e "\n${BLUE}${BOLD}=========================================${NC}"
54+
if [ $FAILED -eq 0 ]; then
55+
echo -e "${GREEN}${BOLD}SUCCESS: All linting and formatting tasks complete!${NC}"
56+
exit 0
57+
else
58+
echo -e "${RED}${BOLD}FAILURE: One or more steps failed.${NC}"
59+
exit 1
60+
fi

src/a2a/client/transports/rest.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ async def get_task(
109109
params = MessageToDict(request)
110110
if 'id' in params:
111111
del params['id'] # id is part of the URL path
112+
if 'tenant' in params:
113+
del params['tenant']
112114

113115
response_data = await self._execute_request(
114116
'GET',
@@ -127,12 +129,16 @@ async def list_tasks(
127129
context: ClientCallContext | None = None,
128130
) -> ListTasksResponse:
129131
"""Retrieves tasks for an agent."""
132+
params = MessageToDict(request)
133+
if 'tenant' in params:
134+
del params['tenant']
135+
130136
response_data = await self._execute_request(
131137
'GET',
132138
'/tasks',
133139
request.tenant,
134140
context=context,
135-
params=MessageToDict(request),
141+
params=params,
136142
)
137143
response: ListTasksResponse = ParseDict(
138144
response_data, ListTasksResponse()
@@ -185,8 +191,10 @@ async def get_task_push_notification_config(
185191
params = MessageToDict(request)
186192
if 'id' in params:
187193
del params['id']
188-
if 'task_id' in params:
189-
del params['task_id']
194+
if 'taskId' in params:
195+
del params['taskId']
196+
if 'tenant' in params:
197+
del params['tenant']
190198

191199
response_data = await self._execute_request(
192200
'GET',
@@ -208,8 +216,10 @@ async def list_task_push_notification_configs(
208216
) -> ListTaskPushNotificationConfigsResponse:
209217
"""Lists push notification configurations for a specific task."""
210218
params = MessageToDict(request)
211-
if 'task_id' in params:
212-
del params['task_id']
219+
if 'taskId' in params:
220+
del params['taskId']
221+
if 'tenant' in params:
222+
del params['tenant']
213223

214224
response_data = await self._execute_request(
215225
'GET',
@@ -233,8 +243,10 @@ async def delete_task_push_notification_config(
233243
params = MessageToDict(request)
234244
if 'id' in params:
235245
del params['id']
236-
if 'task_id' in params:
237-
del params['task_id']
246+
if 'taskId' in params:
247+
del params['taskId']
248+
if 'tenant' in params:
249+
del params['tenant']
238250

239251
await self._execute_request(
240252
'DELETE',

src/a2a/server/apps/jsonrpc/jsonrpc_app.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,13 @@ async def _handle_requests(self, request: Request) -> Response: # noqa: PLR0911
365365
message='Batch requests are not supported'
366366
),
367367
)
368+
if body.get('jsonrpc') != '2.0':
369+
return self._generate_error_response(
370+
request_id,
371+
InvalidRequestError(
372+
message="Invalid request: 'jsonrpc' must be exactly '2.0'"
373+
),
374+
)
368375
except Exception as e:
369376
logger.exception('Failed to validate base JSON-RPC request')
370377
return self._generate_error_response(

src/a2a/server/models.py

Lines changed: 7 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from datetime import datetime
2-
from typing import TYPE_CHECKING, Any, Generic, TypeVar
2+
from typing import TYPE_CHECKING, Any
33

44

55
if TYPE_CHECKING:
@@ -11,24 +11,14 @@ def override(func): # noqa: ANN001, ANN201
1111
return func
1212

1313

14-
from google.protobuf.json_format import MessageToDict, ParseDict
15-
from google.protobuf.message import Message as ProtoMessage
16-
from pydantic import BaseModel
17-
18-
from a2a.types.a2a_pb2 import Artifact, Message, TaskStatus
19-
20-
2114
try:
22-
from sqlalchemy import JSON, DateTime, Dialect, Index, LargeBinary, String
15+
from sqlalchemy import JSON, DateTime, Index, LargeBinary, String
2316
from sqlalchemy.orm import (
2417
DeclarativeBase,
2518
Mapped,
2619
declared_attr,
2720
mapped_column,
2821
)
29-
from sqlalchemy.types import (
30-
TypeDecorator,
31-
)
3222
except ImportError as e:
3323
raise ImportError(
3424
'Database models require SQLAlchemy. '
@@ -40,101 +30,6 @@ def override(func): # noqa: ANN001, ANN201
4030
) from e
4131

4232

43-
T = TypeVar('T')
44-
45-
46-
class PydanticType(TypeDecorator[T], Generic[T]):
47-
"""SQLAlchemy type that handles Pydantic model and Protobuf message serialization."""
48-
49-
impl = JSON
50-
cache_ok = True
51-
52-
def __init__(self, pydantic_type: type[T], **kwargs: dict[str, Any]):
53-
"""Initialize the PydanticType.
54-
55-
Args:
56-
pydantic_type: The Pydantic model or Protobuf message type to handle.
57-
**kwargs: Additional arguments for TypeDecorator.
58-
"""
59-
self.pydantic_type = pydantic_type
60-
super().__init__(**kwargs)
61-
62-
def process_bind_param(
63-
self, value: T | None, dialect: Dialect
64-
) -> dict[str, Any] | None:
65-
"""Convert Pydantic model or Protobuf message to a JSON-serializable dictionary for the database."""
66-
if value is None:
67-
return None
68-
if isinstance(value, ProtoMessage):
69-
return MessageToDict(value, preserving_proto_field_name=False)
70-
if isinstance(value, BaseModel):
71-
return value.model_dump(mode='json')
72-
return value # type: ignore[return-value]
73-
74-
def process_result_value(
75-
self, value: dict[str, Any] | None, dialect: Dialect
76-
) -> T | None:
77-
"""Convert a JSON-like dictionary from the database back to a Pydantic model or Protobuf message."""
78-
if value is None:
79-
return None
80-
# Check if it's a protobuf message class
81-
if isinstance(self.pydantic_type, type) and issubclass(
82-
self.pydantic_type, ProtoMessage
83-
):
84-
return ParseDict(value, self.pydantic_type()) # type: ignore[return-value]
85-
# Assume it's a Pydantic model
86-
return self.pydantic_type.model_validate(value) # type: ignore[attr-defined]
87-
88-
89-
class PydanticListType(TypeDecorator, Generic[T]):
90-
"""SQLAlchemy type that handles lists of Pydantic models or Protobuf messages."""
91-
92-
impl = JSON
93-
cache_ok = True
94-
95-
def __init__(self, pydantic_type: type[T], **kwargs: dict[str, Any]):
96-
"""Initialize the PydanticListType.
97-
98-
Args:
99-
pydantic_type: The Pydantic model or Protobuf message type for items in the list.
100-
**kwargs: Additional arguments for TypeDecorator.
101-
"""
102-
self.pydantic_type = pydantic_type
103-
super().__init__(**kwargs)
104-
105-
def process_bind_param(
106-
self, value: list[T] | None, dialect: Dialect
107-
) -> list[dict[str, Any]] | None:
108-
"""Convert a list of Pydantic models or Protobuf messages to a JSON-serializable list for the DB."""
109-
if value is None:
110-
return None
111-
result: list[dict[str, Any]] = []
112-
for item in value:
113-
if isinstance(item, ProtoMessage):
114-
result.append(
115-
MessageToDict(item, preserving_proto_field_name=False)
116-
)
117-
elif isinstance(item, BaseModel):
118-
result.append(item.model_dump(mode='json'))
119-
else:
120-
result.append(item) # type: ignore[arg-type]
121-
return result
122-
123-
def process_result_value(
124-
self, value: list[dict[str, Any]] | None, dialect: Dialect
125-
) -> list[T] | None:
126-
"""Convert a JSON-like list from the DB back to a list of Pydantic models or Protobuf messages."""
127-
if value is None:
128-
return None
129-
# Check if it's a protobuf message class
130-
if isinstance(self.pydantic_type, type) and issubclass(
131-
self.pydantic_type, ProtoMessage
132-
):
133-
return [ParseDict(item, self.pydantic_type()) for item in value] # type: ignore[misc]
134-
# Assume it's a Pydantic model
135-
return [self.pydantic_type.model_validate(item) for item in value] # type: ignore[attr-defined]
136-
137-
13833
# Base class for all database models
13934
class Base(DeclarativeBase):
14035
"""Base class for declarative models in A2A SDK."""
@@ -153,14 +48,12 @@ class TaskMixin:
15348
last_updated: Mapped[datetime | None] = mapped_column(
15449
DateTime, nullable=True
15550
)
156-
157-
# Properly typed Pydantic fields with automatic serialization
158-
status: Mapped[TaskStatus] = mapped_column(PydanticType(TaskStatus))
159-
artifacts: Mapped[list[Artifact] | None] = mapped_column(
160-
PydanticListType(Artifact), nullable=True
51+
status: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
52+
artifacts: Mapped[list[dict[str, Any]] | None] = mapped_column(
53+
JSON, nullable=True
16154
)
162-
history: Mapped[list[Message] | None] = mapped_column(
163-
PydanticListType(Message), nullable=True
55+
history: Mapped[list[dict[str, Any]] | None] = mapped_column(
56+
JSON, nullable=True
16457
)
16558
protocol_version: Mapped[str | None] = mapped_column(
16659
String(16), nullable=True

src/a2a/server/request_handlers/rest_handler.py

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
MessageToDict,
88
MessageToJson,
99
Parse,
10-
ParseDict,
1110
)
1211

1312

@@ -27,7 +26,6 @@
2726
AgentCard,
2827
CancelTaskRequest,
2928
GetTaskPushNotificationConfigRequest,
30-
GetTaskRequest,
3129
SubscribeToTaskRequest,
3230
)
3331
from a2a.utils import proto_utils
@@ -220,12 +218,11 @@ async def set_push_notification(
220218
(due to the `@validate` decorator), A2AError if processing error is
221219
found.
222220
"""
223-
task_id = request.path_params['id']
224221
body = await request.body()
225222
params = a2a_pb2.TaskPushNotificationConfig()
226223
Parse(body, params)
227224
# Set the parent to the task resource name format
228-
params.task_id = task_id
225+
params.task_id = request.path_params['id']
229226
config = (
230227
await self.request_handler.on_create_task_push_notification_config(
231228
params, context
@@ -247,10 +244,9 @@ async def on_get_task(
247244
Returns:
248245
A `Task` object containing the Task.
249246
"""
250-
task_id = request.path_params['id']
251-
history_length_str = request.query_params.get('historyLength')
252-
history_length = int(history_length_str) if history_length_str else None
253-
params = GetTaskRequest(id=task_id, history_length=history_length)
247+
params = a2a_pb2.GetTaskRequest()
248+
proto_utils.parse_params(request.query_params, params)
249+
params.id = request.path_params['id']
254250
task = await self.request_handler.on_get_task(params, context)
255251
if task:
256252
return MessageToDict(task)
@@ -295,12 +291,8 @@ async def list_tasks(
295291
A list of `dict` representing the `Task` objects.
296292
"""
297293
params = a2a_pb2.ListTasksRequest()
298-
# Parse query params, keeping arrays/repeated fields in mind if there are any
299-
# Using a simple ParseDict for now, might need more robust query param parsing
300-
# if the request structure contains nested or repeated elements
301-
ParseDict(
302-
dict(request.query_params), params, ignore_unknown_fields=True
303-
)
294+
proto_utils.parse_params(request.query_params, params)
295+
304296
result = await self.request_handler.on_list_tasks(params, context)
305297
return MessageToDict(result)
306298

@@ -318,13 +310,9 @@ async def list_push_notifications(
318310
Returns:
319311
A list of `dict` representing the `TaskPushNotificationConfig` objects.
320312
"""
321-
task_id = request.path_params['id']
322-
params = a2a_pb2.ListTaskPushNotificationConfigsRequest(task_id=task_id)
323-
324-
# Parse query params, keeping arrays/repeated fields in mind if there are any
325-
ParseDict(
326-
dict(request.query_params), params, ignore_unknown_fields=True
327-
)
313+
params = a2a_pb2.ListTaskPushNotificationConfigsRequest()
314+
proto_utils.parse_params(request.query_params, params)
315+
params.task_id = request.path_params['id']
328316

329317
result = (
330318
await self.request_handler.on_list_task_push_notification_configs(

0 commit comments

Comments
 (0)