htmy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of htmy might be problematic. Click here for more details.

htmy/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ from .core import BaseTag as BaseTag
2
+ from .core import ContextAware as ContextAware
3
+ from .core import ErrorBoundary as ErrorBoundary
4
+ from .core import Formatter as Formatter
5
+ from .core import Fragment as Fragment
6
+ from .core import SafeStr as SafeStr
7
+ from .core import SkipProperty as SkipProperty
8
+ from .core import StandaloneTag as StandaloneTag
9
+ from .core import Tag as Tag
10
+ from .core import TagConfig as TagConfig
11
+ from .core import TagWithProps as TagWithProps
12
+ from .core import WithContext as WithContext
13
+ from .core import XBool as XBool
14
+ from .core import component as component
15
+ from .core import xml_format_string as xml_format_string
16
+ from .renderer import HTMY as HTMY
17
+ from .typing import AsyncComponent as AsyncComponent
18
+ from .typing import AsyncContextProvider as AsyncContextProvider
19
+ from .typing import AsyncFunctionComponent as AsyncFunctionComponent
20
+ from .typing import Component as Component
21
+ from .typing import ComponentSequence as ComponentSequence
22
+ from .typing import ComponentType as ComponentType
23
+ from .typing import Context as Context
24
+ from .typing import ContextKey as ContextKey
25
+ from .typing import ContextProvider as ContextProvider
26
+ from .typing import ContextValue as ContextValue
27
+ from .typing import FunctionComponent as FunctionComponent
28
+ from .typing import HTMYComponentType as HTMYComponentType
29
+ from .typing import Properties as Properties
30
+ from .typing import PropertyValue as PropertyValue
31
+ from .typing import SyncComponent as SyncComponent
32
+ from .typing import SyncContextProvider as SyncContextProvider
33
+ from .typing import SyncFunctionComponent as SyncFunctionComponent
34
+ from .typing import is_component_sequence as is_component_sequence
35
+ from .utils import join_components as join_components
htmy/core.py ADDED
@@ -0,0 +1,502 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ import asyncio
5
+ import enum
6
+ from collections.abc import Callable, Container
7
+ from typing import Any, ClassVar, Generic, Self, TypedDict, cast, overload
8
+ from xml.sax.saxutils import escape as xml_escape
9
+
10
+ from .typing import (
11
+ AsyncFunctionComponent,
12
+ Component,
13
+ ComponentType,
14
+ Context,
15
+ ContextKey,
16
+ ContextValue,
17
+ FunctionComponent,
18
+ PropertyValue,
19
+ SyncFunctionComponent,
20
+ T,
21
+ is_component_sequence,
22
+ )
23
+ from .utils import join_components
24
+
25
+ # -- Utility components
26
+
27
+
28
+ class Fragment:
29
+ """Fragment utility component that simply wraps some children components."""
30
+
31
+ __slots__ = ("_children",)
32
+
33
+ def __init__(self, *children: ComponentType) -> None:
34
+ """
35
+ Initialization.
36
+
37
+ Arguments:
38
+ *children: The wrapped children.
39
+ """
40
+ self._children = children
41
+
42
+ def htmy(self, context: Context) -> Component:
43
+ return tuple(join_components(self._children, "\n"))
44
+
45
+
46
+ class ErrorBoundary(Fragment):
47
+ """
48
+ Error boundary component for graceful error handling.
49
+
50
+ If an error occurs during the rendering of the error boundary's subtree,
51
+ the fallback component will be rendered instead.
52
+ """
53
+
54
+ __slots__ = ("_errors", "_fallback")
55
+
56
+ def __init__(
57
+ self,
58
+ *children: ComponentType,
59
+ fallback: Component | None = None,
60
+ errors: Container[type[Exception]] | None = None,
61
+ ) -> None:
62
+ """
63
+ Initialization.
64
+
65
+ Arguments:
66
+ *children: The wrapped children components.
67
+ fallback: The fallback component to render in case an error occurs during children rendering.
68
+ errors: An optional set of accepted error types. Only accepted errors are swallowed and rendered
69
+ with the fallback. If an error is not in this set but one of its base classes is, then the
70
+ error will still be accepted and the fallbak rendered. By default all errors are accepted.
71
+ """
72
+ super().__init__(*children)
73
+ self._errors = errors
74
+ self._fallback: Component = "" if fallback is None else fallback
75
+
76
+ def fallback_component(self, error: Exception) -> ComponentType:
77
+ """
78
+ Returns the fallback component for the given error.
79
+
80
+ Arguments:
81
+ error: The error that occurred during the rendering of the error boundary's subtree.
82
+
83
+ Raises:
84
+ Exception: The received error if it's not accepted.
85
+ """
86
+ if not (self._errors is None or any(e in self._errors for e in type(error).mro())):
87
+ raise error
88
+
89
+ return (
90
+ Fragment(*self._fallback)
91
+ if is_component_sequence(self._fallback)
92
+ else cast(ComponentType, self._fallback)
93
+ )
94
+
95
+
96
+ class WithContext(Fragment):
97
+ """
98
+ A simple, static context provider component.
99
+ """
100
+
101
+ __slots__ = ("_context",)
102
+
103
+ def __init__(self, *children: ComponentType, context: Context) -> None:
104
+ """
105
+ Initialization.
106
+
107
+ Arguments:
108
+ *children: The children components to wrap in the given context.
109
+ context: The context to make available to children components.
110
+ """
111
+ super().__init__(*children)
112
+ self._context = context
113
+
114
+ def htmy_context(self) -> Context:
115
+ return self._context
116
+
117
+
118
+ # -- Context utilities
119
+
120
+
121
+ class ContextAware:
122
+ """
123
+ Base class with utilities for safe context use.
124
+
125
+ Features:
126
+
127
+ - Register subclass instance in a context.
128
+ - Load subclass instance from context.
129
+ - Wrap components within a subclass instance context.
130
+
131
+ Subclass instance registration:
132
+
133
+ Direct subclasses are considered the "base context type". Subclass instances are
134
+ registered in contexts under their own type and also under their "base context type".
135
+
136
+ Example:
137
+
138
+ ```python
139
+ class ContextDataDefinition(ContextAware):
140
+ # This is the "base context type", instances of this class and its subclasses
141
+ # will always be registered under this type.
142
+ ...
143
+
144
+ class ContextDataImplementation(ContextDataDefinition):
145
+ # Instances of this class will be registered under `ContextDataDefinition` (the
146
+ # "base context type") and also under this type.
147
+ ...
148
+
149
+ class SpecializedContextDataImplementation(ContextDataImplementation):
150
+ # Instances of this class will be registered under `ContextDataDefinition` (the
151
+ # "base context type") and also under this type, but they will not be registered
152
+ # under `ContextDataImplementation`, since that's not the base context type.
153
+ ...
154
+ ```
155
+ """
156
+
157
+ __slot__ = ()
158
+
159
+ _base_context_type: ClassVar[type[ContextAware] | None] = None
160
+
161
+ def __init_subclass__(cls) -> None:
162
+ if cls.mro()[1] == ContextAware:
163
+ cls._base_context_type = cls
164
+
165
+ def in_context(self, *children: ComponentType) -> WithContext:
166
+ """
167
+ Creates a context provider component that renders the given children using this
168
+ instance in its context.
169
+ """
170
+ return WithContext(*children, context=self.to_context())
171
+
172
+ def to_context(self) -> Context:
173
+ """
174
+ Creates a context with this instance in it.
175
+
176
+ See the context registration rules in the class documentation for more information.
177
+ """
178
+ result: dict[ContextKey, ContextValue] = {type(self): self}
179
+ if self._base_context_type is not None:
180
+ result[self._base_context_type] = self
181
+
182
+ return result
183
+
184
+ @classmethod
185
+ def from_context(cls, context: Context, default: Self | None = None) -> Self:
186
+ """
187
+ Looks up an instance of this class from the given contexts.
188
+
189
+ Arguments:
190
+ context: The context the instance should be loaded from.
191
+ default: The default to use if no instance was found in the context.
192
+ """
193
+ result = context[cls] if default is None else context.get(cls, default)
194
+ if isinstance(result, cls):
195
+ return result
196
+
197
+ raise TypeError("Incorrectly context data type.")
198
+
199
+
200
+ # -- Function components
201
+
202
+
203
+ class SyncFunctionComponentWrapper(Generic[T]):
204
+ """Base class `FunctionComponent` wrappers."""
205
+
206
+ __slots__ = ("_props",)
207
+
208
+ _wrapped_function: SyncFunctionComponent[T]
209
+
210
+ def __init__(self, props: T) -> None:
211
+ self._props = props
212
+
213
+ def __init_subclass__(cls, *, func: SyncFunctionComponent[T]) -> None:
214
+ cls._wrapped_function = func
215
+
216
+ def htmy(self, context: Context) -> Component:
217
+ # type(self) is necessary, otherwise the wrapped function would be called
218
+ # with an extra self argument...
219
+ return type(self)._wrapped_function(self._props, context)
220
+
221
+
222
+ class AsyncFunctionComponentWrapper(Generic[T]):
223
+ """Base class `FunctionComponent` wrappers."""
224
+
225
+ __slots__ = ("_props",)
226
+
227
+ _wrapped_function: AsyncFunctionComponent[T]
228
+
229
+ def __init__(self, props: T) -> None:
230
+ self._props = props
231
+
232
+ def __init_subclass__(cls, *, func: AsyncFunctionComponent[T]) -> None:
233
+ cls._wrapped_function = func
234
+
235
+ async def htmy(self, context: Context) -> Component:
236
+ # type(self) is necessary, otherwise the wrapped function would be called
237
+ # with an extra self argument...
238
+ return await type(self)._wrapped_function(self._props, context)
239
+
240
+
241
+ @overload
242
+ def component(func: SyncFunctionComponent[T]) -> type[SyncFunctionComponentWrapper[T]]: ...
243
+
244
+
245
+ @overload
246
+ def component(func: AsyncFunctionComponent[T]) -> type[AsyncFunctionComponentWrapper[T]]: ...
247
+
248
+
249
+ def component(
250
+ func: FunctionComponent[T],
251
+ ) -> type[SyncFunctionComponentWrapper[T]] | type[AsyncFunctionComponentWrapper[T]]:
252
+ """
253
+ Decorator that converts the given function into a component.
254
+
255
+ Internally this is achieved by wrapping the function in a pre-configured
256
+ `FunctionComponentWrapper` subclass.
257
+
258
+ Arguments:
259
+ func: The decorated function component.
260
+
261
+ Returns:
262
+ A pre-configured `FunctionComponentWrapper` subclass.
263
+ """
264
+
265
+ if asyncio.iscoroutinefunction(func):
266
+
267
+ class AsyncFCW(AsyncFunctionComponentWrapper[T], func=func): ...
268
+
269
+ return AsyncFCW
270
+ else:
271
+
272
+ class SyncFCW(SyncFunctionComponentWrapper[T], func=func): ... # type: ignore[arg-type]
273
+
274
+ return SyncFCW
275
+
276
+
277
+ # -- Formatting
278
+
279
+
280
+ class SkipProperty(Exception):
281
+ """Exception raised by property formatters if the property should be skipped."""
282
+
283
+ ...
284
+
285
+
286
+ class SafeStr(str):
287
+ """
288
+ String subclass whose instances shouldn't get escaped during rendering.
289
+
290
+ Note: any operation on `SafeStr` instances will result in plain `str` instances which
291
+ will be rendered normally. Make sure the `str` to `SafeStr` conversion (`SafeStr(my_string)`)
292
+ takes when there's no string operation afterwards.
293
+ """
294
+
295
+ ...
296
+
297
+
298
+ class XBool(enum.Enum):
299
+ """
300
+ Utility for the valid formatting of boolean XML (and HTML) attributes.
301
+
302
+ See this article for more information:
303
+ https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#boolean_attributes
304
+ """
305
+
306
+ true = True
307
+ false = False
308
+
309
+ def format(self) -> str:
310
+ """
311
+ Raises `SkipProperty` for `XBool.false`, returns empty string for `XBool.true`.
312
+ """
313
+ if self is XBool.true:
314
+ return ""
315
+
316
+ raise SkipProperty()
317
+
318
+
319
+ def xml_format_string(value: str) -> str:
320
+ """Escapes `<`, `>`, and `&` characters in the given string, unless it's a `SafeStr`."""
321
+ return value if isinstance(value, SafeStr) else xml_escape(value)
322
+
323
+
324
+ class Formatter(ContextAware):
325
+ """
326
+ The default, context-aware property name and value formatter.
327
+
328
+ Important: the default implementation looks up the formatter for a given value by checking
329
+ its type, but it doesn't do this check with the base classes of the encountered type. For
330
+ example the formatter will know how to format `datetime` object, but it won't know how to
331
+ format a `MyCustomDatetime(datetime)` instance.
332
+
333
+ One reason for this is efficiency: always checking the base classes of every single value is a
334
+ lot of unnecessary calculation. The other reason is customizability: this way you could use
335
+ subclassing for fomatter selection, e.g. with `LocaleDatetime(datetime)`-like classes.
336
+
337
+ Property name and value formatters may raise a `SkipProperty` error if a property should be skipped.
338
+ """
339
+
340
+ __slots__ = ("_default_formatter", "_name_formatter", "_value_formatters")
341
+
342
+ def __init__(
343
+ self,
344
+ *,
345
+ default_formatter: Callable[[Any], str] = str,
346
+ name_formatter: Callable[[str], str] | None = None,
347
+ ) -> None:
348
+ """
349
+ Initialization.
350
+
351
+ Arguments:
352
+ default_formatter: The default property value formatter to use if no formatter could
353
+ be found for a given value.
354
+ name_formatter: Optional property name formatter (for replacing the default name formatter).
355
+ """
356
+ super().__init__()
357
+ self._default_formatter = default_formatter
358
+ self._name_formatter = self._format_name if name_formatter is None else name_formatter
359
+ self._value_formatters: dict[type, Callable[[Any], str]] = self._base_formatters()
360
+
361
+ def add(self, key: type[T], formatter: Callable[[T], str]) -> Self:
362
+ """Registers the given value formatter under the given key."""
363
+ self._value_formatters[key] = formatter
364
+ return self
365
+
366
+ def format(self, name: str, value: Any) -> str:
367
+ """
368
+ Formats the given name-value pair.
369
+
370
+ Returns an empty string if the property name or value should be skipped.
371
+
372
+ See `SkipProperty` for more information.
373
+ """
374
+ try:
375
+ return f'{self.format_name(name)}="{self.format_value(value)}"'
376
+ except SkipProperty:
377
+ return ""
378
+
379
+ def format_name(self, name: str) -> str:
380
+ """
381
+ Formats the given name.
382
+
383
+ Raises:
384
+ SkipProperty: If the property should be skipped.
385
+ """
386
+ return self._name_formatter(name)
387
+
388
+ def format_value(self, value: Any) -> str:
389
+ """
390
+ Formats the given value.
391
+
392
+ Arguments:
393
+ value: The property value to format.
394
+
395
+ Raises:
396
+ SkipProperty: If the property should be skipped.
397
+ """
398
+ fmt = self._value_formatters.get(type(value), self._default_formatter)
399
+ return fmt(value)
400
+
401
+ def _format_name(self, name: str, /) -> str:
402
+ """The default property name formatter."""
403
+ no_replacement = "_" in {name[0], name[-1]}
404
+ return name.strip("_") if no_replacement else name.replace("_", "-")
405
+
406
+ def _base_formatters(self) -> dict[type, Callable[[Any], str]]:
407
+ """Factory that creates the default value formatter mapping."""
408
+ from datetime import date, datetime
409
+
410
+ return {
411
+ bool: lambda v: "true" if v else "false",
412
+ date: lambda d: cast(date, d).isoformat(),
413
+ datetime: lambda d: cast(datetime, d).isoformat(),
414
+ XBool: lambda v: cast(XBool, v).format(),
415
+ }
416
+
417
+
418
+ # -- XML
419
+
420
+
421
+ _default_tag_formatter = Formatter()
422
+
423
+
424
+ class TagConfig(TypedDict, total=False):
425
+ child_separator: ComponentType | None
426
+
427
+
428
+ class BaseTag(abc.ABC):
429
+ """
430
+ Base tag class.
431
+
432
+ Tags are always synchronous.
433
+
434
+ If the content of a tag must be calculated asynchronously, then the content can be implemented
435
+ as a separate async component or be resolved in an async parent component. If a property of a
436
+ tag must be calculated asynchronously, then the tag can be wrapped in an async component that
437
+ resolves the async content and then passes the value to the tag.
438
+ """
439
+
440
+ __slots__ = ()
441
+
442
+ @property
443
+ def _htmy_name(self) -> str:
444
+ return type(self).__name__
445
+
446
+ @abc.abstractmethod
447
+ def htmy(self, context: Context) -> Component: ...
448
+
449
+
450
+ class TagWithProps(BaseTag):
451
+ """Base class for tags with properties."""
452
+
453
+ __slots__ = ("props",)
454
+
455
+ def __init__(self, **props: PropertyValue) -> None:
456
+ super().__init__()
457
+ self.props = props
458
+
459
+ def htmy(self, context: Context) -> Component:
460
+ name = self._htmy_name
461
+ props = self._htmy_format_props(context=context)
462
+ return (SafeStr(f"<{name} {props}>"), SafeStr(f"</{name}>"))
463
+
464
+ def _htmy_format_props(self, context: Context) -> str:
465
+ formatter = Formatter.from_context(context, _default_tag_formatter)
466
+ return " ".join(formatter.format(name, value) for name, value in self.props.items())
467
+
468
+
469
+ class StandaloneTag(TagWithProps):
470
+ """Tag that has properties and no closing elements, e.g. `<img .../>`."""
471
+
472
+ __slots__ = ()
473
+
474
+ def htmy(self, context: Context) -> Component:
475
+ name = self._htmy_name
476
+ props = self._htmy_format_props(context=context)
477
+ return SafeStr(f"<{name} {props}/>")
478
+
479
+
480
+ class Tag(TagWithProps):
481
+ """Base class for tags with both properties and children."""
482
+
483
+ __slots__ = ("children",)
484
+
485
+ tag_config: TagConfig = {"child_separator": "\n"}
486
+
487
+ def __init__(self, *children: ComponentType, **props: PropertyValue) -> None:
488
+ self.children = children
489
+ self.props = props
490
+
491
+ def htmy(self, context: Context) -> Component:
492
+ separator = self.tag_config.get("child_separator", None)
493
+ opening, closing = cast(tuple[ComponentType, ComponentType], super().htmy(context))
494
+ return (
495
+ opening,
496
+ *(
497
+ self.children
498
+ if separator is None
499
+ else join_components(self.children, separator=separator, pad=True)
500
+ ),
501
+ closing,
502
+ )