Skip to content

Commit

Permalink
Implement length validation for text objects
Browse files Browse the repository at this point in the history
We can use the native Pydantic max_length validation for the plain text
and markdown text objects by implementing a __len__ dunder method on the
text objects. To do this, create a base class for text objects.
  • Loading branch information
jonathansick committed Sep 26, 2024
1 parent 2d74e9d commit 7466941
Show file tree
Hide file tree
Showing 5 changed files with 81 additions and 79 deletions.
104 changes: 36 additions & 68 deletions src/templatebot/storage/slack/blockkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from abc import ABC
from typing import Annotated, Literal, Self

from pydantic import BaseModel, Field, model_validator
Expand All @@ -19,7 +20,8 @@
"SlackSectionBlock",
"SlackSectionBlockAccessoryTypes",
"SlackStaticSelectElement",
"SlackTextObjectTypes",
"SlackTextObjectBase",
"SlackTextObjectType",
]

block_id_field = Field(
Expand All @@ -44,7 +46,7 @@ class SlackSectionBlock(BaseModel):

block_id: Annotated[str | None, block_id_field] = None

text: SlackTextObjectTypes | None = Field(
text: SlackTextObjectType | None = Field(
None,
description=(
"The text to display in the block. Not required if `fields` is "
Expand All @@ -53,7 +55,7 @@ class SlackSectionBlock(BaseModel):
)

# Fields can take other types of elements.
fields: list[SlackTextObjectTypes] | None = Field(
fields: list[SlackTextObjectType] | None = Field(
None,
description=(
"An array of text objects. Each element of the array is a "
Expand Down Expand Up @@ -116,7 +118,7 @@ class SlackContextBlock(BaseModel):
block_id: Annotated[str | None, block_id_field] = None

# image elements can also be supported when available
elements: list[SlackTextObjectTypes] = Field(
elements: list[SlackTextObjectType] = Field(
...,
description=(
"An array of text objects. Each element of the array is a "
Expand Down Expand Up @@ -150,6 +152,7 @@ class SlackInputBlock(BaseModel):
"A label that appears above an input element. "
"Maximum length of 2000 characters."
),
max_length=2000,
)

element: SlackStaticSelectElement | SlackPlainTextInputElement = Field(
Expand All @@ -171,6 +174,7 @@ class SlackInputBlock(BaseModel):
"apppears below an input element in a lighter font. "
"Maximum length of 2000 characters."
),
max_length=2000,
)

optional: bool = Field(
Expand All @@ -181,17 +185,22 @@ class SlackInputBlock(BaseModel):
),
)

@model_validator(mode="after")
def validate_text_length(self) -> Self:
"""Ensure that the text length is not more than 2000 characters."""
if len(self.label.text) > 2000:
raise ValueError("The length of the label text must be <= 2000.")
if self.hint and len(self.hint.text) > 2000:
raise ValueError("The length of the hint text must be <= 2000.")
return self

class SlackTextObjectBase(BaseModel, ABC):
"""A base class for Slack Block Kit text objects."""

type: Literal["plain_text", "mrkdwn"] = Field(
..., description="The type of object."
)

text: str = Field(..., description="The text to display.")

def __len__(self) -> int:
"""Return the length of the text."""
return len(self.text)


class SlackPlainTextObject(BaseModel):
class SlackPlainTextObject(SlackTextObjectBase):
"""A plain_text composition object.
https://api.slack.com/reference/block-kit/composition-objects#text
Expand All @@ -201,8 +210,6 @@ class SlackPlainTextObject(BaseModel):
"plain_text", description="The type of object."
)

text: str = Field(..., description="The text to display.")

emoji: bool = Field(
True,
description=(
Expand All @@ -212,7 +219,7 @@ class SlackPlainTextObject(BaseModel):
)


class SlackMrkdwnTextObject(BaseModel):
class SlackMrkdwnTextObject(SlackTextObjectBase):
"""A mrkdwn text composition object.
https://api.slack.com/reference/block-kit/composition-objects#text
Expand All @@ -222,8 +229,6 @@ class SlackMrkdwnTextObject(BaseModel):
"mrkdwn", description="The type of object."
)

text: str = Field(..., description="The text to display.")

verbatim: bool = Field(
False,
description=(
Expand All @@ -234,6 +239,10 @@ class SlackMrkdwnTextObject(BaseModel):
)


SlackTextObjectType = SlackPlainTextObject | SlackMrkdwnTextObject
"""A type alias for Slack Block Kit text objects."""


class SlackOptionObject(BaseModel):
"""An option object for Slack Block Kit elements.
Expand All @@ -249,6 +258,7 @@ class SlackOptionObject(BaseModel):
"A plain text object that defines the text shown in the option. "
"Maximum length of 75 characters."
),
max_length=75,
)

value: str = Field(
Expand All @@ -267,6 +277,7 @@ class SlackOptionObject(BaseModel):
"A plain text object that defines a line of descriptive text "
"shown below the text. Maximum length of 75 characters."
),
max_length=75,
)

url: str | None = Field(
Expand All @@ -280,15 +291,6 @@ class SlackOptionObject(BaseModel):
max_length=3000,
)

@model_validator(mode="after")
def validate_text_length(self) -> Self:
"""Ensure that the text length is not more than 75 characters."""
if len(self.text.text) > 75:
raise ValueError("The length of the text must be <= 75.")
if self.description and len(self.description.text) > 75:
raise ValueError("The length of the description must be <= 75.")
return self


class SlackOptionGroupObject(BaseModel):
"""An option group object for Slack Block Kit elements.
Expand All @@ -304,6 +306,7 @@ class SlackOptionGroupObject(BaseModel):
"A plain text object that defines the label shown above this "
"group of options. Maximum length of 75 characters."
),
max_length=75,
)

options: list[SlackOptionObject] = Field(
Expand All @@ -315,13 +318,6 @@ class SlackOptionGroupObject(BaseModel):
max_length=100,
)

@model_validator(mode="after")
def validate_text_length(self) -> Self:
"""Ensure that the text length is not more than 75 characters."""
if len(self.label.text) > 75:
raise ValueError("The length of the label text must be <= 75.")
return self


class SlackConfirmationDialogObject(BaseModel):
"""A confirmation dialog object for Slack Block Kit elements.
Expand All @@ -335,6 +331,7 @@ class SlackConfirmationDialogObject(BaseModel):
"A plain text object that defines the dialog's title. Maximum "
"length of 100 characters."
),
max_length=100,
)

text: SlackPlainTextObject = Field(
Expand All @@ -343,6 +340,7 @@ class SlackConfirmationDialogObject(BaseModel):
"A text object that defines the explanatory text that appears in "
"the confirm dialog. Maximum length of 300 characters."
),
max_length=300,
)

confirm: SlackPlainTextObject = Field(
Expand All @@ -351,6 +349,7 @@ class SlackConfirmationDialogObject(BaseModel):
"A plain text object that defines the text of the button that "
"confirms the action. Maximum length of 30 characters."
),
max_length=30,
)

deny: SlackPlainTextObject = Field(
Expand All @@ -359,6 +358,7 @@ class SlackConfirmationDialogObject(BaseModel):
"A plain text object that defines the text of the button that "
"denies the action. Maximum length of 30 characters."
),
max_length=30,
)

style: Literal["primary", "danger"] = Field(
Expand All @@ -369,19 +369,6 @@ class SlackConfirmationDialogObject(BaseModel):
),
)

@model_validator(mode="after")
def validate_text_length(self) -> Self:
"""Ensure that the text length is not more than 300 characters."""
if len(self.title.text) > 100:
raise ValueError("The length of the title text must be <= 100.")
if len(self.text.text) > 300:
raise ValueError("The length of the text must be <= 300.")
if len(self.confirm.text) > 300:
raise ValueError("The length of the confirm text must be <= 30.")
if len(self.deny.text) > 300:
raise ValueError("The length of the deny text must be <= 30.")
return self


class SlackStaticSelectElement(BaseModel):
"""A static select element for Slack Block Kit.
Expand All @@ -403,6 +390,7 @@ class SlackStaticSelectElement(BaseModel):
"A plain text object that defines the placeholder text shown on "
"the static select element. Maximum length of 150 characters."
),
max_length=150,
)

options: list[SlackOptionObject] | None = Field(
Expand Down Expand Up @@ -468,15 +456,6 @@ def validate_options_or_option_groups(self) -> Self:
)
return self

@model_validator(mode="after")
def validate_text_length(self) -> Self:
"""Ensure that the text length is not more than 150 characters."""
if len(self.placeholder.text) > 150:
raise ValueError(
"The length of the placeholder text must be <= 150."
)
return self


class SlackPlainTextInputElement(BaseModel):
"""A plain text input element for Slack Block Kit.
Expand Down Expand Up @@ -514,6 +493,7 @@ class SlackPlainTextInputElement(BaseModel):
"A plain text object that defines the placeholder text shown in "
"the plain-text input. Maximum length of 150 characters."
),
max_length=150,
)

multiline: bool = Field(
Expand Down Expand Up @@ -550,15 +530,6 @@ class SlackPlainTextInputElement(BaseModel):

# dispatch_action_config is not implemented yet.

@model_validator(mode="after")
def validate_text_length(self) -> Self:
"""Ensure that the text length is not more than 150 characters."""
if self.placeholder and len(self.placeholder.text) > 150:
raise ValueError(
"The length of the placeholder text must be <= 150."
)
return self

@model_validator(mode="after")
def validate_min_max_length(self) -> Self:
"""Ensure that the min_length is less than or equal to max_length."""
Expand All @@ -576,8 +547,5 @@ def validate_min_max_length(self) -> Self:
SlackBlock = SlackSectionBlock | SlackContextBlock | SlackInputBlock
"""A generic type alias for Slack Block Kit blocks."""

SlackTextObjectTypes = SlackPlainTextObject | SlackMrkdwnTextObject
"""A type alias for Slack Block Kit text objects."""

SlackSectionBlockAccessoryTypes = SlackStaticSelectElement
"""A type alias for Slack Block Kit section block accessory types."""
16 changes: 5 additions & 11 deletions src/templatebot/storage/slack/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

from __future__ import annotations

from typing import Literal, Self
from typing import Literal

from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, Field

from .blockkit import SlackBlock, SlackPlainTextObject

Expand All @@ -19,6 +19,7 @@ class SlackModalView(BaseModel):
title: SlackPlainTextObject = Field(
...,
description="The title of the view. Maximum length is 24 characters.",
max_length=24,
)

blocks: list[SlackBlock] = Field(
Expand All @@ -30,13 +31,15 @@ class SlackModalView(BaseModel):
description=(
"The text for the close button. Maximum length is 24 characters."
),
max_length=24,
)

submit: SlackPlainTextObject | None = Field(
None,
description=(
"The text for the submit button. Maximum length is 24 characters."
),
max_length=24,
)

private_metadata: str | None = Field(
Expand Down Expand Up @@ -72,12 +75,3 @@ class SlackModalView(BaseModel):
external_id: str | None = Field(
None, description="A unique identifier for the view."
)

@model_validator(mode="after")
def validate_text_length(self) -> Self:
"""Ensure that the text fields are within the maximum length."""
for field in ("title", "close", "submit"):
value = getattr(self, field)
if value and len(value.text) > 24:
raise ValueError(f"{field} must be 24 characters or fewer.")
return self
Empty file added tests/storage/__init__.py
Empty file.
Empty file added tests/storage/slack/__init__.py
Empty file.
40 changes: 40 additions & 0 deletions tests/storage/slack/blockkit_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Tests for Block Kit models."""

from __future__ import annotations

import pytest
from pydantic import BaseModel, Field, ValidationError

from templatebot.storage.slack import blockkit


def test_plain_text_object_length() -> None:
"""Test that the length of a plain text object is correct."""

class Model(BaseModel):
text: blockkit.SlackPlainTextObject = Field(..., max_length=5)

data = Model.model_validate(
{"text": {"type": "plain_text", "text": "Hello"}}
)
assert data.text.text == "Hello"
assert data.text.type == "plain_text"

with pytest.raises(ValidationError):
Model.model_validate(
{"text": {"type": "plain_text", "text": "Hello!"}}
)


def test_mrkdwn_text_object_length() -> None:
"""Test that the length of a mrkdwn text object is correct."""

class Model(BaseModel):
text: blockkit.SlackMrkdwnTextObject = Field(..., max_length=5)

data = Model.model_validate({"text": {"type": "mrkdwn", "text": "Hello"}})
assert data.text.text == "Hello"
assert data.text.type == "mrkdwn"

with pytest.raises(ValidationError):
Model.model_validate({"text": {"type": "mrkdwn", "text": "Hello!"}})

0 comments on commit 7466941

Please sign in to comment.