Source code for bootlace.util

import collections
import functools
import itertools
import warnings
from collections.abc import Callable
from collections.abc import Iterable
from collections.abc import Iterator
from collections.abc import Mapping
from collections.abc import MutableMapping
from collections.abc import MutableSet
from typing import Any
from typing import Generic
from typing import Protocol
from typing import TypeAlias
from typing import TypeVar

import attrs
from dominate import tags
from dominate.dom_tag import dom_tag
from dominate.util import container
from dominate.util import text
from flask import request
from markupsafe import Markup

T = TypeVar("T")

__all__ = [
    "BootlaceWarning",
    "Classes",
    "HtmlIDScope",
    "IntoTag",
    "MaybeTaggable",
    "Taggable",
    "Tag",
    "as_tag",
    "ids",
    "is_active_endpoint",
    "maybe",
    "render",
]


[docs] class BootlaceWarning(UserWarning): """A warning specific to Bootlace"""
def _monkey_patch_dominate() -> None: """Monkey patch the dominate tags to support class attribute manipulation""" tags.html_tag.classes = property(lambda self: Classes(self)) # type: ignore tags.html_tag.data = PrefixAccessor("data") # type: ignore tags.html_tag.aria = PrefixAccessor("aria") # type: ignore tags.html_tag.hx = PrefixAccessor("hx") # type: ignore
[docs] class Taggable(Protocol): """Protocol for objects that can be converted to a tag."""
[docs] def __tag__(self) -> dom_tag: """Convert the object to a dominate tag. This method gives objects control over how they are processed by :func:`as_tag`. It should return a :mod:`dominate` tag. If a taggable object contains other taggable objects, it should use :func:`as_tag` to convert them, and then apply any additional processing as necessary to the returned :class:`~dominate.html_tag`. :meta public: :returns: A :mod:`dominate` tag. """ ...
#: A type that can be converted to a tag IntoTag: TypeAlias = Taggable | dom_tag #: A type that can be converted to a tag via :func:`as_tag` MaybeTaggable: TypeAlias = IntoTag | str | Iterable[Taggable | dom_tag]
[docs] def as_tag(item: MaybeTaggable) -> dom_tag: """Convert an item to a dominate tag. :mod:`bootlace` uses :mod:`dominate` to render HTML. To do this, objects implement the :class:`Taggable` protocol, providing a ``__tag__`` dunder method. This method will also accept regular :mod:`dominate` tags, strings, and iterables of :class:`Taggable` objects. It will try to always return a :mod:`dominate` tag. To render taggable objects in a template, use :func:`render`, a convenience function that will convert the object to a :mod:`dominate` tag and then render it to a :class:`Markup` object for use in a template. Handling notes -------------- When a string is passed in, it will be wrapped with :class:`dominate.util.text` to render a literal string as a tag. When an iterable of taggable items is passed, it is returned as a :class:`dominate.util.container`, which will render the tags in sequence. Unknown types are displayed using their string representation (by calling :class:`str` on them), along with a comment in the rendered HTML and a :class:`Bootlace` warning emitted. Arguments --------- :param item: The item to convert to :mod:`dominate` tags. :returns: A :mod:`dominate` tag. """ if isinstance(item, tags.html_tag): # item.children = [as_tag(child) for child in item.children] return item if hasattr(item, "__tag__"): return item.__tag__() if isinstance(item, str): return text(item) if isinstance(item, Iterable): return container(*[as_tag(i) for i in item]) warnings.warn(BootlaceWarning(f"Rendered type {item.__class__.__name__} not explicitly supported"), stacklevel=2) return container(text(str(item)), tags.comment(f"Rendered type {item.__class__.__name__} not supported"))
[docs] def render(item: MaybeTaggable) -> Markup: """Render an item to a Markup object. This function is a convenience wrapper around :func:`as_tag` and :meth:`dominate.tags.html_tag.render`. It will try to convert most objects to a :mod:`dominate` tag and then render it to a :class:`Markup` object which can be inserted into :mod:`jinja` templates. Arguments --------- :param item: The item to render. See :func:`as_tag` for more information. :returns: A :class:`Markup` object, suitable for inserting into a :mod:`jinja` template. """ return Markup(as_tag(item).render())
[docs] class Classes(MutableSet[str]): """A helper for manipulating the class attribute on a tag.""" def __init__(self, tag: tags.html_tag) -> None: self.tag = tag def __contains__(self, cls: object) -> bool: return cls in self.tag.attributes.get("class", "").split() def __iter__(self) -> Iterator[str]: return iter(self.tag.attributes.get("class", "").split()) def __len__(self) -> int: return len(self.tag.attributes.get("class", "").split())
[docs] def add(self, *classes: str) -> tags.html_tag: # type: ignore[override] """Add classes to the tag.""" current: list[str] = self.tag.attributes.get("class", "").split() for cls in classes: if cls not in current: current.append(cls) self.tag.attributes["class"] = " ".join(current) return self.tag
[docs] def remove(self, *classes: str) -> tags.html_tag: # type: ignore[override] """Remove classes from the tag.""" current: list[str] = self.tag.attributes.get("class", "").split() for cls in classes: if cls in current: current.remove(cls) self.tag.attributes["class"] = " ".join(current) return self.tag
[docs] def discard(self, value: str) -> None: """Remove a class if it exists.""" self.remove(value)
[docs] def swap(self, old: str, new: str) -> tags.html_tag: """Swap one class for another.""" current: list[str] = self.tag.attributes.get("class", "").split() if old in current: current.remove(old) if new not in current: current.append(new) self.tag.attributes["class"] = " ".join(current) return self.tag
@attrs.define class PrefixAccessor: """A helper for accessing attributes with a prefix.""" #: Attribute prefix prefix: str = attrs.field() def __get__(self, instance: tags.html_tag, owner: type[tags.html_tag]) -> "PrefixAccess": return PrefixAccess(self.prefix, instance) @attrs.define class PrefixAccess(MutableMapping[str, str]): #: Attribute prefix prefix: str = attrs.field() #: The tag to access tag: tags.html_tag = attrs.field() def __getitem__(self, name: str) -> str: return self.tag.attributes[f"{self.prefix}-{name}"] def __setitem__(self, name: str, value: str) -> None: self.tag.attributes[f"{self.prefix}-{name}"] = value def __delitem__(self, name: str) -> None: del self.tag.attributes[f"{self.prefix}-{name}"] def __iter__(self) -> Iterator[str]: for key in self.tag.attributes: if key.startswith(f"{self.prefix}-"): yield key[len(self.prefix) + 1 :] def __len__(self) -> int: return sum(1 for _ in self) def set(self, name: str, value: str) -> tags.html_tag: """Set an attribute with the given name.""" self[name] = value return self.tag def remove(self, name: str) -> tags.html_tag: """Remove an attribute with the given name.""" del self[name] return self.tag
[docs] @attrs.define class HtmlIDScope: """A helper for generating unique HTML IDs.""" #: A mapping of scopes to counters scopes: collections.defaultdict[str, itertools.count] = attrs.field( factory=lambda: collections.defaultdict(itertools.count) )
[docs] def __call__(self, scope: str) -> str: """Generate a unique ID for a given scope. Parameters ---------- scope : str Scopes are used to group IDs together, e.g. items in a list, or a form and its fields. """ counter = next(self.scopes[scope]) if counter == 0: return scope return f"{scope}-{counter}"
[docs] def factory(self, scope: str) -> functools.partial: """Create a factory function for generating IDs in a specific scope.""" return functools.partial(self, scope)
[docs] def reset(self) -> None: """Reset all ID scopes.""" self.scopes.clear()
ids = HtmlIDScope()
[docs] def maybe(cls: type[T]) -> Callable[[str | T], T]: """Convert a string to a class instance if necessary.""" def converter(value: str | T) -> T: if isinstance(value, str): return cls(value) # type: ignore return value return converter
[docs] def is_active_endpoint(endpoint: str, url_kwargs: Mapping[str, Any], ignore_query: bool = True) -> bool: """Check if the current request is for the given endpoint and URL kwargs""" if request.endpoint != endpoint: return False if request.url_rule is None: # pragma: no cover return False try: rule_url = request.url_rule.build(url_kwargs, append_unknown=not ignore_query) except TypeError: # pragma: no cover return False if rule_url is None: return False _, url = rule_url return url == request.path
def is_active_blueprint(blueprint: str) -> bool: """Check if the current request is for the given blueprint""" return request.blueprint == blueprint H = TypeVar("H", bound=tags.html_tag)
[docs] @attrs.define class Tag(Generic[H]): """A helper for creating tags. Holds the tag type as well as attributes for the tag. This can be used by calling the instance as a function to create a tag, or by calling the :meth:`update` method to apply the attributes to an existing tag. """ #: The tag type tag: type[H] = attrs.field() #: The classes to apply to the tag classes: set[str] = attrs.field(factory=set) #: The attributes to apply to the tag attributes: dict[str, str] = attrs.field(factory=dict)
[docs] def __tag__(self) -> H: """Create a tag from the attributes and classes.""" tag = self.tag(**self.attributes) tag.classes.add(*self.classes) return tag
[docs] def __call__(self, *args: Any, **kwds: Any) -> H: """Create a tag from the attributes and classes. This method is a convenience wrapper around :meth:`__tag__` that allows the tag to be created with additional arguments and keyword arguments passed to the tag constructor. """ tag = self.tag(*args, **{**self.attributes, **kwds}) tag.classes.add(*self.classes) return tag
def __setitem__(self, name: str, value: str) -> None: self.attributes[name] = value def __getitem__(self, name: str) -> str: return self.attributes[name]
[docs] def update(self, tag: H) -> H: """Update the tag with the attributes and classes.""" tag.classes.add(*self.classes) tag.attributes.update(self.attributes) return tag