Search code examples
pythonslackpydantic

How to solve multiple and nested discriminators with Pydantic v2?


I am trying to validate Slack interaction payloads, that look like these:

type: block_actions
container:
  type: view
...
type: block_actions
container:
  type: message
...
type: view_submission
...

I use 3 different models for payloads coming to the same interaction endpoint:

class MessageContainer(BaseModel):
    type: Literal["message"]
    ...

class ViewContainer(BaseModel):
    type: Literal["view"]
    ...

class MessageActions(ActionsBase):
    type: Literal["block_actions"]
    container: MessageContainer
    ...

class ViewActions(ActionsBase):
    type: Literal["block_actions"]
    container: ViewContainer
    ...

class ViewSubmission(BaseModel):
    type: Literal["view_submission"]
    ...

and I was planning to use

BlockActions = Annotated[
    MessageActions | ViewActions,
    Field(discriminator="container.type"),
]
SlackInteraction = Annotated[
    ViewSubmission | BlockActions,
    Field(discriminator="type"),
]
SlackInteractionAdapter = TypeAdapter(SlackInteraction)

but cannot make it work with v2.10.4.

Do I have to dispatch them manually or there is a way to solve it with Pydantic?


Solution

  • Not sure it's possible to use 2 discriminators to resolve one type (as you are trying to do).

    I can suggest you 3 options:

    1. Split block_actions into block_message_actions and block_view_actions:

    from typing import Annotated, Literal
    
    from pydantic import BaseModel, Field, TypeAdapter
    
    
    class MessageContainer(BaseModel):
        pass
    
    class ViewContainer(BaseModel):
        pass
    
    
    class ActionsBase(BaseModel):
        pass
    
    class MessageActions(ActionsBase):
        type: Literal["block_message_actions"]
        container: MessageContainer
    
    
    class ViewActions(ActionsBase):
        type: Literal["block_view_actions"]
        container: ViewContainer
    
    
    class ViewSubmission(BaseModel):
        type: Literal["view_submission"]
    
    
    SlackInteraction = Annotated[
        ViewSubmission | ViewActions | MessageActions,
        Field(discriminator="type"),
    ]
    
    SlackInteractionAdapter = TypeAdapter(SlackInteraction)
    
    
    
    a = SlackInteractionAdapter.validate_python({"type": "view_submission"})
    assert isinstance(a, ViewSubmission)
    
    
    b = SlackInteractionAdapter.validate_python(
        {"type": "block_message_actions", "container": {}},
    )
    assert isinstance(b, MessageActions)
    assert isinstance(b.container, MessageContainer)
    
    
    c = SlackInteractionAdapter.validate_python(
        {"type": "block_view_actions", "container": {}},
    )
    assert isinstance(c, ViewActions)
    assert isinstance(c.container, ViewContainer)
    
    

    2. Use Discriminated Unions with callable Discriminator:

    
    def get_discriminator_value(v: Any) -> str:
        if isinstance(v, dict):
            if v["type"] == "view_submission":
                return "view_submission"
            return "message_action" if v["container"]["type"] == "message" else "view_action"
        if v.type == "view_submission":
            return "view_submission"
        return "message_action" if v.container.type == "message" else "view_action"
    
    
    SlackInteraction = Annotated[
        Union[
            Annotated[ViewSubmission, Tag("view_submission")],
            Annotated[MessageActions, Tag("message_action")],
            Annotated[ViewActions, Tag("view_action")],
        ],
        Discriminator(get_discriminator_value),
    ]
    SlackInteractionAdapter = TypeAdapter(SlackInteraction)
    

    3. Use nested discriminated unions:

    from typing import Annotated, Literal
    
    from pydantic import BaseModel, Field, TypeAdapter
    
    
    class MessageContainer(BaseModel):
        type: Literal["message"]
    
    class ViewContainer(BaseModel):
        type: Literal["view"]
    
    
    ActionContainer = Annotated[
        MessageContainer | ViewContainer,
        Field(discriminator="type"),
    ]
    
    class BlockActions(BaseModel):
        type: Literal["block_actions"]
        container: ActionContainer
    
    
    
    class ViewSubmission(BaseModel):
        type: Literal["view_submission"]
    
    
    
    SlackInteraction = Annotated[
        ViewSubmission | BlockActions,
        Field(discriminator="type"),
    ]
    
    SlackInteractionAdapter = TypeAdapter(SlackInteraction)
    
    
    a = SlackInteractionAdapter.validate_python({"type": "view_submission"})
    assert isinstance(a, ViewSubmission)
    
    
    b = SlackInteractionAdapter.validate_python(
        {"type": "block_actions", "container": {"type": "message"}},
    )
    assert isinstance(b, BlockActions)
    assert isinstance(b.container, MessageContainer)
    
    
    c = SlackInteractionAdapter.validate_python(
        {"type": "block_actions", "container": {"type": "view"}},
    )
    assert isinstance(c, BlockActions)
    assert isinstance(c.container, ViewContainer)