Skip to content

Commit 95f5e66

Browse files
authored
Merge pull request #1 from alexrudy/feature/breadcrumb
Adds breadcrumb extension
2 parents 78c05b9 + ed60c79 commit 95f5e66

File tree

9 files changed

+508
-17
lines changed

9 files changed

+508
-17
lines changed

src/bootlace/breadcrumbs.py

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
from collections.abc import Callable
2+
from collections.abc import Iterator
3+
from collections.abc import Mapping
4+
from typing import Any
5+
from typing import Protocol
6+
from typing import TypeVar
7+
8+
import attrs
9+
from dominate import tags
10+
from dominate.util import text
11+
from flask import Blueprint
12+
from flask import current_app
13+
from flask import Flask
14+
from flask import request
15+
from flask import url_for
16+
from werkzeug.local import LocalProxy
17+
18+
from .util import as_tag
19+
from .util import is_active_endpoint
20+
21+
22+
class Named(Protocol):
23+
24+
__name__: str
25+
26+
27+
V = TypeVar("V", bound=Named)
28+
29+
EXTENSION_KEY: str = "bootlace.breadcrumbs"
30+
DIVIDER_SETTING: str = "BOOTLACE_BREADCRUMBS_DIVIDER"
31+
32+
33+
def endpoint_name(instance: object, attribute: attrs.Attribute, value: str) -> None:
34+
if "." in value:
35+
raise ValueError("Endpoint names cannot contain dots")
36+
37+
38+
@attrs.define(frozen=True, init=False)
39+
class KeywordArguments(Mapping[str, Any]):
40+
41+
_arguments: frozenset[tuple[str, Any]]
42+
43+
def __init__(self, *args: Any, **kwargs: Any) -> None:
44+
arguments = frozenset(dict(*args, **kwargs).items())
45+
object.__setattr__(self, "_arguments", arguments)
46+
47+
def as_dict(self) -> dict[str, Any]:
48+
return dict(self._arguments)
49+
50+
def __getitem__(self, __key: str) -> Any:
51+
return self.as_dict()[__key]
52+
53+
def __iter__(self) -> Iterator[str]:
54+
return iter((key for key, _ in self._arguments))
55+
56+
def __len__(self) -> int:
57+
return len(self._arguments)
58+
59+
def __repr__(self) -> str:
60+
return f"KeywordArguments({self.as_dict()!r})"
61+
62+
63+
@attrs.define(frozen=True, repr=False)
64+
class Endpoint:
65+
"""An endpoint for a breadcrumb, as captured at registration"""
66+
67+
context: None | Blueprint
68+
name: str = attrs.field(validator=endpoint_name)
69+
url_kwargs: KeywordArguments = attrs.field(factory=lambda: KeywordArguments(), converter=KeywordArguments)
70+
ignore_query: bool = True
71+
72+
@property
73+
def url(self) -> str:
74+
75+
if isinstance(self.context, Blueprint):
76+
name = f"{self.context.name}.{self.name}"
77+
return url_for(name, **self.url_kwargs)
78+
79+
return url_for(self.name, **self.url_kwargs)
80+
81+
@property
82+
def active(self) -> bool:
83+
return is_active_endpoint(self.name, self.url_kwargs, self.ignore_query)
84+
85+
def __repr__(self) -> str:
86+
parts = []
87+
if self.context is not None:
88+
parts.append(f"{self.context.name:s}.{self.name:s}")
89+
else:
90+
parts.append(f"{self.name:s}")
91+
92+
if self.url_kwargs:
93+
parts.append(f", {self.url_kwargs!r}")
94+
95+
if not self.ignore_query:
96+
parts.append(", ignore_query=False")
97+
98+
statement = ", ".join(parts)
99+
return f"Endpoint({statement})"
100+
101+
102+
@attrs.define
103+
class Breadcrumb:
104+
"""A single breadcrumb"""
105+
106+
title: str
107+
link: Endpoint
108+
109+
@property
110+
def active(self) -> bool:
111+
return self.link.active
112+
113+
@property
114+
def url(self) -> str:
115+
return self.link.url
116+
117+
def __tag__(self) -> tags.html_tag:
118+
if self.active:
119+
return text(self.title)
120+
121+
return tags.a(self.title, href=self.url)
122+
123+
124+
@attrs.define
125+
class Breadcrumbs:
126+
"""The trail of breadcrumbs"""
127+
128+
crumbs: list[Breadcrumb] = attrs.field(factory=list)
129+
divider: str = attrs.field(default=">")
130+
131+
def __iter__(self) -> Iterator[Breadcrumb]:
132+
return iter(self.crumbs)
133+
134+
def __len__(self) -> int:
135+
return len(self.crumbs)
136+
137+
def __getitem__(self, index: int) -> Breadcrumb:
138+
return self.crumbs[index]
139+
140+
def push(self, crumb: Breadcrumb) -> None:
141+
self.crumbs.insert(0, crumb)
142+
143+
def __tag__(self) -> tags.html_tag:
144+
if not self.crumbs:
145+
return text("")
146+
147+
nav = tags.nav(aria_label="breadcrumb")
148+
if self.divider != "/":
149+
nav["style"] = f"--breadcrumb-divider: '{self.divider:s}';" # noqa: B907
150+
151+
ol = tags.ol(cls="breadcrumb")
152+
for crumb in self:
153+
item = tags.li(as_tag(crumb), cls="breadcrumb-item")
154+
if crumb.active:
155+
item["aria-current"] = "page"
156+
item.classes.add("active")
157+
ol.add(item)
158+
nav.add(ol)
159+
return nav
160+
161+
162+
@attrs.define
163+
class BreadcrumbEntry:
164+
"""A single entry in the breadcrumbs datastructure"""
165+
166+
title: str
167+
parent: Endpoint | None
168+
169+
170+
@attrs.define(init=False)
171+
class BreadcrumbExtension:
172+
"""An extension for breadcrumbs"""
173+
174+
tree: dict[Endpoint, BreadcrumbEntry] = attrs.field(factory=dict)
175+
176+
def __init__(self, app: Flask | None = None) -> None:
177+
self.tree = {}
178+
if app is not None:
179+
self.init_app(app)
180+
181+
def init_app(self, app: Flask) -> None:
182+
app.config.setdefault(DIVIDER_SETTING, ">")
183+
app.extensions[EXTENSION_KEY] = self
184+
185+
def register(
186+
self, context: Flask | Blueprint | None, parent: str | Endpoint | None, title: str
187+
) -> Callable[[V], V]:
188+
if isinstance(context, Flask):
189+
context = None
190+
191+
parent_link: Endpoint | None = None
192+
if isinstance(parent, str):
193+
if parent.startswith("."):
194+
if context is None:
195+
raise ValueError("Cannot use relative endpoint without a context")
196+
parent_link = Endpoint(name=parent.lstrip("."), context=context)
197+
else:
198+
parent_link = Endpoint(name=parent, context=None)
199+
else:
200+
parent_link = parent
201+
202+
def decorator(view: V) -> V:
203+
nonlocal parent_link
204+
link = Endpoint(name=view.__name__, context=context)
205+
206+
if link == parent_link:
207+
raise ValueError("A breadcrumb cannot be its own parent")
208+
209+
self.tree[link] = BreadcrumbEntry(title=title, parent=parent_link)
210+
return view
211+
212+
return decorator
213+
214+
@property
215+
def divider(self) -> str:
216+
return current_app.config[DIVIDER_SETTING]
217+
218+
def _current_context(self) -> Blueprint | None:
219+
if request.blueprint:
220+
return current_app.blueprints[request.blueprint] # type: ignore
221+
return None
222+
223+
def _current_endpoint(self) -> Endpoint | None:
224+
context = self._current_context()
225+
if not request.endpoint: # pragma: no cover
226+
return None
227+
228+
name = request.endpoint.split(".")[-1]
229+
230+
return Endpoint(name=name, context=context)
231+
232+
def get(self) -> Breadcrumbs:
233+
endpoint = self._current_endpoint()
234+
crumbs = Breadcrumbs(divider=self.divider)
235+
if not endpoint: # pragma: no cover
236+
return crumbs
237+
238+
current = self.tree.get(endpoint)
239+
while current:
240+
crumbs.push(Breadcrumb(title=current.title, link=endpoint))
241+
if not current.parent:
242+
break
243+
endpoint = current.parent
244+
current = self.tree.get(endpoint)
245+
246+
return crumbs
247+
248+
249+
breadcrumbs: BreadcrumbExtension = LocalProxy(lambda: current_app.extensions[EXTENSION_KEY]) # type: ignore

src/bootlace/links.py

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33

44
import attrs
55
from dominate import tags
6-
from flask import request
76
from flask import url_for
87

98
from .util import as_tag
9+
from .util import is_active_endpoint
1010
from .util import MaybeTaggable
1111

1212

@@ -51,17 +51,4 @@ def url(self) -> str:
5151

5252
@property
5353
def active(self) -> bool:
54-
if request.endpoint != self.endpoint:
55-
return False
56-
57-
if request.url_rule is None: # pragma: no cover
58-
return False
59-
60-
rule_url = request.url_rule.build(self.url_kwargs, append_unknown=not self.ignore_query)
61-
62-
if rule_url is None: # pragma: no cover
63-
return False
64-
65-
_, url = rule_url
66-
67-
return url == request.path
54+
return is_active_endpoint(self.endpoint, self.url_kwargs, self.ignore_query)

src/bootlace/util.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@
55
from collections.abc import Callable
66
from collections.abc import Iterable
77
from collections.abc import Iterator
8+
from collections.abc import Mapping
9+
from typing import Any
810
from typing import Protocol
911
from typing import TypeVar
1012

1113
import attrs
1214
from dominate import tags
1315
from dominate.util import container
1416
from dominate.util import text
17+
from flask import request
1518

1619
T = TypeVar("T")
1720

@@ -114,3 +117,21 @@ def converter(value: str | T) -> T:
114117
return value
115118

116119
return converter
120+
121+
122+
def is_active_endpoint(endpoint: str, url_kwargs: Mapping[str, Any], ignore_query: bool = True) -> bool:
123+
"""Check if the current request is for the given endpoint and URL kwargs"""
124+
if request.endpoint != endpoint:
125+
return False
126+
127+
if request.url_rule is None: # pragma: no cover
128+
return False
129+
130+
rule_url = request.url_rule.build(url_kwargs, append_unknown=not ignore_query)
131+
132+
if rule_url is None: # pragma: no cover
133+
return False
134+
135+
_, url = rule_url
136+
137+
return url == request.path

tests/conftest.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ def app() -> Flask:
77
app = Flask(__name__)
88
app.config.update({"ENV": "test", "TESTING": True})
99

10+
return app
11+
12+
13+
@pytest.fixture
14+
def homepage(app: Flask) -> None:
1015
@app.route("/")
1116
def index() -> str:
1217
return "Hello, World!"
13-
14-
return app

tests/nav/test_elements.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import pytest
12
from flask import Flask
23

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

1617

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

tests/nav/test_render.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def test_nav_style(style: NavStyle) -> None:
7171
assert_same_html(expected_html=expected, actual_html=str(source))
7272

7373

74+
@pytest.mark.usefixtures("homepage")
7475
def test_nav_active_endpoint(app: Flask) -> None:
7576
nav = elements.Nav()
7677
nav.items.append(CurrentLink(link=View(text="Active", endpoint="index")))

tests/table/test_columns.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import datetime as dt
22

33
import attrs
4+
import pytest
45
from flask import Flask
56

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

2526

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

2830
col = EditColumn(heading="Edit", attribute="editor", endpoint="index")

0 commit comments

Comments
 (0)