diff --git a/src/bootlace/nav/bar.py b/src/bootlace/nav/bar.py index b2c090c..b4fd22d 100644 --- a/src/bootlace/nav/bar.py +++ b/src/bootlace/nav/bar.py @@ -1,14 +1,14 @@ -from typing import Any - import attrs from dominate import tags from dominate.dom_tag import dom_tag from dominate.util import container +from marshmallow import fields from .core import Link from .core import NavElement from .core import SubGroup from .nav import Nav +from .schema import NavSchema from bootlace.size import SizeClass from bootlace.style import ColorClass from bootlace.util import as_tag @@ -27,14 +27,18 @@ class NavBar(NavElement): id: str = attrs.field(factory=element_id.factory("navbar")) #: The elements in the navbar - items: list[NavElement] = attrs.field(factory=list) + items: list[NavElement] = attrs.field(factory=list, metadata={"form": fields.List(fields.Nested(NavSchema))}) #: The size of the navbar, if any, used to select when it #: should expand or collapse - expand: SizeClass | None = SizeClass.LARGE + expand: SizeClass | None = attrs.field( + default=SizeClass.LARGE, metadata={"form": fields.Enum(SizeClass, allow_none=True)} + ) #: The color of the navbar, if using a bootstrap color class - color: ColorClass | None = ColorClass.TERTIARY + color: ColorClass | None = attrs.field( + default=ColorClass.TERTIARY, metadata={"form": fields.Enum(ColorClass, allow_none=True)} + ) #: Whether the navbar should be fluid (e.g. full width) fluid: bool = True @@ -42,20 +46,6 @@ class NavBar(NavElement): nav: Tag = Tag(tags.nav, classes={"navbar"}) container: Tag = Tag(tags.div, classes={"container"}) - def serialize(self) -> dict[str, Any]: - data = super().serialize() - data["items"] = [item.serialize() for item in self.items] - data["expand"] = self.expand.value if self.expand else None - data["color"] = self.color.value if self.color else None - return data - - @classmethod - def deserialize(cls, data: dict[str, Any]) -> NavElement: - data["items"] = [NavElement.deserialize(item) for item in data["items"]] - data["expand"] = SizeClass(data["expand"]) if data["expand"] else None - data["color"] = ColorClass(data["color"]) if data["color"] else None - return cls(**data) - def __tag__(self) -> tags.html_tag: nav = self.nav() if self.expand: diff --git a/src/bootlace/nav/core.py b/src/bootlace/nav/core.py index 52f5214..2bfc466 100644 --- a/src/bootlace/nav/core.py +++ b/src/bootlace/nav/core.py @@ -1,14 +1,15 @@ import enum import warnings from typing import Any -from typing import Self import attrs from dominate import tags from dominate.dom_tag import dom_tag +from marshmallow import fields from bootlace import links from bootlace.endpoint import Endpoint +from bootlace.nav.schema import NavSchema from bootlace.util import as_tag from bootlace.util import BootlaceWarning from bootlace.util import ids as element_id @@ -33,38 +34,15 @@ class NavAlignment(enum.Enum): JUSTIFIED = "nav-justified" -def nav_serialize_filter(field: attrs.Attribute, value: Any) -> Any: - """Serialize the value of a NavElement""" - if isinstance(value, Tag): - return False - - return True - - class NavElement: """Base class for nav components""" + Schema: type[NavSchema] = NavSchema _NAV_ELEMENT_REGISTRY: dict[str, type["NavElement"]] = {} def __init_subclass__(cls) -> None: cls._NAV_ELEMENT_REGISTRY[cls.__name__] = cls - def serialize(self) -> dict[str, Any]: - """Serialize the element to a dictionary""" - data = attrs.asdict(self, filter=nav_serialize_filter) # type: ignore - data["__type__"] = self.__class__.__name__ - return data - - @classmethod - def deserialize(cls, data: dict[str, Any]) -> "NavElement": - """Deserialize an element from a dictionary""" - if cls is NavElement: - element_cls = cls._NAV_ELEMENT_REGISTRY.get(data["__type__"], NavElement) - del data["__type__"] - return element_cls.deserialize(data) - - return cls(**data) - @property def active(self) -> bool: """Whether the element is active""" @@ -106,25 +84,6 @@ class Link(NavElement): a: Tag = Tag(tags.a, classes={"nav-link"}) - def serialize(self) -> dict[str, Any]: - data = super().serialize() - data["link"] = attrs.asdict(self.link, filter=nav_serialize_filter) - data["link"]["__type__"] = self.link.__class__.__name__ - - if "endpoint" in data["link"]: - data["link"]["endpoint"]["url_kwargs"] = dict(data["link"]["endpoint"]["url_kwargs"]["_arguments"]) - - return data - - @classmethod - def deserialize(cls, data: dict[str, Any]) -> Self: - link_cls = getattr(links, data["link"].pop("__type__")) - if "endpoint" in data["link"]: - data["link"]["endpoint"] = Endpoint(**data["link"]["endpoint"]) - - data["link"] = link_cls(**data["link"]) - return cls(**data) - @classmethod def with_url(cls, url: str, text: MaybeTaggable, **kwargs: Any) -> "Link": """Create a link with a URL.""" @@ -190,17 +149,7 @@ def __tag__(self) -> dom_tag: class SubGroup(NavElement): """Any grouping of items in the nav bar""" - items: list[NavElement] = attrs.field(factory=list) - - def serialize(self) -> dict[str, Any]: - data = super().serialize() - data["items"] = [item.serialize() for item in self.items] - return data - - @classmethod - def deserialize(cls, data: dict[str, Any]) -> Self: - data["items"] = [NavElement.deserialize(item) for item in data["items"]] - return cls(**data) + items: list[NavElement] = attrs.field(factory=list, metadata={"form": fields.List(fields.Nested(NavSchema))}) @property def active(self) -> bool: diff --git a/src/bootlace/nav/nav.py b/src/bootlace/nav/nav.py index a40a998..ec860cc 100644 --- a/src/bootlace/nav/nav.py +++ b/src/bootlace/nav/nav.py @@ -1,9 +1,8 @@ import warnings -from typing import Any -from typing import Self import attrs from dominate import tags +from marshmallow import fields from .core import NavAlignment from .core import NavStyle @@ -23,26 +22,14 @@ class Nav(SubGroup): id: str = attrs.field(factory=element_id.factory("nav")) #: The style of the nav - style: NavStyle = NavStyle.PLAIN + style: NavStyle = attrs.field(default=NavStyle.PLAIN, metadata={"form": fields.Enum(NavStyle)}) #: The alignment of the elments in the nav - alignment: NavAlignment = NavAlignment.DEFAULT + alignment: NavAlignment = attrs.field(default=NavAlignment.DEFAULT, metadata={"form": fields.Enum(NavAlignment)}) ul: Tag = Tag(tags.ul, classes={"nav"}) li: Tag = Tag(tags.li, classes={"nav-item"}) - def serialize(self) -> dict[str, Any]: - data = super().serialize() - data["style"] = self.style.name - data["alignment"] = self.alignment.name - return data - - @classmethod - def deserialize(cls, data: dict[str, Any]) -> Self: - data["style"] = NavStyle[data["style"]] - data["alignment"] = NavAlignment[data["alignment"]] - return super().deserialize(data) - def __tag__(self) -> tags.html_tag: active_endpoint = next((item for item in self.items if item.active), None) ul = self.ul(id=self.id) diff --git a/src/bootlace/nav/schema.py b/src/bootlace/nav/schema.py new file mode 100644 index 0000000..5c556d3 --- /dev/null +++ b/src/bootlace/nav/schema.py @@ -0,0 +1,178 @@ +from collections.abc import Mapping +from typing import Any +from typing import TYPE_CHECKING + +import attrs +from dominate import tags +from markupsafe import Markup +from marshmallow import fields +from marshmallow import post_load +from marshmallow import Schema +from marshmallow import ValidationError +from marshmallow_oneofschema.one_of_schema import OneOfSchema + +from bootlace import links +from bootlace.endpoint import Endpoint +from bootlace.util import MaybeTaggable +from bootlace.util import render +from bootlace.util import Tag + +if TYPE_CHECKING: + from bootlace.nav.core import NavElement + + +class NavSchema(OneOfSchema): + """Registry for nav element schemas""" + + type_field = "__type__" + type_schemas: dict[str, type[Schema]] = {} + + def __init__(self, **kwargs: Any) -> None: + self.register_all() + super().__init__(**kwargs) + + @classmethod + def register_all(cls) -> None: + """Register a schema for a nav element""" + from bootlace.nav.core import NavElement + + for element in NavElement._NAV_ELEMENT_REGISTRY.values(): + cls.register(element) + + @classmethod + def register(cls, element: "type[NavElement]") -> None: + """Register a schema for a nav element""" + schema = build_schema(element) + cls.type_schemas[element.__name__] = schema + + +class BaseSchema(Schema): + """Base schema with reloading""" + + @post_load + def make_instance(self, data: dict[str, Any], **kwargs: Any) -> Any: + return self.Meta.element(**data) # type: ignore + + +class DomTagField(fields.Field): + + def _serialize(self, value: Any, attr: str | None, obj: Any, **kwargs: Any) -> Any: + if value is None: # pragma: no cover + return None + + if not hasattr(tags, value.__name__): # pragma: no cover + raise ValidationError(f"Unknown tag {value.__name__}") + + return value.__name__ + + def _deserialize(self, value: Any, attr: str | None, data: Mapping[str, Any] | None, **kwargs: Any) -> Any: + if value is None: # pragma: no cover + return None + return getattr(tags, value) + + +class Set(fields.List): + + def _serialize(self, value: Any, attr: str | None, obj: Any, **kwargs: Any) -> Any: + if value is None: # pragma: no cover + return None + return list(value) + + def _deserialize(self, value: Any, attr: str | None, data: Mapping[str, Any] | None, **kwargs: Any) -> Any: + if value is None: # pragma: no cover + return None + return set(value) + + +class TagSchema(Schema): + + tag = DomTagField() + classes = Set(fields.String()) + attributes = fields.Dict() + + class Meta: + element = Tag + + @post_load + def make_instance(self, data: dict[str, Any], **kwargs: Any) -> Any: + return Tag(**data) + + +class TaggableField(fields.Field): + + def _serialize(self, value: Any, attr: str | None, obj: Any, **kwargs: Any) -> Any: + if value is None: # pragma: no cover + return None + return str(render(value)) + + def _deserialize(self, value: Any, attr: str | None, data: Mapping[str, Any] | None, **kwargs: Any) -> Any: + if value is None: # pragma: no cover + return None + return Markup(value) + + +ATTRS_FIELD_TYPE_MAP = { + str: fields.String, + str | None: lambda: fields.String(allow_none=True), + int: fields.Integer, + float: fields.Float, + bool: fields.Boolean, + Tag: lambda: fields.Nested(TagSchema), + links.LinkBase: lambda: fields.Nested(LinkBaseSchema), + MaybeTaggable: TaggableField, +} + + +def build_schema(element: "type[NavElement]") -> type[Schema]: + form_fields: dict[str, fields.Field] = {} + for field in attrs.fields(element): # type: ignore + + ff = field.metadata.get("form", None) + if ff is None: + if field.type not in ATTRS_FIELD_TYPE_MAP: # pragma: no cover + raise ValueError(f"Unknown field type {field.type!r}") + + form_fields[field.name] = ATTRS_FIELD_TYPE_MAP[field.type]() # type: ignore + else: + form_fields[field.name] = ff + + meta = type("Meta", (), {"element": element}) + + attributes: dict[str, Any] = {"Meta": meta, **form_fields} + + return type(f"{element.__name__}Schema", (BaseSchema,), attributes) + + +class EndpointSchema(BaseSchema): + name = fields.String(required=True) + url_kwargs = fields.Dict() + ignore_query = fields.Boolean(required=True) + + class Meta: + element = Endpoint + + +class LinkSchema(BaseSchema): + text = TaggableField() + a = fields.Nested(TagSchema) + url = fields.String(required=True) + active = fields.Boolean(required=True) + enabled = fields.Boolean(required=True) + + class Meta: + element = links.Link + + +class ViewLinkSchema(BaseSchema): + text = TaggableField() + a = fields.Nested(TagSchema) + endpoint = fields.Nested(EndpointSchema) + enabled = fields.Boolean() + + class Meta: + element = links.View + + +class LinkBaseSchema(OneOfSchema): + type_field = "__type__" + type_schemas: dict[str, type[Schema]] = {"Link": LinkSchema, "View": ViewLinkSchema} diff --git a/tests/nav/conftest.py b/tests/nav/conftest.py index 030353e..5dcbd24 100644 --- a/tests/nav/conftest.py +++ b/tests/nav/conftest.py @@ -10,9 +10,6 @@ def get_fixture(name: str) -> str: here = Path(__file__).parent - if not name.endswith(".html"): - name += ".html" - return (here / "fixtures" / name).read_text() diff --git a/tests/nav/fixtures/navbar.json b/tests/nav/fixtures/navbar.json new file mode 100644 index 0000000..856c50d --- /dev/null +++ b/tests/nav/fixtures/navbar.json @@ -0,0 +1,319 @@ +{ + "id": "navbar", + "items": [ + { + "link": { + "text": "Navbar", + "a": { + "tag": "a", + "classes": [], + "attributes": {} + }, + "url": "#", + "active": false, + "enabled": true, + "__type__": "Link" + }, + "a": { + "tag": "a", + "classes": [ + "nav-link" + ], + "attributes": {} + }, + "id": "navbar-brand", + "__type__": "Brand" + }, + { + "items": [ + { + "items": [ + { + "link": { + "text": "Home", + "a": { + "tag": "a", + "classes": [], + "attributes": {} + }, + "url": "#", + "active": false, + "enabled": true, + "__type__": "Link" + }, + "id": "nav-link", + "a": { + "tag": "a", + "classes": [ + "nav-link" + ], + "attributes": {} + }, + "__type__": "CurrentLink" + }, + { + "link": { + "text": "Link", + "a": { + "tag": "a", + "classes": [], + "attributes": {} + }, + "url": "#", + "active": false, + "enabled": true, + "__type__": "Link" + }, + "id": "nav-link-1", + "a": { + "tag": "a", + "classes": [ + "nav-link" + ], + "attributes": {} + }, + "__type__": "Link" + }, + { + "items": [ + { + "link": { + "text": "Action", + "a": { + "tag": "a", + "classes": [], + "attributes": {} + }, + "url": "#", + "active": false, + "enabled": true, + "__type__": "Link" + }, + "id": "nav-link-2", + "a": { + "tag": "a", + "classes": [ + "nav-link" + ], + "attributes": {} + }, + "__type__": "Link" + }, + { + "link": { + "text": "Another action", + "a": { + "tag": "a", + "classes": [], + "attributes": {} + }, + "url": "#", + "active": false, + "enabled": true, + "__type__": "Link" + }, + "id": "nav-link-3", + "a": { + "tag": "a", + "classes": [ + "nav-link" + ], + "attributes": {} + }, + "__type__": "Link" + }, + { + "hr": { + "tag": "hr", + "classes": [ + "dropdown-divider" + ], + "attributes": {} + }, + "__type__": "Separator" + }, + { + "link": { + "text": "Separated link", + "a": { + "tag": "a", + "classes": [], + "attributes": {} + }, + "url": "#", + "active": false, + "enabled": true, + "__type__": "Link" + }, + "id": "nav-link-4", + "a": { + "tag": "a", + "classes": [ + "nav-link" + ], + "attributes": {} + }, + "__type__": "Link" + } + ], + "title": "Dropdown", + "id": "bs-dropdown", + "dropdown": { + "tag": "div", + "classes": [ + "dropdown" + ], + "attributes": {} + }, + "ul": { + "tag": "ul", + "classes": [ + "dropdown-menu" + ], + "attributes": {} + }, + "li": { + "tag": "li", + "classes": [], + "attributes": {} + }, + "toggle": { + "tag": "a", + "classes": [ + "nav-link", + "dropdown-toggle" + ], + "attributes": { + "role": "button" + } + }, + "__type__": "Dropdown" + }, + { + "link": { + "text": "Disabled", + "a": { + "tag": "a", + "classes": [], + "attributes": {} + }, + "url": "#", + "active": false, + "enabled": true, + "__type__": "Link" + }, + "id": "nav-link-5", + "a": { + "tag": "a", + "classes": [ + "nav-link" + ], + "attributes": {} + }, + "__type__": "DisabledLink" + } + ], + "style": "PLAIN", + "alignment": "DEFAULT", + "id": "navbar-nav", + "ul": { + "tag": "ul", + "classes": [ + "navbar-nav" + ], + "attributes": {} + }, + "li": { + "tag": "li", + "classes": [ + "nav-item" + ], + "attributes": {} + }, + "__type__": "NavBarNav" + }, + { + "id": "navbar-search", + "placeholder": "Search", + "action": "#", + "method": "GET", + "button": null, + "form": { + "tag": "form", + "classes": [ + "d-flex" + ], + "attributes": { + "role": "search" + } + }, + "input": { + "tag": "input_", + "classes": [ + "me-2", + "form-control" + ], + "attributes": { + "type": "search" + } + }, + "button_tag": { + "tag": "button", + "classes": [ + "btn-success", + "btn" + ], + "attributes": { + "type": "submit" + } + }, + "__type__": "NavBarSearch" + } + ], + "id": "navbarSupportedContent", + "button": { + "tag": "button", + "classes": [ + "navbar-toggler" + ], + "attributes": { + "type": "button" + } + }, + "icon": { + "tag": "span", + "classes": [ + "navbar-toggler-icon" + ], + "attributes": {} + }, + "container": { + "tag": "div", + "classes": [ + "navbar-collapse", + "collapse" + ], + "attributes": {} + }, + "__type__": "NavBarCollapse" + } + ], + "expand": "LARGE", + "color": "TERTIARY", + "fluid": true, + "nav": { + "tag": "nav", + "classes": [ + "navbar" + ], + "attributes": {} + }, + "container": { + "tag": "div", + "classes": [ + "container" + ], + "attributes": {} + }, + "__type__": "NavBar" +} diff --git a/tests/nav/test_serialize.py b/tests/nav/test_serialize.py index d776ee1..08fc83e 100644 --- a/tests/nav/test_serialize.py +++ b/tests/nav/test_serialize.py @@ -2,20 +2,28 @@ import pytest +from .conftest import get_fixture from bootlace.nav import core from bootlace.nav import elements +from bootlace.testing.html import assert_same_html +from bootlace.util import render def test_nav(nav: elements.NavBar) -> None: - data = nav.serialize() - raw = json.dumps(data) + schema = elements.NavBar.Schema() + data = schema.dump(nav) print(json.dumps(data, indent=2)) - nav2 = core.NavElement.deserialize(json.loads(raw)) + assert nav == schema.loads(json.dumps(data)), "Nav should round-trip" + + serialized = get_fixture("navbar.json") + assert nav == schema.loads(serialized), "Nav should match fixture" - assert nav == nav2 + source = render(schema.loads(serialized)) + expected = get_fixture("navbar.html") + assert_same_html(expected_html=expected, actual_html=str(source)) @pytest.mark.parametrize( @@ -35,10 +43,13 @@ def test_nav(nav: elements.NavBar) -> None: ids=lambda x: x.__class__.__name__, ) def test_scalar(element: core.NavElement) -> None: - data = element.serialize() + + schema = element.Schema() + + data = schema.dump(element) assert data["__type__"] == element.__class__.__name__ raw = json.dumps(data) - element2 = core.NavElement.deserialize(json.loads(raw)) + element2 = schema.loads(raw) assert element == element2