Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
483 changes: 24 additions & 459 deletions htpy/__init__.py

Large diffs are not rendered by default.

100 changes: 100 additions & 0 deletions htpy/_attributes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from __future__ import annotations

import typing as t
from collections.abc import Iterable

import markupsafe

from htpy._types import HasHtml

if t.TYPE_CHECKING:
from collections.abc import Mapping

from htpy._types import Attribute


def _force_escape(value: t.Any) -> str:
return markupsafe.escape(str(value))


# Inspired by https://www.npmjs.com/package/classnames
def _class_names(items: t.Any) -> t.Any:
if isinstance(items, str):
return _force_escape(items)

if isinstance(items, dict) or not isinstance(items, Iterable):
items = [items]

result = list(_class_names_for_items(items))
if not result:
return False

return " ".join(_force_escape(class_name) for class_name in result)


def _class_names_for_items(items: t.Any) -> t.Any:
for item in items:
if isinstance(item, dict):
for k, v in item.items(): # pyright: ignore [reportUnknownVariableType]
if v:
yield k
else:
if item:
yield item


def id_class_names_from_css_str(x: t.Any) -> Mapping[str, Attribute]:
if not isinstance(x, str):
raise TypeError(f"id/class strings must be str. got {x}")

if "#" in x and "." in x and x.find("#") > x.find("."):
raise ValueError("id (#) must be specified before classes (.)")

if x[0] not in ".#":
raise ValueError("id/class strings must start with # or .")

parts = x.split(".")
ids = [part.removeprefix("#").strip() for part in parts if part.startswith("#")]
classes = [part.strip() for part in parts if not part.startswith("#") if part]

assert len(ids) in (0, 1)

result: dict[str, Attribute] = {}
if ids:
result["id"] = ids[0]

if classes:
result["class"] = " ".join(classes)

return result


def _generate_attrs(raw_attrs: Mapping[str, Attribute]) -> Iterable[tuple[str, Attribute]]:
for key, value in raw_attrs.items():
if not isinstance(key, str): # pyright: ignore [reportUnnecessaryIsInstance]
raise TypeError("Attribute key must be a string")

if value is False or value is None:
continue

if key == "class":
if result := _class_names(value):
yield ("class", result)

elif value is True:
yield _force_escape(key), True

else:
if not isinstance(value, str | int | HasHtml):
raise TypeError(f"Attribute value must be a string or an integer , got {value!r}")

yield _force_escape(key), _force_escape(value)


def attrs_string(attrs: Mapping[str, Attribute]) -> str:
result = " ".join(k if v is True else f'{k}="{v}"' for k, v in _generate_attrs(attrs))

if not result:
return ""

return " " + result
97 changes: 97 additions & 0 deletions htpy/_contexts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from __future__ import annotations

import dataclasses
import functools
import typing as t

from htpy._rendering import chunks_as_markup, iter_chunks_node

try:
from warnings import deprecated # type: ignore[attr-defined,unused-ignore]
except ImportError:
from typing_extensions import deprecated

if t.TYPE_CHECKING:
from collections.abc import Callable, Iterator, Mapping

import markupsafe

from htpy._types import Node


T = t.TypeVar("T")
P = t.ParamSpec("P")


@dataclasses.dataclass(frozen=True, slots=True)
class ContextProvider(t.Generic[T]):
context: Context[T]
value: T
node: Node

@deprecated( # type: ignore[misc,unused-ignore]
"iterating over a context provider is deprecated and will be removed in a future release. "
"Please use the context_provider.iter_chunks() method instead."
) # pyright: ignore [reportUntypedFunctionDecorator]
def __iter__(self) -> Iterator[str]:
return self.iter_chunks()

def __str__(self) -> markupsafe.Markup:
return chunks_as_markup(self)

__html__ = __str__

def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]:
return iter_chunks_node(self.node, {**(context or {}), self.context: self.value}) # pyright: ignore [reportUnknownMemberType]

def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
return str(self).encode(encoding, errors)


@dataclasses.dataclass(frozen=True, slots=True)
class ContextConsumer(t.Generic[T]):
context: Context[T]
debug_name: str
func: Callable[[T], Node]

def __str__(self) -> markupsafe.Markup:
return chunks_as_markup(self)

__html__ = __str__

def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]:
context_value = (context or {}).get(self.context, self.context.default)

if context_value is _NO_DEFAULT:
raise LookupError(
f'Context value for "{self.context.name}" does not exist, ' # pyright: ignore
f"requested by {self.debug_name}()."
)
return iter_chunks_node(self.func(context_value), context) # pyright: ignore

def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
return str(self).encode(encoding, errors)


class _NO_DEFAULT:
pass


@dataclasses.dataclass(frozen=True, slots=True)
class Context(t.Generic[T]):
name: str
_: dataclasses.KW_ONLY
default: T | type[_NO_DEFAULT] = _NO_DEFAULT

def provider(self, value: T, node: Node) -> ContextProvider[T]:
return ContextProvider(self, value, node)

def consumer(
self,
func: Callable[t.Concatenate[T, P], Node],
) -> Callable[P, ContextConsumer[T]]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> ContextConsumer[T]:
return ContextConsumer(self, func.__name__, lambda value: func(value, *args, **kwargs))

return wrapper
189 changes: 189 additions & 0 deletions htpy/_elements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
from __future__ import annotations

import functools
import keyword
import typing as t
from collections.abc import Callable, Iterable, Mapping

from htpy._attributes import attrs_string, id_class_names_from_css_str
from htpy._contexts import ContextConsumer, ContextProvider
from htpy._fragments import Fragment
from htpy._rendering import chunks_as_markup, iter_chunks_node
from htpy._types import HasHtml, KnownInvalidChildren

try:
from warnings import deprecated # type: ignore[attr-defined,unused-ignore]
except ImportError:
from typing_extensions import deprecated

if t.TYPE_CHECKING:
from collections.abc import Iterator
from types import UnionType

import markupsafe

from htpy._contexts import Context
from htpy._types import Attribute, Node


BaseElementSelf = t.TypeVar("BaseElementSelf", bound="BaseElement")
ElementSelf = t.TypeVar("ElementSelf", bound="Element")


class BaseElement:
__slots__ = ("_name", "_attrs", "_children")

def __init__(self, name: str, attrs_str: str = "", children: Node = None) -> None:
self._name = name
self._attrs = attrs_str
self._children = children

def __str__(self) -> markupsafe.Markup:
return chunks_as_markup(self)

__html__ = __str__

@t.overload
def __call__(
self: BaseElementSelf,
id_class: str,
/,
*attrs: Mapping[str, Attribute],
**kwargs: Attribute,
) -> BaseElementSelf: ...
@t.overload
def __call__(
self: BaseElementSelf,
/,
*attrs: Mapping[str, Attribute],
**kwargs: Attribute,
) -> BaseElementSelf: ...
def __call__(self: BaseElementSelf, /, *args: t.Any, **kwargs: t.Any) -> BaseElementSelf:
id_class: str = ""
attr_dicts: t.Sequence[Mapping[str, Attribute]]
attrs: dict[str, Attribute] = {}

if args and not isinstance(args[0], Mapping):
id_class, *attr_dicts = args
else:
attr_dicts = args

for attr_dict in attr_dicts:
attrs.update(attr_dict)

return self.__class__(
self._name,
attrs_string(
{
**(id_class_names_from_css_str(id_class) if id_class else {}),
**attrs,
**{_python_to_html_name(k): v for k, v in kwargs.items()},
}
),
self._children,
)

@deprecated( # type: ignore[misc,unused-ignore]
"iterating over an element is deprecated and will be removed in a future release. "
"Please use the element.iter_chunks() method instead."
) # pyright: ignore [reportUntypedFunctionDecorator]
def __iter__(self) -> Iterator[str]:
return self.iter_chunks()

def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]:
yield f"<{self._name}{self._attrs}>"
yield from iter_chunks_node(self._children, context)
yield f"</{self._name}>"

def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
return str(self).encode(encoding, errors)

# Avoid having Django "call" a htpy element that is injected into a
# template. Setting do_not_call_in_templates will prevent Django from doing
# an extra call:
# https://docs.djangoproject.com/en/5.0/ref/templates/api/#variables-and-lookups
do_not_call_in_templates = True


def _validate_children(children: t.Any) -> None:
# Non-lazy iterables:
# list and tuple are iterables and part of _KnownValidChildren. Since we
# know they can be consumed multiple times, we validate them recursively now
# rather than at render time to provide better error messages.
if isinstance(children, list | tuple):
for child in children: # pyright: ignore[reportUnknownVariableType]
_validate_children(child)
return

# bytes, bytearray etc:
# These are Iterable (part of _KnownValidChildren) but still not
# useful as a child node.
if isinstance(children, KnownInvalidChildren):
raise TypeError(f"{children!r} is not a valid child element")

# Element, str, int and all other regular/valid types.
if isinstance(children, _KnownValidChildren):
return

# Arbitrary objects that are not valid children.
raise TypeError(f"{children!r} is not a valid child element")


class Element(BaseElement):
def __getitem__(self: ElementSelf, children: Node) -> ElementSelf:
_validate_children(children)
return self.__class__(self._name, self._attrs, children) # pyright: ignore [reportUnknownArgumentType]

def __repr__(self) -> str:
return f"<{self.__class__.__name__} '<{self._name}{self._attrs}>...</{self._name}>'>"


class HTMLElement(Element):
def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]:
yield "<!doctype html>"
yield from super().iter_chunks(context)


class VoidElement(BaseElement):
def iter_chunks(self, context: Mapping[Context[t.Any], t.Any] | None = None) -> Iterator[str]:
yield f"<{self._name}{self._attrs}>"

def __repr__(self) -> str:
return f"<{self.__class__.__name__} '<{self._name}{self._attrs}>'>"


def _python_to_html_name(name: str) -> str:
# Make _hyperscript (https://hyperscript.org/) work smoothly
if name == "_":
return "_"

html_name = name
name_without_underscore_suffix = name.removesuffix("_")
if keyword.iskeyword(name_without_underscore_suffix):
html_name = name_without_underscore_suffix
html_name = html_name.replace("_", "-")

return html_name


@functools.lru_cache(maxsize=300)
def get_element(name: str) -> Element:
if not name.islower():
raise AttributeError(
f"{name} is not a valid element name. html elements must have all lowercase names"
)
return Element(_python_to_html_name(name))


_KnownValidChildren: UnionType = (
None
| BaseElement
| ContextProvider # pyright: ignore[reportMissingTypeArgument]
| ContextConsumer # pyright: ignore[reportMissingTypeArgument]
| str
| int
| Fragment
| HasHtml
| Callable
| Iterable
)
Loading