Skip to content

Commit

Permalink
Merge pull request #1 from alexrudy/feature/breadcrumb
Browse files Browse the repository at this point in the history
Adds breadcrumb extension
  • Loading branch information
alexrudy authored Mar 21, 2024
2 parents 78c05b9 + ed60c79 commit 95f5e66
Show file tree
Hide file tree
Showing 9 changed files with 508 additions and 17 deletions.
249 changes: 249 additions & 0 deletions src/bootlace/breadcrumbs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
from collections.abc import Callable
from collections.abc import Iterator
from collections.abc import Mapping
from typing import Any
from typing import Protocol
from typing import TypeVar

import attrs
from dominate import tags
from dominate.util import text
from flask import Blueprint
from flask import current_app
from flask import Flask
from flask import request
from flask import url_for
from werkzeug.local import LocalProxy

from .util import as_tag
from .util import is_active_endpoint


class Named(Protocol):

__name__: str


V = TypeVar("V", bound=Named)

EXTENSION_KEY: str = "bootlace.breadcrumbs"
DIVIDER_SETTING: str = "BOOTLACE_BREADCRUMBS_DIVIDER"


def endpoint_name(instance: object, attribute: attrs.Attribute, value: str) -> None:
if "." in value:
raise ValueError("Endpoint names cannot contain dots")


@attrs.define(frozen=True, init=False)
class KeywordArguments(Mapping[str, Any]):

_arguments: frozenset[tuple[str, Any]]

def __init__(self, *args: Any, **kwargs: Any) -> None:
arguments = frozenset(dict(*args, **kwargs).items())
object.__setattr__(self, "_arguments", arguments)

def as_dict(self) -> dict[str, Any]:
return dict(self._arguments)

def __getitem__(self, __key: str) -> Any:
return self.as_dict()[__key]

def __iter__(self) -> Iterator[str]:
return iter((key for key, _ in self._arguments))

def __len__(self) -> int:
return len(self._arguments)

def __repr__(self) -> str:
return f"KeywordArguments({self.as_dict()!r})"


@attrs.define(frozen=True, repr=False)
class Endpoint:
"""An endpoint for a breadcrumb, as captured at registration"""

context: None | Blueprint
name: str = attrs.field(validator=endpoint_name)
url_kwargs: KeywordArguments = attrs.field(factory=lambda: KeywordArguments(), converter=KeywordArguments)
ignore_query: bool = True

@property
def url(self) -> str:

if isinstance(self.context, Blueprint):
name = f"{self.context.name}.{self.name}"
return url_for(name, **self.url_kwargs)

return url_for(self.name, **self.url_kwargs)

@property
def active(self) -> bool:
return is_active_endpoint(self.name, self.url_kwargs, self.ignore_query)

def __repr__(self) -> str:
parts = []
if self.context is not None:
parts.append(f"{self.context.name:s}.{self.name:s}")
else:
parts.append(f"{self.name:s}")

if self.url_kwargs:
parts.append(f", {self.url_kwargs!r}")

if not self.ignore_query:
parts.append(", ignore_query=False")

statement = ", ".join(parts)
return f"Endpoint({statement})"


@attrs.define
class Breadcrumb:
"""A single breadcrumb"""

title: str
link: Endpoint

@property
def active(self) -> bool:
return self.link.active

@property
def url(self) -> str:
return self.link.url

def __tag__(self) -> tags.html_tag:
if self.active:
return text(self.title)

return tags.a(self.title, href=self.url)


@attrs.define
class Breadcrumbs:
"""The trail of breadcrumbs"""

crumbs: list[Breadcrumb] = attrs.field(factory=list)
divider: str = attrs.field(default=">")

def __iter__(self) -> Iterator[Breadcrumb]:
return iter(self.crumbs)

def __len__(self) -> int:
return len(self.crumbs)

def __getitem__(self, index: int) -> Breadcrumb:
return self.crumbs[index]

def push(self, crumb: Breadcrumb) -> None:
self.crumbs.insert(0, crumb)

def __tag__(self) -> tags.html_tag:
if not self.crumbs:
return text("")

nav = tags.nav(aria_label="breadcrumb")
if self.divider != "/":
nav["style"] = f"--breadcrumb-divider: '{self.divider:s}';" # noqa: B907

ol = tags.ol(cls="breadcrumb")
for crumb in self:
item = tags.li(as_tag(crumb), cls="breadcrumb-item")
if crumb.active:
item["aria-current"] = "page"
item.classes.add("active")
ol.add(item)
nav.add(ol)
return nav


@attrs.define
class BreadcrumbEntry:
"""A single entry in the breadcrumbs datastructure"""

title: str
parent: Endpoint | None


@attrs.define(init=False)
class BreadcrumbExtension:
"""An extension for breadcrumbs"""

tree: dict[Endpoint, BreadcrumbEntry] = attrs.field(factory=dict)

def __init__(self, app: Flask | None = None) -> None:
self.tree = {}
if app is not None:
self.init_app(app)

def init_app(self, app: Flask) -> None:
app.config.setdefault(DIVIDER_SETTING, ">")
app.extensions[EXTENSION_KEY] = self

def register(
self, context: Flask | Blueprint | None, parent: str | Endpoint | None, title: str
) -> Callable[[V], V]:
if isinstance(context, Flask):
context = None

parent_link: Endpoint | None = None
if isinstance(parent, str):
if parent.startswith("."):
if context is None:
raise ValueError("Cannot use relative endpoint without a context")
parent_link = Endpoint(name=parent.lstrip("."), context=context)
else:
parent_link = Endpoint(name=parent, context=None)
else:
parent_link = parent

def decorator(view: V) -> V:
nonlocal parent_link
link = Endpoint(name=view.__name__, context=context)

if link == parent_link:
raise ValueError("A breadcrumb cannot be its own parent")

self.tree[link] = BreadcrumbEntry(title=title, parent=parent_link)
return view

return decorator

@property
def divider(self) -> str:
return current_app.config[DIVIDER_SETTING]

def _current_context(self) -> Blueprint | None:
if request.blueprint:
return current_app.blueprints[request.blueprint] # type: ignore
return None

def _current_endpoint(self) -> Endpoint | None:
context = self._current_context()
if not request.endpoint: # pragma: no cover
return None

name = request.endpoint.split(".")[-1]

return Endpoint(name=name, context=context)

def get(self) -> Breadcrumbs:
endpoint = self._current_endpoint()
crumbs = Breadcrumbs(divider=self.divider)
if not endpoint: # pragma: no cover
return crumbs

current = self.tree.get(endpoint)
while current:
crumbs.push(Breadcrumb(title=current.title, link=endpoint))
if not current.parent:
break
endpoint = current.parent
current = self.tree.get(endpoint)

return crumbs


breadcrumbs: BreadcrumbExtension = LocalProxy(lambda: current_app.extensions[EXTENSION_KEY]) # type: ignore
17 changes: 2 additions & 15 deletions src/bootlace/links.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@

import attrs
from dominate import tags
from flask import request
from flask import url_for

from .util import as_tag
from .util import is_active_endpoint
from .util import MaybeTaggable


Expand Down Expand Up @@ -51,17 +51,4 @@ def url(self) -> str:

@property
def active(self) -> bool:
if request.endpoint != self.endpoint:
return False

if request.url_rule is None: # pragma: no cover
return False

rule_url = request.url_rule.build(self.url_kwargs, append_unknown=not self.ignore_query)

if rule_url is None: # pragma: no cover
return False

_, url = rule_url

return url == request.path
return is_active_endpoint(self.endpoint, self.url_kwargs, self.ignore_query)
21 changes: 21 additions & 0 deletions src/bootlace/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@
from collections.abc import Callable
from collections.abc import Iterable
from collections.abc import Iterator
from collections.abc import Mapping
from typing import Any
from typing import Protocol
from typing import TypeVar

import attrs
from dominate import tags
from dominate.util import container
from dominate.util import text
from flask import request

T = TypeVar("T")

Expand Down Expand Up @@ -114,3 +117,21 @@ def converter(value: str | T) -> T:
return value

return converter


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

rule_url = request.url_rule.build(url_kwargs, append_unknown=not ignore_query)

if rule_url is None: # pragma: no cover
return False

_, url = rule_url

return url == request.path
7 changes: 5 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ def app() -> Flask:
app = Flask(__name__)
app.config.update({"ENV": "test", "TESTING": True})

return app


@pytest.fixture
def homepage(app: Flask) -> None:
@app.route("/")
def index() -> str:
return "Hello, World!"

return app
2 changes: 2 additions & 0 deletions tests/nav/test_elements.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import pytest
from flask import Flask

from bootlace.nav import elements
Expand All @@ -14,6 +15,7 @@ def test_link() -> None:
assert link_e.url == "#"


@pytest.mark.usefixtures("homepage")
def test_view(app: Flask) -> None:
view_e = elements.Link.with_view(endpoint="index", text="View")

Expand Down
1 change: 1 addition & 0 deletions tests/nav/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def test_nav_style(style: NavStyle) -> None:
assert_same_html(expected_html=expected, actual_html=str(source))


@pytest.mark.usefixtures("homepage")
def test_nav_active_endpoint(app: Flask) -> None:
nav = elements.Nav()
nav.items.append(CurrentLink(link=View(text="Active", endpoint="index")))
Expand Down
2 changes: 2 additions & 0 deletions tests/table/test_columns.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import datetime as dt

import attrs
import pytest
from flask import Flask

from bootlace.icon import Icon
Expand All @@ -23,6 +24,7 @@ class Item:
when: dt.datetime = dt.datetime(2021, 1, 1, 12, 18, 5)


@pytest.mark.usefixtures("homepage")
def test_edit_column(app: Flask) -> None:

col = EditColumn(heading="Edit", attribute="editor", endpoint="index")
Expand Down
Loading

0 comments on commit 95f5e66

Please sign in to comment.