Skip to content

Commit

Permalink
[ENH-RT] Add New Rich Text Features (#62)
Browse files Browse the repository at this point in the history
  • Loading branch information
nicklambourne authored Jan 31, 2024
1 parent de353d0 commit 978257c
Show file tree
Hide file tree
Showing 15 changed files with 556 additions and 4 deletions.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ build-backend = "poetry.core.masonry.api"
[tool.flake8]
exclude = [".venv", "./build", "./dist", ".eggs", ".git"]
max-line-length = 100
extend-ignore = """
BLK100
"""
per-file-ignores = [
"slackblocks/__init__.py:F401",
]
10 changes: 10 additions & 0 deletions slackblocks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
HeaderBlock,
ImageBlock,
InputBlock,
RichTextBlock,
SectionBlock,
)
from .elements import (
Expand Down Expand Up @@ -51,6 +52,15 @@
Trigger,
Workflow,
)
from .rich_text import (
ListType,
RichText,
RichTextLink,
RichTextList,
RichTextPreformattedCodeBlock,
RichTextQuote,
RichTextSection,
)
from .views import HomeTabView, ModalView, View

name = "slackblocks"
40 changes: 36 additions & 4 deletions slackblocks/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@
TextLike,
TextType,
)
from slackblocks.rich_text import (
RichTextElement,
RichTextList,
RichTextPreformattedCodeBlock,
RichTextQuote,
RichTextSection,
)
from slackblocks.utils import coerce_to_list

ALLOWED_INPUT_ELEMENTS = (
Expand All @@ -61,14 +68,15 @@ class BlockType(Enum):
in the Slack Blocks API and their programmatic names.
"""

SECTION = "section"
DIVIDER = "divider"
IMAGE = "image"
INPUT = "input"
ACTIONS = "actions"
CONTEXT = "context"
DIVIDER = "divider"
FILE = "file"
HEADER = "header"
IMAGE = "image"
INPUT = "input"
RICH_TEXT = "rich_text"
SECTION = "section"


class Block(ABC):
Expand Down Expand Up @@ -276,6 +284,30 @@ def _resolve(self) -> Dict[str, Any]:
return input_block


class RichTextBlock(Block):
def __init__(
self,
elements: Union[RichTextElement, List[RichTextElement]],
block_id: Optional[str] = None,
) -> None:
super().__init__(type_=BlockType.RICH_TEXT, block_id=block_id)
self.elements = coerce_to_list(
elements,
(
RichTextList,
RichTextPreformattedCodeBlock,
RichTextQuote,
RichTextSection,
),
min_size=1,
)

def _resolve(self) -> Dict[str, Any]:
rich_text_block = self._attributes()
rich_text_block["elements"] = [element._resolve() for element in self.elements]
return rich_text_block


class SectionBlock(Block):
"""
A section is one of the most flexible blocks available -
Expand Down
192 changes: 192 additions & 0 deletions slackblocks/rich_text.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
from abc import ABC, abstractmethod
from enum import Enum
from json import dumps
from typing import Any, Dict, List, Optional, TypeVar, Union

from .errors import InvalidUsageError
from .utils import coerce_to_list, validate_int


class RichTextObjectType(Enum):
SECTION = "rich_text_section"
LIST = "rich_text_list"
PREFORMATTED = "rich_text_preformatted"
QUOTE = "rich_text_quote"


class ListType(Enum):
BULLET = "bullet"
ORDERED = "ordered"

def all() -> List[str]:
return [list_type.value for list_type in ListType]


class RichTextObject(ABC):
def __init__(self, type_: RichTextObjectType) -> None:
self.type_ = type_

@abstractmethod
def _resolve(self) -> Dict[str, Any]:
return {"type": self.type_.value}

def __repr__(self) -> str:
return dumps(self._resolve(), indent=4)


class RichTextSection(RichTextObject):
def __init__(self, elements: Union[RichTextObject, List[RichTextObject]]) -> None:
super().__init__(type_=RichTextObjectType.SECTION)
self.elements = coerce_to_list(elements, class_=RichTextObject)

def _resolve(self) -> Dict[str, Any]:
section = super()._resolve()
section["elements"] = [element._resolve() for element in self.elements]
return section


class RichTextList(RichTextObject):
def __init__(
self,
style: Union[str, ListType],
elements: Union[RichTextSection, List[RichTextSection]],
indent: Optional[int] = None,
offset: Optional[int] = 0,
border: Optional[int] = 0,
) -> None:
super().__init__(type_=RichTextObjectType.LIST)
if isinstance(style, str):
if style in ListType.all():
self.style = style
else:
raise InvalidUsageError(f"`style` must be one of [{ListType.all()}]")
elif isinstance(style, ListType):
self.style = style.value
self.elements = coerce_to_list(elements, RichTextSection, min_size=1)
self.indent = validate_int(indent, allow_none=True)
self.offset = validate_int(offset, allow_none=True)
self.border = validate_int(border, allow_none=True)

def _resolve(self) -> Dict[str, Any]:
rich_text_list = super()._resolve()
rich_text_list["elements"] = [element._resolve() for element in self.elements]
rich_text_list["style"] = self.style
if self.indent is not None:
rich_text_list["indent"] = self.indent
if self.offset is not None:
rich_text_list["offset"] = self.offset
if self.border is not None:
rich_text_list["border"] = self.border
return rich_text_list


class RichTextSubElement(Enum):
TEXT = "text"
LINK = "link"


class RichText(RichTextObject):
def __init__(
self,
text: str,
bold: Optional[bool] = None,
italic: Optional[bool] = None,
strike: Optional[bool] = None,
) -> None:
super().__init__(type_=RichTextSubElement.TEXT)
self.text = text
self.bold = bold
self.italic = italic
self.strike = strike

def _resolve(self) -> Dict[str, Any]:
rich_text = super()._resolve()
rich_text["text"] = self.text
style = {}
if self.bold is not None:
style["bold"] = self.bold
if self.italic is not None:
style["italic"] = self.italic
if self.strike is not None:
style["strike"] = self.strike
if style:
rich_text["style"] = style
return rich_text


class RichTextLink(RichTextObject):
def __init__(
self,
url: str,
text: Optional[str] = None,
unsafe: Optional[bool] = None,
bold: Optional[bool] = None,
italic: Optional[bool] = None,
) -> None:
super().__init__(type_=RichTextSubElement.LINK)
self.url = url
self.text = text
self.unsafe = unsafe
self.bold = bold
self.italic = italic

def _resolve(self) -> Dict[str, Any]:
link = super()._resolve()
link["url"] = self.url
if self.text is not None:
link["text"] = self.text
if self.unsafe is not None:
link["unsafe"] = self.unsafe
style = {}
if self.bold is not None:
style["bold"] = self.bold
if self.italic is not None:
style["italic"] = self.italic
if style:
link["style"] = style
return link


class RichTextPreformattedCodeBlock(RichTextObject):
def __init__(
self,
elements: Union[RichText, RichTextLink, List[Union[RichText, RichTextLink]]],
border: Optional[int] = None,
) -> None:
super().__init__(type_=RichTextObjectType.PREFORMATTED)
self.elements = coerce_to_list(elements, (RichText, RichTextLink))
self.border = border

def _resolve(self) -> Dict[str, Any]:
preformatted = super()._resolve()
preformatted["elements"] = [element._resolve() for element in self.elements]
if self.border is not None:
preformatted["border"] = self.border
return preformatted


class RichTextQuote(RichTextObject):
def __init__(
self,
elements: Union[RichTextObject, List[RichTextObject]],
border: Optional[int] = None,
) -> None:
super().__init__(RichTextObjectType.QUOTE)
self.elements = coerce_to_list(elements, RichTextObject)
self.border = border

def _resolve(self) -> Dict[str, Any]:
quote = super()._resolve()
quote["elements"] = [element._resolve() for element in self.elements]
if self.border is not None:
quote["border"] = self.border
return quote


RichTextElement = TypeVar(
"RichTextElement",
RichTextList,
RichTextPreformattedCodeBlock,
RichTextQuote,
RichTextSection,
)
17 changes: 17 additions & 0 deletions slackblocks/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,20 @@ def validate_string(
f"exceeds length limit of {max_length} characters"
)
return string


def validate_int(
num: Union[int, None],
min_: Optional[int] = None,
max_: Optional[int] = None,
allow_none: bool = False,
) -> int:
if num is None and not allow_none:
raise InvalidUsageError("`num` is None, which is disallowed.")
if min_ is not None:
if num < min_:
raise InvalidUsageError(f"{num} is less than the minimum {min_}")
if max_ is not None:
if num > max_:
raise InvalidUsageError(f"{num} is less than the minimum {max_}")
return num
32 changes: 32 additions & 0 deletions test/samples/blocks/rich_text_block_basic.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"type": "rich_text",
"block_id": "fake_block_id",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "You 'bout to witness hip-hop in its most purest",
"style": {
"bold": true
}
},
{
"type": "text",
"text": "Most rawest form, flow almost flawless",
"style": {
"strike": true
}
},
{
"type": "text",
"text": "Most hardest, most honest known artist",
"style": {
"italic": true
}
}
]
}
]
}
9 changes: 9 additions & 0 deletions test/samples/rich_text/rich_text_basic.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "text",
"text": "I am a bold rich text block!",
"style": {
"bold": true,
"italic": true,
"strike": false
}
}
10 changes: 10 additions & 0 deletions test/samples/rich_text/rich_text_link_basic.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"type": "link",
"url": "https://google.com/",
"text": "Google",
"unsafe": false,
"style": {
"bold": true,
"italic": false
}
}
Loading

0 comments on commit 978257c

Please sign in to comment.