diff --git a/snakemd/elements.py b/snakemd/elements.py index 2a1d8d7..c6a034c 100644 --- a/snakemd/elements.py +++ b/snakemd/elements.py @@ -845,6 +845,9 @@ class MDList(Block): (i.e., :code:`- [x]`) - set to :code:`Iterable[bool]` to render the checked status of the top-level list elements directly + + .. deprecated:: 2.4 + Use :class:`snakemd.Checklist` template instead """ def __init__( @@ -859,7 +862,7 @@ def __init__( checked if checked is None or isinstance(checked, bool) else list(checked) ) self._space = "" - if isinstance(self._checked, list) and self._top_level_count() != len( + if isinstance(self._checked, list) and MDList._top_level_count(self._items) != len( self._checked ): raise ValueError( @@ -896,7 +899,7 @@ def __str__(self) -> str: i = 1 for item in self._items: if isinstance(item, MDList): - item._space = self._space + " " * self._get_indent_size(i) + item._space = self._space + " " * self._get_indent_size(self._ordered, i) output.append(str(item)) else: # Create the start of the row based on `order` parameter @@ -964,7 +967,8 @@ def _process_items(items) -> list[Block]: processed.append(item) return processed - def _top_level_count(self) -> int: + @staticmethod + def _top_level_count(items) -> int: """ Given that MDList can accept a variety of blocks, we need to know how many items in the provided list @@ -972,26 +976,31 @@ def _top_level_count(self) -> int: We use this number to throw errors if this count does not match up with the checklist count. + :param items: + a list of items :return: a count of top-level elements """ count = 0 - for item in self._items: + for item in items: if not isinstance(item, MDList): count += 1 return count - def _get_indent_size(self, item_index: int = -1) -> int: + @staticmethod + def _get_indent_size(ordered: bool, item_index: int = -1) -> int: """ Returns the number of spaces that any sublists should be indented. + :param bool ordered: + the boolean value indicating if a list is ordered :param int item_index: the index of the item to check (only used for ordered lists); defaults to -1 :return: the number of spaces """ - if not self._ordered: + if not ordered: return 2 # Ordered items vary in length, so we adjust the result based on the index return 2 + len(str(item_index)) diff --git a/snakemd/templates.py b/snakemd/templates.py index bef9736..266a66b 100644 --- a/snakemd/templates.py +++ b/snakemd/templates.py @@ -83,15 +83,148 @@ class Kind(Enum): WARNING = auto() CAUTION = auto() - def __init__(self, kind: Kind, message: str | Iterable[str | Inline | Block]) -> None: + def __init__( + self, + kind: Kind, + message: str | Iterable[str | Inline | Block] + ) -> None: + super().__init__() self._kind = kind self._message = message + self._alert = Quote([f"[!{self._kind.name}]", self._message]) def __str__(self) -> str: - return str(Quote([f"[!{self._kind.name}]", self._message])) + """ + Renders self as a markdown ready string. See + :class:`snakemd.Quote` for more details. + + :return: + the Alert as a markdown string + """ + return str(self._alert) + + def __repr__(self) -> str: + """ + Renders self as an unambiguous string for development. + See :class:`snakemd.Quote` for more details. + + :return: + the Alert as a development string + """ + return repr(self._alert) + + +class Checklist(Template): + """ + Checklist is an MDList extension to provide support + for Markdown checklists, which are a Markdown + extension. Previously, this feature was baked + directly into MDList. However, because checklists + are not a vanilla Markdown feature, they were + moved here. + + .. versionadded:: 2.4 + Included for user convenience + + :raises ValueError: + when the checked argument is an Iterable[bool] that does not + match the number of top-level elements in the list + :param Iterable[str | Inline | Block] items: + a "list" of objects to be rendered as a list + :param bool | Iterable[bool] checked: + the checked state of the list + + - defaults to :code:`False` which renders a series of unchecked + boxes (i.e., :code:`- [ ]`) + - set to :code:`True` to render a series of checked boxes + (i.e., :code:`- [x]`) + - set to :code:`Iterable[bool]` to render the checked + status of the top-level list elements directly + """ + + def __init__( + self, + items: Iterable[str | Inline | Block], + checked: bool | Iterable[bool] = False + ) -> None: + super().__init__() + self._items: list[Block] = MDList._process_items(items) + self._checked: bool | list[bool] = ( + checked if checked is None or isinstance( + checked, bool) else list(checked) + ) + self._space = "" + if isinstance(self._checked, list) and MDList._top_level_count(self._items) != len( + self._checked + ): + raise ValueError( + "Number of top-level elements in checklist does not " + "match number of booleans supplied by checked parameter: " + f"{self._checked}" + ) + + def __str__(self): + """ + Renders the checklist as a markdown string. Checklists + function very similarly to unorded lists, but require + additional information about the status of each task + (i.e., whether it is checked or not). + + .. code-block:: markdown + + - [ ] Do reading + - [X] Do writing + + :return: + the list as a markdown string + """ + output = [] + i = 1 + for item in self._items: + if isinstance(item, (Checklist, MDList)): + item._space = self._space + " " * 2 + output.append(str(item)) + else: + row = f"{self._space}-" + + if isinstance(self._checked, bool): + checked_str = "X" if self._checked else " " + row = f"{row} [{checked_str}] {item}" + else: + checked_str = "X" if self._checked[i - 1] else " " + row = f"{row} [{checked_str}] {item}" + + output.append(row) + i += 1 + + checklist = "\n".join(output) + logger.debug("Rendered checklist: %r", checklist) + return checklist def __repr__(self) -> str: - return f"Alerts(kind={self._kind!r},message={self._message!r})" + """ + Renders self as an unambiguous string for development. + In this case, it displays in the style of a dataclass, + where instance variables are listed with their + values. Unlike many of the other templates, Checklists + aren't a direct wrapper of MDList, and therefore cannot + be represented as MDList alone. + + .. doctest:: checklist + + >>> checklist = Checklist(["Do Homework"], True) + >>> repr(checklist) + "Checklist(items=[Paragraph(...)], checked=True)" + + :return: + the Checklist object as a development string + """ + return ( + f"Checklist(" + f"items={self._items!r}, " + f"checked={self._checked!r}" + f")" + ) class CSVTable(Template): diff --git a/tests/templates/test_checklist.py b/tests/templates/test_checklist.py new file mode 100644 index 0000000..450e290 --- /dev/null +++ b/tests/templates/test_checklist.py @@ -0,0 +1,26 @@ +from snakemd.elements import MDList +from snakemd.templates import Checklist + +def test_checklist_one_item_true(): + checklist = Checklist(["Write code"], True) + assert str(checklist) == "- [X] Write code" + +def test_checklist_one_item_false(): + checklist = Checklist(["Write code"], False) + assert str(checklist) == "- [ ] Write code" + +def test_checklist_one_item_explicit(): + checklist = Checklist(["Write code"], [False]) + assert str(checklist) == "- [ ] Write code" + +def test_checklist_many_items_true(): + checklist = Checklist(["Write code", "Do Laundry"], True) + assert str(checklist) == "- [X] Write code\n- [X] Do Laundry" + +def test_checklist_many_items_nested_true(): + checklist = Checklist(["Write code", Checklist(["Implement TODO"], True), "Do Laundry"], True) + assert str(checklist) == "- [X] Write code\n - [X] Implement TODO\n- [X] Do Laundry" + +def test_checklist_many_items_nested_mdlist_true(): + checklist = Checklist(["Write code", MDList(["Implement TODO"]), "Do Laundry"], True) + assert str(checklist) == "- [X] Write code\n - Implement TODO\n- [X] Do Laundry"