/ src / solace_agent_mesh / common / utils / pydantic_utils.py
pydantic_utils.py
  1  """Provides a Pydantic BaseModel for SAM configuration with dict-like access."""
  2  from pydantic import BaseModel, ValidationError
  3  from typing import Any, TypeVar, Union, get_args, get_origin
  4  from types import UnionType
  5  
  6  T = TypeVar("T", bound="SamConfigBase")
  7  
  8  
  9  class SamConfigBase(BaseModel):
 10      """
 11      A Pydantic BaseModel for SAM configuration that allows dictionary-style access
 12      for backward compatibility with components expecting dicts.
 13      Supports .get(), ['key'], and 'in' operator.
 14      """
 15  
 16      @classmethod
 17      def model_validate_and_clean(cls: type[T], obj: Any) -> T:
 18          """
 19          Validates a dictionary, first removing any keys with None values.
 20          This allows Pydantic's default values to be applied correctly when
 21          a config key is present but has no value in YAML.
 22          """
 23          if isinstance(obj, dict):
 24              cleaned_obj = {k: v for k, v in obj.items() if v is not None}
 25              return cls.model_validate(cleaned_obj)
 26          return cls.model_validate(obj)
 27  
 28      @classmethod
 29      def format_validation_error_message(cls: type[T], error: ValidationError, app_name: str | None, agent_name: str | None = None) -> str:
 30          """
 31          Formats Pydantic validation error messages into a clear, actionable format.
 32  
 33          Example output:
 34          ---- Configuration validation failed for 'my-agent-app' ----
 35  
 36             Agent Name: AgentConfig
 37  
 38          ERROR 1:
 39             Missing required field: 'namespace'
 40             Location: app_config.namespace
 41             Description: Absolute topic prefix for A2A communication (e.g., 'myorg/dev')
 42  
 43          ---- Please update your YAML configuration ----
 44          """
 45  
 46          error_lines = [
 47              f"\n---- Configuration validation failed for {app_name or 'UNKNOWN'} ----",
 48              ""
 49          ]
 50  
 51          if agent_name:
 52              error_lines.append(f"   Agent Name: {agent_name}\n")
 53  
 54          def get_nested_field_description(model_class: type[BaseModel], path: list[str | int]) -> str | None:
 55              """Recursively get field description from nested models"""
 56              if not path:
 57                  return None
 58  
 59              current_field = path[0]
 60              if str(current_field) not in model_class.model_fields:
 61                  return None
 62  
 63              field_info = model_class.model_fields[str(current_field)]
 64  
 65              if len(path) == 1:
 66                  return field_info.description
 67  
 68              annotation = field_info.annotation
 69  
 70              # Handle Optional/Union types
 71              if annotation is not None:
 72                  origin = get_origin(annotation)
 73                  if origin is Union or origin is UnionType:
 74                      types = get_args(annotation)
 75                      annotation = next((t for t in types if t is not type(None)), None)
 76                  elif origin is list:
 77                      inner_type = get_args(annotation)[0]
 78                      if len(path) > 1 and isinstance(path[1], int):
 79                          if isinstance(inner_type, type) and issubclass(inner_type, BaseModel):
 80                              return get_nested_field_description(inner_type, path[2:])
 81                          return None
 82                      annotation = inner_type
 83  
 84              if annotation is not None and isinstance(annotation, type) and issubclass(annotation, BaseModel):
 85                  return get_nested_field_description(annotation, path[1:])
 86  
 87              return None
 88  
 89  
 90          for index, err in enumerate(error.errors()):
 91              error_type = err.get('type')
 92              loc = err['loc']
 93              msg = err['msg']
 94  
 95              error_lines.append(f"ERROR {index + 1}:")
 96  
 97              absolute_path = '.'.join(str(item) for item in loc)
 98              description = get_nested_field_description(cls, list(loc))
 99              if error_type == 'missing':
100                  error_lines.extend([
101                      f"   Missing required field: '{loc[-1]}'",
102                  ])
103              else:
104                  error_lines.extend([
105                      f"   Error: {msg}",
106                  ])
107              error_lines.append(f"   Location: app_config.{absolute_path}")
108              error_lines.append(f"   Description: {description or 'UNKNOWN'}")
109              error_lines.append("")
110  
111          error_lines.append('---- Please update your YAML configuration ----')
112          return '\n'.join(error_lines) + "\n"
113  
114      def get(self, key: str, default: Any = None) -> Any:
115          """Provides dict-like .get() method."""
116          return getattr(self, key, default)
117  
118      def __getitem__(self, key: str) -> Any:
119          """Provides dict-like ['key'] access."""
120          return getattr(self, key)
121  
122      def __setitem__(self, key: str, value: Any):
123          """Provides dict-like ['key'] = value assignment."""
124          setattr(self, key, value)
125  
126      def __contains__(self, key: str) -> bool:
127          """
128          Provides dict-like 'in' support that mimics the old behavior.
129          Returns True only if the key was explicitly provided during model creation.
130          """
131          return key in self.model_fields_set
132  
133      def keys(self):
134          """Provides dict-like .keys() method."""
135          return self.model_dump().keys()
136  
137      def values(self):
138          """Provides dict-like .values() method."""
139          return self.model_dump().values()
140  
141      def items(self):
142          """Provides dict-like .items() method."""
143          return self.model_dump().items()
144  
145      def __iter__(self):
146          """Provides dict-like iteration over keys."""
147          return iter(self.model_dump())
148      
149      def pop(self, key: str, default: Any = None) -> Any:
150          """
151          Provides dict-like .pop() method.
152          Removes the attribute and returns its value, or default if not present.
153          """
154          if hasattr(self, key):
155              value = getattr(self, key)
156              # Set to None rather than deleting, as Pydantic models don't support delattr
157              setattr(self, key, None)
158              return value
159          return default