Skip to content

Commit

Permalink
refactor: Navbar serialization now uses Marshmallow
Browse files Browse the repository at this point in the history
This makes schemas easier to control, more explicit, and easily keeps deserialization and serialization in sync.
  • Loading branch information
alexrudy committed Dec 31, 2024
1 parent a552c41 commit 54c5e4a
Show file tree
Hide file tree
Showing 7 changed files with 530 additions and 99 deletions.
28 changes: 9 additions & 19 deletions src/bootlace/nav/bar.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -27,35 +27,25 @@ 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

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:
Expand Down
59 changes: 4 additions & 55 deletions src/bootlace/nav/core.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"""
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down
19 changes: 3 additions & 16 deletions src/bootlace/nav/nav.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down
178 changes: 178 additions & 0 deletions src/bootlace/nav/schema.py
Original file line number Diff line number Diff line change
@@ -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}
3 changes: 0 additions & 3 deletions tests/nav/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand Down
Loading

0 comments on commit 54c5e4a

Please sign in to comment.