from __future__ import annotations
import warnings
from collections.abc import Iterable
from collections.abc import Iterable as IterableABC
from copy import deepcopy
from typing import Any, ClassVar, Final, Literal, TypeVar
from django.template.loader import get_template
from django.templatetags.static import static
# Define a type variable for Widget return types
WidgetT = TypeVar("WidgetT", bound="Widget")
[docs]class Block(TemplateWidget):
"""
Render a single HTML element.
All the constructor parameters can be set in a subclass of this class as
class attributes. Parameters to the constructor override any defined class
attributes.
Example:
.. code-block:: python
from wildewidgets import Block
block = Block(
'Hello World',
tag='a',
name='foo',
modifier='bar',
css_class='blah dah',
attributes={'href': 'https://example.com'}
)
When rendered in the template with the ``wildewidgets`` template tag, this
will produce:
.. code-block:: html
<a href="https://example.com" class="foo foo--bar blah dah">Hello World</a>
Args:
*blocks: any content to be rendered in the block, either other blocks, or
plain strings, or a mix of both. If this is not specified, the
:py:attr:`contents` attribute will be used instead. If you specify
this, it will override :py:attr:`contents`.
Keyword Args:
tag: the name of the HTML element to use as our tag, e.g. ``div``
name: This CSS class will be added to the classes to identify this
block
modifier: If specified, also add a class named ``{name}--{modifier}`` to the
CSS classes
css_class: a string of CSS classes to apply to the block
css_id: Use this as the ``id`` attribute for the block
attributes: Set any additional attributes for the block
data_attributes: Set ``data-bs-`` attributes for the block
aria_attributes: Set ``aria-`` attributes for the block
empty: if ``True``, this element uses no close tag
script: Javascript to attach to this block
Raises:
ValueError: empty is ``True``, but contents were added to this block
"""
[docs] class RequiredAttrOrKwarg(ValueError):
def __init__(self, name: str):
super().__init__(
f'"{name}" must be provided as either'
"a keyword argument or as a class attribute"
)
#: The name of the template to use to render this block
template_name: str = "wildewidgets/block.html"
#: block is the official wildewidgets name of the block; it can't be changed
#: by constructor kwargs
block: str = ""
#: The name of the HTML element to use as our tag, e.g. ``div``
tag: str = "div"
#: The CSS class that will be added to this element to as an identifier for
#: this kind of block
name: str | None = None
#: If specified, also add a class named ``{name}--{modifier}`` to the CSS
#: classes
modifier: str | None = None
#: A string of CSS classes to apply to this block
css_class: str = ""
#: The CSS ``id`` for this block
css_id: str | None = None
#: A list of strings or Blocks that will be the content for this block
contents: list[str | Widget] = [] # noqa: RUF012
#: Additional HTML attributes to set on this block
attributes: ClassVar[dict[str, str]] = {}
#: Additional ``data-bs-`` attributes to set on this block
data_attributes: ClassVar[dict[str, str]] = {}
#: Additional ``aria-`` attributes to set on this block
aria_attributes: ClassVar[dict[str, str]] = {}
#: If ``True``, this block uses no close tag
empty: bool = False
#: The Javascript to apply to this block. It will appear in the HTML directly after
#: the block appears. Do not surround this with a ``<script>`` tag; that will be
#: done for you.
script: str | None = None
def __init__(
self,
*blocks: str | Widget | Block,
tag: str | None = None,
name: str | None = None,
modifier: str | None = None,
css_class: str | None = None,
css_id: str | None = None,
empty: bool | None = None,
script: str | None = None,
attributes: dict[str, str] | None = None,
data_attributes: dict[str, str] | None = None,
aria_attributes: dict[str, str] | None = None,
) -> None:
super().__init__()
self._name = name if name is not None else self.name
self._modifier = modifier if modifier is not None else self.modifier
self._css_class = css_class if css_class is not None else self.css_class
if self._css_class is None:
# Somebody actually went and set our class attribute to ``None``
self._css_class = ""
self._css_id = css_id if css_id is not None else self.css_id
self._tag = tag if tag is not None else self.tag
self.contents = list(blocks) if blocks else deepcopy(self.contents)
self.empty = empty if empty is not None else self.empty
self.script = script if script is not None else self.script
self._attributes = (
attributes if attributes is not None else deepcopy(self.attributes)
)
self._data_attributes = (
data_attributes
if data_attributes is not None
else deepcopy(self.data_attributes)
)
self._aria_attributes = (
aria_attributes
if aria_attributes is not None
else deepcopy(self.aria_attributes)
)
if self.empty and len(blocks) > 0:
msg = (
f"{self.__class__.__name__}: This block takes no content "
"because empty=True."
)
raise ValueError(msg)
if self.empty:
self.template_name = "wildewidgets/block--simple.html"
#: The internal list of blocks that are this block's content
self.blocks: list[str | Widget] = []
self.add_blocks()
@property
def css_classes(self) -> list[str]:
"""
Return a list of our CSS classes.
Note:
This excludes the set :py:attr:`name`, :py:attr:`block` and
:py:attr:`modifier` classes. Read those directly from their
attributes instead.
Returns:
The list of the CSS classes for this block, excluding the
``block``, ``name`` and ``modifier`` classes.
"""
return list(self._css_class.split())
@css_classes.setter
def css_classes(self, classes: list[str]) -> None:
"""
Set our CSS classes to be ``classes``.
Note:
Don't try to change :py:attr:`name``, :py:attr:`block`` or
:py:attr:`modifier`` classes with this; set them by direct assigment
instead.
Args:
classes: A list of CSS classes to set as our :py:attr:`_css_class`
attribute. This will replace any existing classes.
"""
self._css_class = " ".join(classes)
[docs] def add_class(self, css_class: str) -> None:
"""
Add a CSS class to our :py:attr:`_css_class` attribute. This is a
convenience method to allow us to add a class withouth having to do
string manipulation.
Note:
If you need to change :py:attr:`name` or :py:attr:`modifier`, set them
directly instead of using this.
Args:
css_class: The CSS to add
"""
# Note: we're purposely not using set() here because Tabler sometimes
# requires a specific class ordering in order to work properly. set()
# destroys our ordering.
if css_class:
if " " in self._css_class:
# Ensure we don't try to add ``None`` or empty string here
classes = self._css_class.split()
else:
classes = [self._css_class]
if css_class not in classes:
classes.append(css_class)
self._css_class = " ".join(list(classes))
[docs] def remove_class(self, css_class: str) -> None:
"""
Remove a CSS class from our :py:attr:`_css_class` attribute. This is a
convenience method to allow us to remove a class withouth having to do
string manipulation.
Note:
This method can only remove things from :py:attr:`_css_class`. You
can't remove :py:attr:`name`, :py:attr:`modifier` or :py:attr:`block`
with this method, since those are supposed to be purely informational.
Args:
css_class: The CSS to remove
"""
# Note: we're purposely not using set() here because Tabler sometimes
# requires a specific class ordering in order to work properly. set()
# destroys our ordering.
if css_class:
# Ensure we don't try to remove ``None`` or empty string here
classes = self._css_class.split()
if css_class in classes:
classes.remove(css_class)
self._css_class = " ".join(list(classes))
[docs] def add_blocks(self) -> None:
"""
Add each block in :py:attr:`contents` to our :py:attr:`blocks` list.
Note:
This is here so it can be overridden.
"""
for block in self.contents:
self.add_block(block)
[docs] def add_block(self, block: str | Widget | Block) -> None:
"""
Add a block to our content. This will appear inside the tag for this block.
Args:
block: the block to add to our content
"""
if self.empty:
msg = (
f"{self.__class__.__name__}: This block takes no content because "
"empty=True."
)
raise ValueError(msg)
self.blocks.append(block)
[docs] def get_script(self) -> str | None:
"""
Return any javascript to attach to this block.
Note:
The Javascript will will appear in the HTML directly after the block
appears. Do not surround this with a ``<script>`` tag; that will be
done for you.
Returns:
The fully rendered Javascript for this block, if any.
"""
return self.script
[docs] def get_context_data(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
"""
Update the template context dictionary used when rendering this block.
Args:
*args: positional arguments (ignored)
Keyword Args:
**kwargs: the current context dictionary
Returns:
The updated context dictionary
"""
context = super().get_context_data(*args, **kwargs)
css_class = " ".join(self.css_classes)
name = self._name if self._name is not None else ""
block = self.block if self.block is not None else ""
modifier = f"{name}--{self._modifier}" if name and self._modifier else ""
context["tag"] = self._tag
context["block_name"] = block
context["name"] = name
context["css_classes"] = f"{name} {modifier} {block} {css_class}".strip()
context["script"] = self.get_script()
context["css_id"] = self._css_id
context["blocks"] = self.blocks
context["attributes"] = self._attributes
context["data_attributes"] = self._data_attributes
context["aria_attributes"] = self._aria_attributes
return context
[docs]class Container(Block):
"""
A `Bootstrap container <https://getbootstrap.com/docs/5.2/layout/containers/>`_
Example:
.. code-block:: python
from wildewidgets import Container
c = Container(size='lg')
Keyword Args:
size: The max viewport size for the container, in bootstrap sizes, or
``fluid``.
Raises:
ValueError: ``size`` was not one of the valid sizes
"""
#: The valid sizes for the Tabler container. These are the bootstrap sizes
#: ``sm``, ``md``, ``lg``, ``xl``, ``xxl`` and ``fluid``.
VALID_SIZES: Final[list[Literal["sm", "md", "lg", "xl", "xxl", "fluid"]]] = [
"sm",
"md",
"lg",
"xl",
"xxl",
"fluid",
]
#: The max viewport size for the container, in bootstrap sizes, or
#:``fluid``.
size: str | None = None
def __init__(
self, *blocks: str | Widget, size: str | None = None, **kwargs: Any
) -> None:
self.size = size or self.size
if self.size and self.size not in self.VALID_SIZES:
valid_sizes = ", ".join(self.VALID_SIZES)
msg = (
f'"{self.size}" is not a valid container size. Valid sizes: "'
f'{valid_sizes}"'
)
raise ValueError(msg)
super().__init__(*blocks, **kwargs)
# IMPORTANT: the Tabler rules sometimes expect that the container-* class to
# be the first class in the class list. If it is not first, the CSS rules
# change. E.g.:
#
# .navbar-vertical.navbar-expand-lg > [class^="container"] {
# flex-direction: column;
# align-items: stretch;
# min-height: 100%;
# justify-content: flex-start;
# padding: 0;}
classes = self.css_classes
klass = "container"
if self.size:
klass += f"-{self.size}"
classes.insert(0, klass)
self.css_classes = classes
[docs]class Link(Block):
"""
A simple ``<a>`` tag. We made it into its own block because we need `<a>`
tags so often.
Example:
.. code-block:: python
from wildewidgets import Link
link = Link('click here', url='https://example.com')
Keyword Args:
contents: The contents of the ``<a>``. This can be string, a
:py:class:`Block`, or an iterable of strings and :py:class:`Block`
objects.
url: The URL of the ``<a>``
title: The value of the ``title`` tag for the link
role: The value of the ``role`` attribute for the link
target: The target for this link, aka the context in which the linked
resource will open.
"""
#: The name of the HTML element to use as our tag, e.g. ``a``
tag: str = "a"
#: The contents of the ``<a>``. This can be string, a :py:class:`Block`,
#: or an iterable of strings and :py:class:`Block` objects.
contents: str | Block | Iterable[str | Block] | None = None # type: ignore[assignment]
#: The URL of the ``<a>``
url: str = "#"
#: The value of the ``role`` attribute for the link
role: str = "link"
#: The value of the ``title`` attribute for the link
title: str | None = None
#: The target for this link, aka the context in which the linked resource
#: will open.
target: str | None = None
def __init__(
self,
*contents: str | Block,
url: str | None = None,
role: str | None = None,
title: str | None = None,
target: str | None = None,
**kwargs: Any,
) -> None:
self.url = url or self.url
self.role = role or self.role
self.title = title or self.title
self.target = target or self.target
# TODO: validate url
# TODO: validate that title and role are htmleescaped
if contents:
self.contents = contents
else:
c = self.contents
if isinstance(c, str):
self.contents = [c]
elif isinstance(c, Block):
self.contents = [deepcopy(c)]
elif isinstance(c, IterableABC):
self.contents = deepcopy(c)
else:
self.contents = []
super().__init__(*self.contents, **kwargs)
self._attributes["href"] = self.url
self._attributes["role"] = self.role
if self.title:
self._attributes["title"] = self.title
if self.target:
self._attributes["target"] = self.target
[docs]class UnorderedList(Block):
"""
An HTML unordered list, aka a ``<ul>`` tag.
This wraps each item in :py:attr:`contents` with ``<li>``.
Example:
With constructor arguments:
.. code-block:: python
from wildewidgets import UnorderedList, Link
items = ['foo', 'bar', 'baz', Link['barney', url='https://example.com]]
ul = UnorderedList(*items)
With :py:meth:`add_block`:
.. code-block:: python
from wildewidgets import UnorderedList, Link
items = ['foo', 'bar', 'baz', Link['barney', url='https://example.com]]
ul = UnorderedList()
for item in items:
ul.add_block(item)
"""
#: The name of the HTML element to use as our tag, e.g. ``ul``
tag: str = "ul"
[docs] def add_block(self, block: str | Block, **kwargs: Any) -> None: # type: ignore[override]
"""
Wrap ``block`` in an ``<li>``
:py:class:`~wildewidgets.widgets.base.Block` append it to
:py:attr:`blocks`, using ``**kwargs`` as the keyword arguments for ``Block``.
If ``block`` is already an ``<li>``, just append it to :py:attr:`blocks`.
"""
if isinstance(block, Block) and block._tag == "li":
self.blocks.append(block)
else:
self.blocks.append(Block(block, tag="li", **kwargs))
[docs]class OrderedList(UnorderedList):
"""
An HTML ordered list, aka a ``<ol>`` tag.
This wraps each item in :py:attr:`contents` with ``<li>``.
Example:
With constructor arguments:
... code-block:: python
from wildewidgets import OrderedList, Link
items = ['foo', 'bar', 'baz', Link['barney', url='https://example.com]]
ul = OrderedList(*items)
With :py:meth:`add_block`:
.. code-block:: python
from wildewidgets import OrderedList, Link
items = ['foo', 'bar', 'baz', Link['barney', url='https://example.com]]
ul = OrderedList()
for item in items:
ul.add_block(item)
"""
#: The name of the HTML element to use as our tag, e.g. ``ol``
tag: str = "ol"
[docs]class HTMLList(UnorderedList):
"""
An unordered HTML list.
.. deprecated:: 0.14.0
Use :py:class:`UnorderedList` instead. Change this:
.. code-block:: python
from wildewidgets import HTMLList, Link
items = ['foo', 'bar', 'baz', Link['barney', url='https://example.com]]
block = HTMLList(items=items)
To this:
.. code-block:: python
from wildewidgets import UnorderedList, Link
items = ['foo', 'bar', 'baz', Link['barney', url='https://example.com]]
block = UnorderedList(*items)
"""
def __init__(
self,
*args: Any, # noqa: ARG002
items: list[str] | None = None,
**kwargs: Any,
) -> None:
warnings.warn(
"Deprecated in 0.14.0. Use UnorderedList instead",
DeprecationWarning,
stacklevel=2,
)
if not items:
items = []
super().__init__(*items, **kwargs)
[docs]class Image(Block):
"""
An ``<img>``::
<img src="image.png" alt="My Image" width="100%">
Example:
.. code-block:: python
from wildewidgets import Image
from django.templatetags.static import static
block = Image(src=static('myapp/images/img.png'), alt='The Image')
Keyword Args:
src: the URL of the image. Typically this will be something like
``static('myapp/images/image.png')`` The default is to use a
placeholder image to remind you that you need to fix this.
height: the value of the ``height`` attribute for the ``<img>``
width: the value of the ``width`` attribute for the ``<img>``
alt: the value of the ``alt`` tag for the ``<img>``. If this is not
set either here or as a class attribute, we'll raise ``ValueError`` to
enforce WCAG 2.0 compliance.
Raises:
ValueError: no ``alt`` was provided
"""
#: The name of the HTML element to use as our tag, e.g. ``img``
tag: str = "img"
#: The CSS block name for this widget. This is used to identify the
#: widget in the template and to apply CSS styles.
block: str = "image"
#: The URL of the image. Typically this will be something like :
# ``static('myapp/images/image.png')``. The default is to use a placeholder
#: image to remind you that you need to fix this.
src: str | None = None
#: the value of the ``width`` attribute for the <img>
width: str | None = None
#: the value of the ``height`` attribute for the <img>
height: str | None = None
#: The value of the ``alt`` tag for the <img>. If this is not set either
#: here or in our contructor kwargs, we'll raise ``ValueError`` (to enforce
#: ADA)
alt: str | None = None
def __init__(
self,
src: str | None = None,
height: str | None = None,
width: str | None = None,
alt: str | None = None,
**kwargs: Any,
) -> None:
self.src = src or self.src
if not self.src:
self.src = static("wildewidgets/images/placeholder.png")
# TODO: validate src as a URL/Path
self.width = width or self.width
self.height = height or self.height
self.alt = alt or self.alt
super().__init__(**kwargs)
if self.src:
self._attributes["src"] = self.src
if self.width:
self._attributes["width"] = self.width
if not self.alt:
msg = 'You must provide an "alt" attribute for your Image'
raise ValueError(msg)
self._attributes["alt"] = self.alt
[docs]class LinkedImage(Link):
"""
An ``<img>`` wrapped in an ``<a>``::
<a href="#">
<img src="image.png" alt="My Image" width="100%">
</a>
Note:
If you want to modify the encapsulated image (to add css classes, for
example), you can do so by modifying the attributes on :py:attr:`image`
after constructing the ``LinkedImage``:
.. code-block:: python
from wildewidgets import LinkedImage
from django.templatetags.static import static
block = LinkedImage(
image_src='image.png',
image_alt='My Image',
url='http://example.com'
)
block.image.add_class('my-extra-class')
block.image._css_id = 'the-image'
Keyword Args:
image_src: the URL of the image. Typically this will be something like
``static('myapp/images/image.png')``
image_width: the value of the ``width`` attribute for the ``<img>``
image_alt: the value of the ``alt`` tag for the ``<img>``. If this is not
set either here or as a class attribute, we'll raise ``ValueError`` to
enforce WCAG 2.0 compliance.
Raises:
ValueError: no ``alt`` was provided
"""
block: str = "linked-image"
#: The URL of the image. Typically this will be something like
#: ``static('myapp/images/image.png')``
image_src: str | None = None
#: the value of the ``width`` attribute for the ``<img>``.
image_width: str | None = None
#: The value of the ``alt`` tag for the ``<img>``. If this is not set either
#: here or in our contructor kwargs, we'll raise ``ValueError`` to enforce
#: WCAG 2.0 compliance.
image_alt: str | None = None
def __init__(
self,
image_src: str | None = None,
image_width: str | None = None,
image_alt: str | None = None,
**kwargs: Any,
) -> None:
self.image_src = image_src or self.image_src
if not self.image_src:
self.image_src = static("sphinx_hosting/images/placeholder.png")
self.image_width = image_width or self.image_width
self.image_alt = image_alt or self.image_alt
#: The actual image block that we will wrap with an ``<a>``
self.image: Image = Image(
src=self.image_src, width=self.image_width, alt=self.image_alt
)
super().__init__(self.image, **kwargs)