Skip to content

Commit 15ee789

Browse files
authored
clean up output text of validator (#1077)
* clean up output text of validator * cleanup * fix lookup * pyink
1 parent b784e42 commit 15ee789

2 files changed

Lines changed: 243 additions & 25 deletions

File tree

agent_sdks/python/src/a2ui/core/schema/validator.py

Lines changed: 155 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -274,56 +274,186 @@ def validate(
274274
root_id: Optional[str] = None,
275275
strict_integrity: bool = True,
276276
) -> None:
277-
"""Validates an A2UI messages against the schema.
278-
279-
Args:
280-
a2ui_json: The A2UI message(s) to validate.
281-
root_id: Optional root component ID.
282-
strict_integrity: If True, performs full topology and integrity checks.
283-
If False, only performs schema validation and basic syntax checks.
284-
"""
277+
"""Validates an A2UI messages against the schema."""
285278
messages = a2ui_json if isinstance(a2ui_json, list) else [a2ui_json]
286279

287-
# Basic schema validation
288-
errors = list(self._validator.iter_errors(messages))
289-
if errors:
290-
error = errors[0]
291-
msg = f"Validation failed: {error.message}"
292-
if error.context:
293-
msg += "\nContext failures:"
294-
for sub_error in error.context:
295-
msg += f"\n - {sub_error.message}"
280+
if self.version == VERSION_0_9:
281+
self._validate_0_9_custom(messages, root_id, strict_integrity)
282+
else:
283+
# Fallback to old behavior for v0.8
284+
errors = list(self._validator.iter_errors(messages))
285+
if errors:
286+
error = errors[0]
287+
msg = f"Validation failed: {error.message}"
288+
if error.context:
289+
msg += "\nContext failures:"
290+
for sub_error in error.context:
291+
msg += f"\n - {sub_error.message}"
292+
raise ValueError(msg)
293+
294+
for message in messages:
295+
if not isinstance(message, dict):
296+
continue
297+
298+
components = None
299+
surface_id = None
300+
if "surfaceUpdate" in message: # v0.8
301+
components = message["surfaceUpdate"].get(COMPONENTS)
302+
surface_id = message["surfaceUpdate"].get("surfaceId")
303+
304+
if components:
305+
ref_map = extract_component_ref_fields(self._catalog)
306+
root_id = _find_root_id(messages, surface_id)
307+
_validate_component_integrity(
308+
root_id, components, ref_map, skip_root_check=not strict_integrity
309+
)
310+
analyze_topology(
311+
root_id, components, ref_map, raise_on_orphans=strict_integrity
312+
)
313+
314+
_validate_recursion_and_paths(message)
315+
316+
def _validate_0_9_custom(
317+
self,
318+
messages: List[Dict[str, Any]],
319+
root_id: Optional[str] = None,
320+
strict_integrity: bool = True,
321+
) -> None:
322+
all_errors = []
323+
for idx, message in enumerate(messages):
324+
if not isinstance(message, dict):
325+
all_errors.append(f"messages[{idx}]: Is not an object")
326+
continue
327+
328+
if "createSurface" in message:
329+
val = self._get_sub_validator("CreateSurfaceMessage")
330+
all_errors.extend(self._get_formatted_errors(val, message, f"messages[{idx}]"))
331+
elif "updateComponents" in message:
332+
all_errors.extend(
333+
self._get_update_components_errors(message, f"messages[{idx}]")
334+
)
335+
elif "updateDataModel" in message:
336+
val = self._get_sub_validator("UpdateDataModelMessage")
337+
all_errors.extend(self._get_formatted_errors(val, message, f"messages[{idx}]"))
338+
elif "deleteSurface" in message:
339+
val = self._get_sub_validator("DeleteSurfaceMessage")
340+
all_errors.extend(self._get_formatted_errors(val, message, f"messages[{idx}]"))
341+
else:
342+
keys = list(message.keys())
343+
all_errors.append(f"messages[{idx}]: Unknown message type with keys {keys}")
344+
345+
if all_errors:
346+
msg = "Validation failed:\n" + "\n".join(f" - {err}" for err in all_errors)
296347
raise ValueError(msg)
297348

349+
# Integrity checks
298350
for message in messages:
299351
if not isinstance(message, dict):
300352
continue
301-
302353
components = None
303354
surface_id = None
304-
if "surfaceUpdate" in message: # v0.8
305-
components = message["surfaceUpdate"].get(COMPONENTS)
306-
surface_id = message["surfaceUpdate"].get("surfaceId")
307-
elif "updateComponents" in message and isinstance(
355+
if "updateComponents" in message and isinstance(
308356
message["updateComponents"], dict
309-
): # v0.9
357+
):
310358
components = message["updateComponents"].get(COMPONENTS)
311359
surface_id = message["updateComponents"].get("surfaceId")
312360

313361
if components:
314362
ref_map = extract_component_ref_fields(self._catalog)
315363
root_id = _find_root_id(messages, surface_id)
316-
# Always check for basic integrity (duplicates)
317364
_validate_component_integrity(
318365
root_id, components, ref_map, skip_root_check=not strict_integrity
319366
)
320-
# Always check topology (cycles), but only raise on orphans if strict_integrity is True
321367
analyze_topology(
322368
root_id, components, ref_map, raise_on_orphans=strict_integrity
323369
)
324370

325371
_validate_recursion_and_paths(message)
326372

373+
def _get_sub_validator(self, def_name: str) -> Draft202012Validator:
374+
sub_schema = self._catalog.s2c_schema.get("$defs", {}).get(def_name)
375+
if not sub_schema:
376+
raise ValueError(f"Definition {def_name} not found in schema")
377+
return Draft202012Validator(sub_schema, registry=self._validator._registry)
378+
379+
def _get_formatted_errors(
380+
self, validator: Draft202012Validator, instance: Any, base_path: str
381+
) -> List[str]:
382+
errors = list(validator.iter_errors(instance))
383+
formatted = []
384+
for err in errors:
385+
path_str = ".".join(str(p) for p in err.path)
386+
full_path = f"{base_path}.{path_str}" if path_str else base_path
387+
388+
message = err.message
389+
if (
390+
(
391+
"Unevaluated properties are not allowed" in message
392+
or "Additional properties are not allowed" in message
393+
)
394+
and "(" in message
395+
and ")" in message
396+
):
397+
message = message[message.find("(") + 1 : message.rfind(")")]
398+
399+
formatted.append(f"{full_path}: {message}")
400+
return formatted
401+
402+
def _get_update_components_errors(
403+
self, message: Dict[str, Any], path: str
404+
) -> List[str]:
405+
errors = []
406+
if "version" not in message or message["version"] != "v0.9":
407+
errors.append(f"{path}: Invalid version, expected 'v0.9'")
408+
409+
uc = message.get("updateComponents")
410+
if not isinstance(uc, dict):
411+
errors.append(f"{path}: Expected updateComponents to be an object")
412+
return errors
413+
414+
if "surfaceId" not in uc or not isinstance(uc["surfaceId"], str):
415+
errors.append(f"{path}.updateComponents: Invalid or missing surfaceId")
416+
417+
components = uc.get("components")
418+
if not isinstance(components, list):
419+
errors.append(f"{path}.updateComponents: Expected components to be an array")
420+
return errors
421+
422+
for idx, comp in enumerate(components):
423+
comp_id = comp.get("id")
424+
comp_path = (
425+
f"{path}.updateComponents.components[id='{comp_id}']"
426+
if comp_id
427+
else f"{path}.updateComponents.components[{idx}]"
428+
)
429+
errors.extend(self._get_single_component_errors(comp, comp_path))
430+
431+
return errors
432+
433+
def _get_single_component_errors(self, comp: Dict[str, Any], path: str) -> List[str]:
434+
if not isinstance(comp, dict):
435+
return [f"{path}: Component is not an object"]
436+
437+
comp_type = comp.get("component")
438+
if not comp_type:
439+
return [f"{path}: Missing 'component' field"]
440+
441+
catalog = self._catalog.catalog_schema
442+
if not catalog or "components" not in catalog:
443+
return [f"{path}: Catalog schema or components missing"]
444+
445+
comp_schema = catalog["components"].get(comp_type)
446+
if not comp_schema:
447+
return [f"{path}: Unknown component: {comp_type}"]
448+
449+
temp_schema = {
450+
"$schema": "https://json-schema.org/draft/2020-12/schema",
451+
"$ref": f"catalog.json#/components/{comp_type}",
452+
}
453+
454+
validator = Draft202012Validator(temp_schema, registry=self._validator._registry)
455+
return self._get_formatted_errors(validator, comp, path)
456+
327457

328458
def _find_root_id(
329459
messages: List[Dict[str, Any]], surface_id: Optional[str] = None

agent_sdks/python/tests/core/schema/test_validator.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def catalog_0_9(self):
3939
{"$ref": "#/$defs/CreateSurfaceMessage"},
4040
{"$ref": "#/$defs/UpdateComponentsMessage"},
4141
{"$ref": "#/$defs/UpdateDataModelMessage"},
42+
{"$ref": "#/$defs/DeleteSurfaceMessage"},
4243
],
4344
"$defs": {
4445
"CreateSurfaceMessage": {
@@ -99,6 +100,7 @@ def catalog_0_9(self):
99100
"surfaceId": {
100101
"type": "string",
101102
},
103+
"path": {"type": "string"},
102104
"value": {"additionalProperties": True},
103105
},
104106
"required": ["surfaceId"],
@@ -108,6 +110,22 @@ def catalog_0_9(self):
108110
"required": ["version", "updateDataModel"],
109111
"additionalProperties": False,
110112
},
113+
"DeleteSurfaceMessage": {
114+
"type": "object",
115+
"properties": {
116+
"version": {"const": "v0.9"},
117+
"deleteSurface": {
118+
"type": "object",
119+
"properties": {
120+
"surfaceId": {"type": "string"},
121+
},
122+
"required": ["surfaceId"],
123+
"additionalProperties": False,
124+
},
125+
},
126+
"required": ["deleteSurface", "version"],
127+
"additionalProperties": False,
128+
},
111129
},
112130
}
113131
catalog_schema = {
@@ -470,6 +488,76 @@ def test_validator_0_9(self, catalog_0_9):
470488
catalog_0_9.validator.validate(invalid_message)
471489
assert "'catalogId' is a required property" in str(excinfo.value)
472490

491+
def test_pretty_error_messages(self, catalog_0_9):
492+
payload = [
493+
{
494+
"version": "v0.9",
495+
"createSurface": {
496+
"surfaceId": "recipe-card",
497+
"catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json",
498+
},
499+
},
500+
{
501+
"version": "v0.9",
502+
"updateComponents": {
503+
"surfaceId": "recipe-card",
504+
"components": [
505+
{
506+
"id": "main-column",
507+
"component": "Column",
508+
"children": ["recipe-image"],
509+
"gap": "small",
510+
},
511+
{
512+
"id": "recipe-image",
513+
"component": "Image",
514+
"url": {"path": "/image"},
515+
"altText": {"path": "/title"},
516+
"fit": "cover",
517+
},
518+
{
519+
"id": "title",
520+
"component": "Text",
521+
"text": {"path": "/title"},
522+
"usageHint": "h3",
523+
},
524+
{
525+
"id": "rating-row",
526+
"component": "Row",
527+
"children": ["star-icon"],
528+
},
529+
],
530+
},
531+
},
532+
{
533+
"version": "v0.9",
534+
"updateDataModel": {
535+
"surfaceId": "recipe-card",
536+
"value": {
537+
"image": (
538+
"https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=300&h=180&fit=crop"
539+
)
540+
},
541+
},
542+
},
543+
{"version": "v0.9", "deleteSurface": {}},
544+
{"unknownMessage": {}},
545+
]
546+
547+
with pytest.raises(ValueError) as excinfo:
548+
catalog_0_9.validator.validate(payload)
549+
550+
err_text = str(excinfo.value)
551+
print(f"\nVALIDATOR_OUTPUT_START\n{err_text}\nVALIDATOR_OUTPUT_END")
552+
553+
assert "Unknown component: Row" in err_text
554+
assert "'usageHint' was unexpected" in err_text
555+
assert "'gap' was unexpected" in err_text
556+
assert "'altText', 'fit' were unexpected" in err_text
557+
assert "'surfaceId' is a required property" in err_text
558+
assert "{'path': '/image'} is not of type 'string'" in err_text
559+
assert "Unknown message type with keys ['unknownMessage']" in err_text
560+
473561
def test_validator_0_8(self, catalog_0_8):
474562
# v0.8 uses monolithic bundling for validation
475563
message = [{

0 commit comments

Comments
 (0)