From 978257ce36ec940ca03c4228bfdec10ddc0e5634 Mon Sep 17 00:00:00 2001 From: Nicholas Lambourne Date: Wed, 31 Jan 2024 10:50:04 +1000 Subject: [PATCH] [ENH-RT] Add New Rich Text Features (#62) --- pyproject.toml | 3 + slackblocks/__init__.py | 10 + slackblocks/blocks.py | 40 +++- slackblocks/rich_text.py | 192 ++++++++++++++++++ slackblocks/utils.py | 17 ++ .../samples/blocks/rich_text_block_basic.json | 32 +++ test/samples/rich_text/rich_text_basic.json | 9 + .../rich_text/rich_text_link_basic.json | 10 + .../rich_text/rich_text_list_basic.json | 36 ++++ .../rich_text/rich_text_list_ordered.json | 27 +++ ...ch_text_preformatted_code_block_basic.json | 10 + .../rich_text/rich_text_quote_basic.json | 10 + .../rich_text/rich_text_section_basic.json | 9 + test/unit/test_blocks.py | 27 +++ test/unit/test_rich_text.py | 128 ++++++++++++ 15 files changed, 556 insertions(+), 4 deletions(-) create mode 100644 slackblocks/rich_text.py create mode 100644 test/samples/blocks/rich_text_block_basic.json create mode 100644 test/samples/rich_text/rich_text_basic.json create mode 100644 test/samples/rich_text/rich_text_link_basic.json create mode 100644 test/samples/rich_text/rich_text_list_basic.json create mode 100644 test/samples/rich_text/rich_text_list_ordered.json create mode 100644 test/samples/rich_text/rich_text_preformatted_code_block_basic.json create mode 100644 test/samples/rich_text/rich_text_quote_basic.json create mode 100644 test/samples/rich_text/rich_text_section_basic.json create mode 100644 test/unit/test_rich_text.py diff --git a/pyproject.toml b/pyproject.toml index 1ac5846..54b0d0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/slackblocks/__init__.py b/slackblocks/__init__.py index 8c0c29d..be8c583 100644 --- a/slackblocks/__init__.py +++ b/slackblocks/__init__.py @@ -7,6 +7,7 @@ HeaderBlock, ImageBlock, InputBlock, + RichTextBlock, SectionBlock, ) from .elements import ( @@ -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" diff --git a/slackblocks/blocks.py b/slackblocks/blocks.py index a109560..2f388b7 100644 --- a/slackblocks/blocks.py +++ b/slackblocks/blocks.py @@ -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 = ( @@ -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): @@ -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 - diff --git a/slackblocks/rich_text.py b/slackblocks/rich_text.py new file mode 100644 index 0000000..962ff43 --- /dev/null +++ b/slackblocks/rich_text.py @@ -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, +) diff --git a/slackblocks/utils.py b/slackblocks/utils.py index 06159f3..afb40e2 100644 --- a/slackblocks/utils.py +++ b/slackblocks/utils.py @@ -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 diff --git a/test/samples/blocks/rich_text_block_basic.json b/test/samples/blocks/rich_text_block_basic.json new file mode 100644 index 0000000..a5631d1 --- /dev/null +++ b/test/samples/blocks/rich_text_block_basic.json @@ -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 + } + } + ] + } + ] +} \ No newline at end of file diff --git a/test/samples/rich_text/rich_text_basic.json b/test/samples/rich_text/rich_text_basic.json new file mode 100644 index 0000000..fbeb274 --- /dev/null +++ b/test/samples/rich_text/rich_text_basic.json @@ -0,0 +1,9 @@ +{ + "type": "text", + "text": "I am a bold rich text block!", + "style": { + "bold": true, + "italic": true, + "strike": false + } +} \ No newline at end of file diff --git a/test/samples/rich_text/rich_text_link_basic.json b/test/samples/rich_text/rich_text_link_basic.json new file mode 100644 index 0000000..3c3ddc9 --- /dev/null +++ b/test/samples/rich_text/rich_text_link_basic.json @@ -0,0 +1,10 @@ +{ + "type": "link", + "url": "https://google.com/", + "text": "Google", + "unsafe": false, + "style": { + "bold": true, + "italic": false + } +} \ No newline at end of file diff --git a/test/samples/rich_text/rich_text_list_basic.json b/test/samples/rich_text/rich_text_list_basic.json new file mode 100644 index 0000000..48b267a --- /dev/null +++ b/test/samples/rich_text/rich_text_list_basic.json @@ -0,0 +1,36 @@ +{ + "type": "rich_text_list", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Oh" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Hi" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Mark" + } + ] + } + ], + "style": "bullet", + "indent": 0, + "offset": 0, + "border": 1 +} \ No newline at end of file diff --git a/test/samples/rich_text/rich_text_list_ordered.json b/test/samples/rich_text/rich_text_list_ordered.json new file mode 100644 index 0000000..9d42196 --- /dev/null +++ b/test/samples/rich_text/rich_text_list_ordered.json @@ -0,0 +1,27 @@ +{ + "type": "rich_text_list", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Oh" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Hi" + } + ] + } + ], + "style": "ordered", + "indent": 1, + "offset": 2, + "border": 3 +} \ No newline at end of file diff --git a/test/samples/rich_text/rich_text_preformatted_code_block_basic.json b/test/samples/rich_text/rich_text_preformatted_code_block_basic.json new file mode 100644 index 0000000..4cb02ec --- /dev/null +++ b/test/samples/rich_text/rich_text_preformatted_code_block_basic.json @@ -0,0 +1,10 @@ +{ + "type": "rich_text_preformatted", + "elements": [ + { + "type": "text", + "text": "\ndef hello_world():\n print('hello, world')" + } + ], + "border": 0 +} \ No newline at end of file diff --git a/test/samples/rich_text/rich_text_quote_basic.json b/test/samples/rich_text/rich_text_quote_basic.json new file mode 100644 index 0000000..2f7fc62 --- /dev/null +++ b/test/samples/rich_text/rich_text_quote_basic.json @@ -0,0 +1,10 @@ +{ + "type": "rich_text_quote", + "elements": [ + { + "type": "text", + "text": "Great and good are seldom the same man" + } + ], + "border": 1 +} \ No newline at end of file diff --git a/test/samples/rich_text/rich_text_section_basic.json b/test/samples/rich_text/rich_text_section_basic.json new file mode 100644 index 0000000..d56875b --- /dev/null +++ b/test/samples/rich_text/rich_text_section_basic.json @@ -0,0 +1,9 @@ +{ + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "The only true wisdom is in knowing you know nothing" + } + ] +} \ No newline at end of file diff --git a/test/unit/test_blocks.py b/test/unit/test_blocks.py index 95c938c..d3f6501 100644 --- a/test/unit/test_blocks.py +++ b/test/unit/test_blocks.py @@ -11,6 +11,9 @@ InvalidUsageError, Option, PlainTextInput, + RichText, + RichTextBlock, + RichTextSection, SectionBlock, Text, TextType, @@ -178,3 +181,27 @@ def test_input_block_invalid_label_type() -> None: element=Text("hello"), block_id="fake_block_id", ) + + +def test_basic_rich_text_block() -> None: + assert fetch_sample(path="test/samples/blocks/rich_text_block_basic.json") == repr( + RichTextBlock( + RichTextSection( + [ + RichText( + "You 'bout to witness hip-hop in its most purest", + bold=True, + ), + RichText( + "Most rawest form, flow almost flawless", + strike=True, + ), + RichText( + "Most hardest, most honest known artist", + italic=True, + ), + ] + ), + block_id="fake_block_id", + ) + ) diff --git a/test/unit/test_rich_text.py b/test/unit/test_rich_text.py new file mode 100644 index 0000000..b1b9b9a --- /dev/null +++ b/test/unit/test_rich_text.py @@ -0,0 +1,128 @@ +from slackblocks.rich_text import ( + ListType, + RichText, + RichTextLink, + RichTextList, + RichTextPreformattedCodeBlock, + RichTextQuote, + RichTextSection, +) + +from .utils import fetch_sample + + +def test_rich_text_basic() -> None: + assert fetch_sample(path="test/samples/rich_text/rich_text_basic.json") == repr( + RichText( + text="I am a bold rich text block!", + bold=True, + italic=True, + strike=False, + ) + ) + + +def test_rich_text_link_basic() -> None: + assert fetch_sample( + path="test/samples/rich_text/rich_text_link_basic.json" + ) == repr( + RichTextLink( + url="https://google.com/", + text="Google", + unsafe=False, + bold=True, + italic=False, + ) + ) + + +def test_rich_text_list_basic() -> None: + assert fetch_sample( + path="test/samples/rich_text/rich_text_list_basic.json" + ) == repr( + RichTextList( + elements=[ + RichTextSection( + elements=[ + RichText( + text="Oh", + ) + ] + ), + RichTextSection( + elements=[ + RichText( + text="Hi", + ) + ] + ), + RichTextSection(elements=[RichText(text="Mark")]), + ], + style=ListType.BULLET, + indent=0, + offset=0, + border=1, + ) + ) + + +def test_rich_text_list_ordered() -> None: + assert fetch_sample( + path="test/samples/rich_text/rich_text_list_ordered.json" + ) == repr( + RichTextList( + elements=[ + RichTextSection( + elements=[ + RichText( + text="Oh", + ) + ] + ), + RichTextSection( + elements=[ + RichText( + text="Hi", + ) + ] + ), + ], + style=ListType.ORDERED, + indent=1, + offset=2, + border=3, + ) + ) + + +def test_rich_text_preformatted_code_block_basic() -> None: + assert fetch_sample( + path="test/samples/rich_text/rich_text_preformatted_code_block_basic.json" + ) == repr( + RichTextPreformattedCodeBlock( + elements=[RichText(text="\ndef hello_world():\n print('hello, world')")], + border=0, + ) + ) + + +def test_rich_text_quote_basic() -> None: + assert fetch_sample( + path="test/samples/rich_text/rich_text_quote_basic.json" + ) == repr( + RichTextQuote( + elements=[RichText(text="Great and good are seldom the same man")], border=1 + ), + ) + + +def test_rich_text_section() -> None: + assert fetch_sample( + path="test/samples/rich_text/rich_text_section_basic.json" + ) == repr( + RichTextSection( + elements=[ + RichText(text="The only true wisdom is in knowing you know nothing") + ] + ) + )