blu-react 0.0.1__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.
blu/_nodes.py ADDED
@@ -0,0 +1,771 @@
1
+ from collections.abc import Callable, Generator
2
+ import importlib
3
+ import inspect
4
+ from logging import getLogger
5
+ from numbers import Number
6
+ import re
7
+
8
+ from blu._exceptions import WrongEnvironmentError
9
+ from blu._utils.typing import Any, Mapping, Protocol, Sequence, cast
10
+ from blu._utils.client import is_client
11
+
12
+ log = getLogger("blu")
13
+
14
+ """
15
+ JSON-serializable primitive value.
16
+ """
17
+ type JsonPrimitive = bool | Number | str | None
18
+
19
+
20
+ """JSON-serializable value."""
21
+ type Jsonable = JsonPrimitive | Sequence[Jsonable] | Mapping[str, Jsonable]
22
+
23
+ """Basic item that can serialized and sent from server to client."""
24
+ type SerializableItem = JsonPrimitive | Node
25
+
26
+ """Can be serialized and sent from server to client."""
27
+ type Serializable = (
28
+ SerializableItem | Sequence[Serializable] | Mapping[str, Serializable]
29
+ )
30
+
31
+ type Node = """
32
+ ClientElement |
33
+ HTMLElement |
34
+ Key |
35
+ Iterable[Node] |
36
+ str |
37
+ int |
38
+ float |
39
+ None
40
+ """
41
+
42
+ """Valid value for a prop of a `ReactElement`."""
43
+ type PropValue = Jsonable | Node
44
+
45
+
46
+ def is_jsonable(item: Any) -> bool:
47
+ if item is None:
48
+ return True
49
+ elif isinstance(item, (bool, Number, str)):
50
+ return True
51
+ elif isinstance(item, Sequence):
52
+ items = cast(Sequence[Any], item)
53
+ return all(is_jsonable(x) for x in items)
54
+ elif isinstance(item, Mapping):
55
+ props = cast(Mapping[str, Any], item)
56
+ return all(is_jsonable(v) for v in props.values())
57
+ else:
58
+ return False
59
+
60
+
61
+ def is_node(item: Any) -> bool:
62
+ """
63
+ :return: :py:data:`True` if ``item`` is a valid :type:`Node`,
64
+ otherwise :py:data:`False`.
65
+ """
66
+ node_types = (bool, Number, str, HTMLElement, CustomElement)
67
+ if item is None:
68
+ return True
69
+ elif isinstance(item, node_types):
70
+ return True
71
+ elif isinstance(item, Sequence):
72
+ seq = cast(Sequence[Any], item)
73
+ return all(is_node(x) for x in seq)
74
+ else:
75
+ return False
76
+
77
+
78
+ """Valid props for a `ReactElement`."""
79
+ type Props = Mapping[str, PropValue]
80
+
81
+ """Valid children of a `ReactElement`."""
82
+ type Children = list[Node]
83
+
84
+
85
+ class HTMLElement:
86
+ """
87
+ A `React DOM component instance
88
+ <https://react.dev/reference/react-dom/components>`_. Created using
89
+ the :mod:`blu.html` module.
90
+
91
+ .. code-block:: python
92
+
93
+ from blu.html import div
94
+
95
+ div(id='my-id')[
96
+ 'Hello World!',
97
+ ]
98
+
99
+ .. code-block:: html
100
+
101
+ <div id="my-id">Hello World!</div>
102
+ """
103
+
104
+ _tagname: str
105
+ """
106
+ The tag name of the element.
107
+
108
+ .. code-block:: python
109
+
110
+ from blu.html import span
111
+
112
+ span.tagname == 'span'
113
+ """
114
+
115
+ _attrs: Props
116
+ """
117
+ The element's props, minus children.
118
+
119
+ .. code-block:: python
120
+
121
+ from blu.html import div
122
+
123
+ div(id='my-id')['Hello!'].props == {'id': 'my-id'}
124
+ """
125
+
126
+ _children: list[Node]
127
+ """
128
+ The element's children.
129
+
130
+ .. code-block:: python
131
+
132
+ from blu.html import div, span
133
+
134
+ div[span['Hello'], 'Hi.'].children == [span['Hello'], 'Hi.']
135
+ """
136
+
137
+ def __init__(self, tagname: str, props: Props, children: list[Node]):
138
+ self._tagname = tagname
139
+ self._attrs = props
140
+ self._children = children
141
+
142
+ def __call__(self, props: Props = {}, /, **kwargs: PropValue) -> "HTMLElement":
143
+ """
144
+ Create a copy of ``self`` with React props set based on the
145
+ given keyword arguments.
146
+
147
+ .. code-block:: python
148
+
149
+ from blu.html import div
150
+
151
+ div(id='my-div')
152
+
153
+ .. code-block:: html
154
+
155
+ <div id="my-div"></div>
156
+
157
+ :param props: A mapping of prop names to prop values. This is an
158
+ escape hatch for cases when the prop name can't be
159
+ represented as a keyword argument name.
160
+
161
+ :param kwargs: The new props, where the argument name is the
162
+ prop's key and the argument value is the prop's value. This
163
+ is the usual way to specify props.
164
+
165
+ :return: A copy of ``self`` with the same children, but with
166
+ props set as follows:
167
+
168
+ 1. For any key-value pair in the ``props`` argument, the
169
+ prop named by the key will be set to the value.
170
+
171
+ For example, ``div(props={'my_prop_': 45})`` results in
172
+ the React element ``<div my_prop_={45} />``
173
+
174
+ 2. For any keyword argument, the value will be set to a key
175
+ derived from:
176
+
177
+ - Removing the last trailing underscore (if any).
178
+ - Replacing all other underscores with dashes.
179
+
180
+ For example, ``div(my_prop_=45)`` results in the React
181
+ element ``<div my-prop={45} />``.
182
+
183
+ 3. If there is any conflict between the ``props`` argument
184
+ and the keyword arguments, the prop that has the conflict
185
+ will be set based on the ``props`` argument as described
186
+ in (1). Any props that do not have conflicts will be set
187
+ the same way they otherwise would, irrespective of the
188
+ props that do have conflicts.
189
+
190
+ For example,
191
+ ``div(props={'props-only': 'props', 'shared': 'props'}, shared='kw', kw_only='kw')``
192
+ results in the React element
193
+ ``<div props-only="props" shared="props" kw-only="kw" />``
194
+ """
195
+ all_props = {
196
+ **props,
197
+ **self._rename_props(kwargs),
198
+ }
199
+ for prop_name in all_props:
200
+ if not is_client and prop_name.startswith("on"):
201
+ raise WrongEnvironmentError(
202
+ f'Could not add attribute "{prop_name}" to {self._tagname} '
203
+ f'element. Event-handling attributes like "{prop_name}" '
204
+ "can only be set in client-side rendering; this code was "
205
+ "run server-side."
206
+ )
207
+ return HTMLElement(
208
+ self._tagname,
209
+ props=all_props,
210
+ children=self._children,
211
+ )
212
+
213
+ def __getitem__(self, children: Node | tuple[Node, ...]) -> "HTMLElement":
214
+ """
215
+ Create a copy of ``self`` whose child nodes are set to the items
216
+ passed in.
217
+
218
+ .. code-block:: python
219
+
220
+ from blu.html import div
221
+
222
+ div['Hello World!']
223
+
224
+ .. code-block:: html
225
+
226
+ <div>Hello World!</div>
227
+
228
+ :param children: A :type:`blu.Node` or a :py:class:`tuple` of
229
+ :type:`blu.Node`\\ s.
230
+ :return:
231
+ - If ``children`` is a :type:`blu.Node`: A copy of
232
+ :data:`self` whose children are set to ``[children]``.
233
+ - If ``children`` is a :py:class:`tuple` of
234
+ :type:`blu.Node`\\ s: A copy of :data:`self` whose
235
+ children are set to ``list(children)``.
236
+ """
237
+ return HTMLElement(
238
+ self._tagname,
239
+ props=self._attrs,
240
+ children=_get_children(children),
241
+ )
242
+
243
+ def _rename_props(self, props: Props) -> Props:
244
+ return {py_to_html_name(k): v for k, v in props.items()}
245
+
246
+
247
+ def _get_children(index: Node | tuple[Node, ...]) -> list[Node]:
248
+ if is_client:
249
+ from pyodide.ffi import JsProxy
250
+
251
+ if isinstance(index, JsProxy):
252
+ index = cast(Node, index.unwrap())
253
+ if isinstance(index, tuple):
254
+ children = list(cast(tuple[Node], index))
255
+ else:
256
+ children = [index]
257
+ return [_convert_to_string(x) for x in children]
258
+
259
+
260
+ def _convert_to_string(item: Node) -> Node:
261
+ str_types = (float, bool)
262
+ if is_client:
263
+ from pyodide.ffi import jsnull
264
+
265
+ if item == jsnull:
266
+ return str(item)
267
+ if isinstance(item, str_types):
268
+ return str(item)
269
+ else:
270
+ return item
271
+
272
+
273
+ class Key:
274
+ """
275
+ A keyed fragment of React nodes.
276
+
277
+ .. code-block:: python
278
+
279
+ from blu import Key
280
+ from blu.html import b
281
+
282
+ Key('my-id')[
283
+ 'Hello, ',
284
+ b['World'],
285
+ '!',
286
+ ]
287
+
288
+ .. code-block:: html
289
+
290
+ Hello, <b>World</b>!
291
+
292
+ :param key: A key to be used by React to uniquely identify the
293
+ fragment.
294
+ :return: An empty fragment identified by ``key``.
295
+
296
+ In most cases, you won't need to use :class:`blu.Key`, but it is
297
+ required when rendering an item in an
298
+ :py:class:`Iterable <collections.abc.Iterable>` (unless that
299
+ :py:class:`Iterable <collections.abc.Iterable>` is a :py:class:`str`
300
+ or :py:class:`tuple`):
301
+
302
+ .. code-block:: python
303
+
304
+ from blu import Key
305
+ from blu.html import b
306
+
307
+ PEOPLE = [
308
+ {'id': 1, name: 'Andy'},
309
+ {'id': 2, name: 'Brittany'},
310
+ {'id': 3, name: 'Calvin'},
311
+ ]
312
+
313
+ Key(person['id'])[
314
+ 'Hello, ',
315
+ b[person['name']],
316
+ '!',
317
+ ]
318
+ for person in PEOPLE
319
+
320
+ .. code-block:: html
321
+
322
+ Hello, <b>Andy</b>!
323
+ Hello, <b>Brittany</b>!
324
+ Hello, <b>Calvin</b>!
325
+ """
326
+
327
+ _key: Any
328
+ _children: list[Node]
329
+
330
+ def __init__(self, key: Any):
331
+ self._key = key
332
+ self._children = []
333
+
334
+ def __getitem__(self, children: Node | tuple[Node, ...]) -> "Key":
335
+ """
336
+ Create a copy of ``self`` with the given children.
337
+
338
+ .. code-block:: python
339
+
340
+ from blu import Key
341
+ from blu.html import b
342
+
343
+ Key('my-id')[
344
+ 'Hello, ',
345
+ b['World'],
346
+ '!',
347
+ ]
348
+
349
+ .. code-block:: html
350
+
351
+ Hello, <b>World</b>!
352
+
353
+ :param children: A :type:`blu.Node` or :py:class:`tuple` of
354
+ :type:`blu.Node`\\s.
355
+ :return: If ``children`` is a :py:class:`tuple`, a frament whose
356
+ children are ``list(children)``. Otherwise, a fragment whose
357
+ children are ``[children]``.
358
+ """
359
+ copy = Key(self._key)
360
+ copy._children = _get_children(children)
361
+ return copy
362
+
363
+
364
+ type ClientRenderer = Callable[..., Node | Generator[None, Node, Node]]
365
+
366
+
367
+ class Element[**P](Protocol):
368
+
369
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> "Element[P]": ...
370
+
371
+ def __getitem__(self, *children: "Node") -> "Element[P]": ...
372
+
373
+
374
+ UNSET = object()
375
+
376
+
377
+ def noop():
378
+ pass
379
+
380
+
381
+ class ClientElement:
382
+ """
383
+ A custom element to be rendered client-side. Created using the
384
+ :func:`blu.client` decorator.
385
+
386
+ .. code-block:: python
387
+
388
+ from blu import client
389
+ from blu.html import b, span
390
+
391
+ __client__ = True
392
+
393
+
394
+ @client
395
+ def ColorfulText(color, bold):
396
+ colorful_span = span(style={'color': color})[
397
+ (yield)
398
+ ]
399
+ if bold:
400
+ return b[colorful_span]
401
+ else:
402
+ return colorful_span
403
+
404
+
405
+ ColorfulText('red', bold=True)[
406
+ 'Danger! The world said hello back.',
407
+ ]
408
+
409
+ .. code-block:: html
410
+
411
+ <b>
412
+ <span style="color: red">
413
+ Danger! The world said hello back.
414
+ </span>
415
+ </b>
416
+ """
417
+
418
+ _args: tuple[Any, ...]
419
+ _kwargs: dict[str, Any]
420
+ _children: list[Node]
421
+ _renderer: ClientRenderer
422
+ _key: Any
423
+ _has_key: bool
424
+
425
+ def __init__(
426
+ self,
427
+ renderer: ClientRenderer,
428
+ args: tuple[Any, ...],
429
+ kwargs: dict[str, Any],
430
+ children: list[Node],
431
+ key: Any,
432
+ has_key: bool,
433
+ ):
434
+ self._renderer = renderer
435
+ self._args = args
436
+ self._kwargs = kwargs
437
+ self._children = children
438
+ self._key = key
439
+ self._has_key = has_key
440
+
441
+ def __call__(
442
+ self,
443
+ *args: Any,
444
+ **kwargs: Any,
445
+ ) -> "ClientElement":
446
+ """
447
+ Create a copy of ``self`` that will be rendered with the given
448
+ arguments.
449
+
450
+ .. code-block:: python
451
+
452
+ from blu import client
453
+ from blu.html import div
454
+
455
+ __client__ = True
456
+
457
+
458
+ @client
459
+ def Greeting(name='World'):
460
+ return div[f'Hello, {name}!']
461
+
462
+
463
+ div[
464
+ Greeting('Arnold'),
465
+ Greeting(name='Hailey'),
466
+ Greeting,
467
+ ]
468
+
469
+ .. code-block:: html
470
+
471
+ <div>Hello, Arnold!</div>
472
+ <div>Hello, Hailey!</div>
473
+ <div>Hello, World!</div>
474
+
475
+ :param args: The positional arguments to pass into the
476
+ element's render function when it is rendered.
477
+ :param kwargs: The keyword arguments to pass into the
478
+ element's render function when it is rendered, as well as an
479
+ optional keyword argument ``key``, which will be used by
480
+ React to uniquely identify the element when it is rendered.
481
+ Usually, you won't need to pass in a ``key``, but it's
482
+ required for any :py:class:`ClientElement <blu.ClientElement>` that is
483
+ rendered as an item in an
484
+ :py:class:`Iterable <collections.abc.Iterable>` (unless that
485
+ :py:class:`Iterable <collections.abc.Iterable>` is a
486
+ :py:class:`str` or a :py:class:`tuple`).
487
+
488
+ :return: A copy of ``self`` that will be rendered by calling its
489
+ render function with ``*args`` and ``**kwargs``, with the
490
+ exception that the keyword argument ``key`` will not be
491
+ passed on to the render function and will instead be used by
492
+ React to identify the new element. If the original had a
493
+ key, it will not be retained in the copy, and the copy will
494
+ have a key if and only if ``**kwargs`` contains the ``key``
495
+ argument.
496
+
497
+ Below is an example of using the ``key`` argument:
498
+
499
+ .. code-block:: python
500
+
501
+ from blu import client
502
+ from blu.html import div
503
+
504
+ PEOPLE = [
505
+ {'id': 0, 'name': 'Allison'},
506
+ {'id': 1, 'name': 'Bill'},
507
+ {'id': 2, 'name': 'Carrie'},
508
+ ]
509
+
510
+
511
+ @client
512
+ def Greeting(name):
513
+ return div[f'Hello, {name}!']
514
+
515
+
516
+ [Greeting(x['name'], key=x['id']) for x in PEOPLE]
517
+
518
+ .. code-block:: html
519
+
520
+ <div>Hello, Allison!</div>
521
+ <div>Hello, Bill!</div>
522
+ <div>Hello, Carrie!</div>
523
+ """
524
+ kwargs_copy = {**kwargs}
525
+ try:
526
+ del kwargs_copy["key"]
527
+ except KeyError:
528
+ pass
529
+ return ClientElement(
530
+ self._renderer,
531
+ args,
532
+ kwargs,
533
+ self._children,
534
+ key=kwargs.get("key", None),
535
+ has_key="key" in kwargs,
536
+ )
537
+
538
+ def __getitem__(self, children: Node) -> "ClientElement":
539
+ """
540
+ Create a copy of ``self`` that will render with the given
541
+ children displayed where the render function uses the ``yield``
542
+ keyword.
543
+
544
+ .. code-block:: python
545
+
546
+ from blu import client
547
+ from blu.html import span
548
+
549
+
550
+ @client
551
+ def RedText(text):
552
+ return span(style={'color': 'red'})[(yield)]
553
+
554
+
555
+ RedText['Danger! This text is red.']
556
+
557
+ .. code-block:: html
558
+
559
+ <span style="color: red">Danger! This text is red.</span>
560
+
561
+ :param children: Any valid :type:`blu.Node` or a
562
+ :py:class:`tuple` of :type:`blu.Node`\\s.
563
+ :return: A copy of ``self`` that will render with ``children``
564
+ displayed where the element's render function uses the
565
+ ``yield`` keyword. If no children were specified, nothing
566
+ will be rendered where the ``yield`` keyword is used.
567
+ :raises: :py:class:`TypeError` if element's rendering function
568
+ is not a :py:class:`Generator <collections.abc.Generator>`,
569
+ i.e. doesn't contain a ``yield`` statement.
570
+ """
571
+ if not inspect.isgeneratorfunction(self._renderer):
572
+ log.info("INFO")
573
+ log.debug(f"Non-generator function: {self._renderer}")
574
+ raise TypeError("This element does not accept any children.")
575
+ log.error(f"ClientElement children: {children}")
576
+ return ClientElement(
577
+ self._renderer,
578
+ self._args,
579
+ self._kwargs,
580
+ _get_children(children),
581
+ key=self._key,
582
+ has_key=self._has_key,
583
+ )
584
+
585
+ def _get_key(self):
586
+ if not self._has_key:
587
+ raise LookupError("This element has no key.")
588
+ return self._key
589
+
590
+ def __reduce__(self):
591
+ module = self._renderer.__module__
592
+ name = self._renderer.__name__
593
+ return (
594
+ _import_client_module,
595
+ (
596
+ module,
597
+ name,
598
+ self._args,
599
+ self._kwargs,
600
+ self._children,
601
+ self._key,
602
+ self._has_key,
603
+ ),
604
+ )
605
+
606
+
607
+ def _import_client_module(
608
+ module: str,
609
+ name: str,
610
+ args: tuple[Any],
611
+ kwargs: dict[str, Any],
612
+ children: list[Any],
613
+ key: Any,
614
+ has_key: bool,
615
+ ):
616
+ base = getattr(importlib.import_module(module), name)
617
+ if has_key:
618
+ all_kwargs = {**kwargs, "key": key}
619
+ else:
620
+ all_kwargs = kwargs
621
+ with_args = base(*args, **all_kwargs)
622
+ if children:
623
+ return with_args[children]
624
+ else:
625
+ return with_args
626
+
627
+
628
+ def py_to_html_name(py_name: str) -> str:
629
+ return re.sub("_$", "", py_name).replace("_", "-")
630
+
631
+
632
+ def create_html_element(tagname: str) -> HTMLElement:
633
+ """
634
+ Create an HTML element. Usually, you will use the :mod:`blu.html`
635
+ module to create HTML elements, but in rare cases where you cannot
636
+ import the tag name you need from :mod:`blu.html`, you can use this
637
+ function to create an HTML element from any valid tag name.
638
+
639
+ .. code-block:: python
640
+
641
+ from blu import create_rare_html_element
642
+
643
+ my_element = create_rare_html_element('my_element-')
644
+
645
+ my_element(id='my-id')['Hello, World!']
646
+
647
+
648
+ .. code-block:: html
649
+
650
+ <my_element->Hello, World!</my_element->
651
+
652
+ :param tagname: A valid HTML tag name.
653
+ :return: A :class:`blu.HTMLElement` whose tag name is ``tagname``,
654
+ with no props set.
655
+ """
656
+ return HTMLElement(tagname, {}, [])
657
+
658
+
659
+ if is_client:
660
+ from pyscript.ffi import create_proxy # type: ignore
661
+
662
+
663
+ def client(renderer: ClientRenderer) -> ClientElement:
664
+ """
665
+ Decorator that converts a rendering function into a
666
+ :class:`blu.ClientElement`.
667
+
668
+ .. code-block:: python
669
+
670
+ from blu import client
671
+ from blu.html import span
672
+
673
+
674
+ @client
675
+ def ColoredText(color):
676
+ return span(style={'color': color})[(yield)]
677
+
678
+ :param renderer: The function that should be used to render the
679
+ :class:`ClientElement <blu.ClientElement>`.
680
+
681
+ :return: An element that will be rendered client-side using
682
+ ``renderer``.
683
+
684
+ When a :class:`ClientElement <blu.ClientElement>` is rendered, the
685
+ rendering function it was created from is called with whatever
686
+ arguments were passed in using :meth:`ClientElement.__call__()
687
+ <blu.ClientElement.__call__>`:
688
+
689
+ .. code-block:: python
690
+
691
+ from blu import client
692
+
693
+
694
+ @client
695
+ def Greeting(name='World'):
696
+ return f'Hello, {name}!'
697
+
698
+
699
+ Greeting('Gary')
700
+
701
+ .. code-block:: html
702
+
703
+ Hello, Gary!
704
+
705
+ If arguments were never set using :meth:`ClientElement.__call__()
706
+ <blu.ClientElement.__call__>`, then the render function will be
707
+ called with no arguments:
708
+
709
+ .. code-block:: python
710
+
711
+ from blu import client
712
+
713
+
714
+ @client
715
+ def Greeting(name='World'):
716
+ return f'Hello, {name}!'
717
+
718
+
719
+ Greeting
720
+
721
+ .. code-block:: html
722
+
723
+ Hello, World!
724
+
725
+ A render function may use the yield keyword (only once) to get the
726
+ children set using :meth:`ClientElement.__getitem__()
727
+ <blu.ClientElement.__getitem__>`:
728
+
729
+ .. code-block:: python
730
+
731
+ from blu import client
732
+ from blu.html import span
733
+
734
+
735
+ @client
736
+ def RedText():
737
+ return span(style={'color': 'red'})[(yield)]
738
+
739
+
740
+ RedText['Danger!']
741
+
742
+ .. code-block:: html
743
+
744
+ <span style='color: red'>Danger!</span>
745
+
746
+ If the element's children were never set using
747
+ :meth:`ClientElement.__getitem__() <blu.ClientElement.__getitem__>`,
748
+ it won't have any children:
749
+
750
+ .. code-block:: python
751
+
752
+ from blu import client
753
+ from blu.html import span
754
+
755
+
756
+ @client
757
+ def RedText():
758
+ return span(style={'color': 'red'})[(yield)]
759
+
760
+
761
+ RedText
762
+
763
+ .. code-block:: html
764
+
765
+ <span style='color: red'></span>
766
+
767
+ """
768
+ element = ClientElement(renderer, (), {}, [], None, False)
769
+ if is_client:
770
+ create_proxy(element) # type: ignore
771
+ return element