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/_hooks.py ADDED
@@ -0,0 +1,323 @@
1
+ from collections.abc import AsyncGenerator, Callable, Coroutine, Generator
2
+ from typing import Any
3
+ import typing
4
+
5
+ from blu._utils import is_client
6
+
7
+ if is_client or typing.TYPE_CHECKING:
8
+ from pyscript.js_modules._blu_js_utils import useEffect, useRefObj, useState
9
+
10
+ # Integers for JS `useEffect` (avoid classifying return values in JS, where
11
+ # coroutines can be finalized before `isinstance` runs).
12
+ _use_effect_ag_phase: dict[int, str] = {}
13
+
14
+ # String tags for JS (reliable compare; PyProxy int coercion was flaky).
15
+ _USE_EFFECT_TAG_ASYNC_GENERATOR = "async_generator"
16
+ _USE_EFFECT_TAG_COROUTINE = "coroutine"
17
+ _USE_EFFECT_TAG_GENERATOR = "generator"
18
+ _USE_EFFECT_TAG_PLAIN = "plain"
19
+
20
+
21
+ def _unwrap_js_proxy(obj: Any) -> Any:
22
+ """Resolve Pyodide ``JsProxy`` so ``id`` matches the underlying Python object."""
23
+ if not is_client:
24
+ return obj
25
+ try:
26
+ from pyodide.ffi import JsProxy
27
+ except ImportError:
28
+ return obj
29
+ while isinstance(obj, JsProxy):
30
+ obj = obj.unwrap()
31
+ return obj
32
+
33
+
34
+ def _use_effect_invoke(callback: Callable[[], Any]) -> tuple[str, Any]:
35
+ """Call ``callback()`` and classify the return value for ``util.js`` ``useEffect``."""
36
+ from collections.abc import AsyncGenerator as AsyncGenABC
37
+ from collections.abc import Coroutine as CoroutineABC
38
+ from collections.abc import Generator as GenABC
39
+
40
+ result = callback()
41
+ if isinstance(result, AsyncGenABC):
42
+ _use_effect_start_async_gen(result)
43
+ return (_USE_EFFECT_TAG_ASYNC_GENERATOR, result)
44
+ if isinstance(result, CoroutineABC):
45
+ _use_effect_start_coro(result)
46
+ return (_USE_EFFECT_TAG_COROUTINE, result)
47
+ if isinstance(result, GenABC):
48
+ return (_USE_EFFECT_TAG_GENERATOR, result)
49
+ return (_USE_EFFECT_TAG_PLAIN, result)
50
+
51
+
52
+ def _use_effect_start_async_gen(agen: AsyncGenerator[Any, None]) -> None:
53
+ """Schedule running an async generator up to its first ``yield``."""
54
+ import asyncio
55
+
56
+ agen = _unwrap_js_proxy(agen)
57
+ aid = id(agen)
58
+ _use_effect_ag_phase[aid] = "pending"
59
+
60
+ async def setup() -> None:
61
+ await anext(agen)
62
+ _use_effect_ag_phase[aid] = "after_yield"
63
+
64
+ asyncio.create_task(setup())
65
+
66
+
67
+ def _use_effect_cleanup_async_gen(
68
+ agen: AsyncGenerator[Any, None],
69
+ on_done: Callable[[], None] | None = None,
70
+ ) -> None:
71
+ """Schedule teardown or ``aclose`` for an effect async generator.
72
+
73
+ ``on_done`` runs after async work finishes (e.g. to release JS proxies). The
74
+ client must not destroy ``agen`` until then, or the cleanup task may see a
75
+ dead generator.
76
+ """
77
+ import asyncio
78
+
79
+ agen = _unwrap_js_proxy(agen)
80
+
81
+ async def work() -> None:
82
+ try:
83
+ aid = id(agen)
84
+ phase = _use_effect_ag_phase.pop(aid, "pending")
85
+ if phase == "after_yield":
86
+ try:
87
+ await anext(agen)
88
+ except StopAsyncIteration:
89
+ pass
90
+ else:
91
+ try:
92
+ await agen.aclose()
93
+ except Exception:
94
+ pass
95
+ finally:
96
+ if on_done is not None:
97
+ on_done()
98
+
99
+ asyncio.create_task(work())
100
+
101
+
102
+ def _use_effect_start_coro(coro: Coroutine[Any, Any, Any]) -> None:
103
+ import asyncio
104
+
105
+ asyncio.create_task(coro)
106
+
107
+
108
+ def use_effect(
109
+ callback: Callable[
110
+ [],
111
+ None
112
+ | Generator[None]
113
+ | Coroutine[None, None, None]
114
+ | AsyncGenerator[None, None],
115
+ ],
116
+ ):
117
+ """
118
+ .. include:: /_includes/hook-note.rst
119
+
120
+ Perform set-up actions immediately after a :class:`ClientElement
121
+ <blu.ClientElement>` is rendered to the DOM and/or tear-down actions
122
+ immediately before it is removed from the DOM.
123
+
124
+ .. code-block:: python
125
+
126
+ from blu import client, use_effect
127
+ from blu.html import div
128
+
129
+ __client__ = True
130
+
131
+
132
+ @client
133
+ def MyClientElement():
134
+
135
+ @use_effect
136
+ def setup_and_teardown():
137
+ do_some_setup()
138
+ yield
139
+ do_some_teardown()
140
+
141
+ return div['Hello!']
142
+
143
+ :param callback: A non-generator function or a generator function
144
+ with a single ``yield`` statement.
145
+
146
+ If ``callback`` is a generator function or async generator function,
147
+ it will be run right up until the ``yield`` statement immediately
148
+ after the element is initially rendered to the DOM. The rest of the
149
+ function will be run immediately before the element is removed from
150
+ the DOM.
151
+
152
+ Otherwise, ``callback`` will be called immediately after the element
153
+ is initially rendered to the DOM.
154
+ """
155
+ useEffect(callback)
156
+
157
+
158
+ def use_state[T](init: T = None) -> tuple[T, Callable[[T], None]]:
159
+ """
160
+ .. include:: /_includes/hook-note.rst
161
+
162
+ Handle state management for an element.
163
+
164
+ .. code-block:: python
165
+
166
+ from blu import client
167
+ from blu.html import button
168
+
169
+ __client__ = True
170
+
171
+
172
+ @client
173
+ def MyElement():
174
+ click_count, set_click_count = use_state(0)
175
+
176
+ def handle_click(e):
177
+ set_click_count(click_count + 1)
178
+
179
+ return (
180
+ button(onClick=handle_click)[
181
+ f'You\\'ve clicked {click_count} times',
182
+ ]
183
+ )
184
+
185
+ :param init: An initial value for the state being managed.
186
+ :return: A tuple containing two items: The current value for the
187
+ state being managed, and a function that takes any value and
188
+ causes the element whose render function ``use_state`` was
189
+ called in to re-render. On a re-render triggered by this setter,
190
+ the first item in the tuple will be whatever was passed into the
191
+ setter when the re-render was triggered. On a re-render not
192
+ triggered by this state's setter, the first item in the tuple
193
+ will be whatever it was in the previous render. On the initial
194
+ render, the first item in the tuple will be ``init``.
195
+
196
+ """
197
+ return tuple(useState(init))
198
+
199
+
200
+ class Ref[T]:
201
+ """
202
+ The object returned by :func:`blu.use_ref`.
203
+
204
+ .. code-block:: python
205
+
206
+ from blu import client
207
+ from blu.tml import button
208
+
209
+ __client__ = True
210
+
211
+
212
+ @client
213
+ def TwoButtons():
214
+ # assigns variable ref to an instance of Ref.
215
+ ref = use_ref()
216
+ """
217
+
218
+ _value: T
219
+ _ref_proxy: Any
220
+
221
+ def __getitem__(self, empty_slice: slice) -> T:
222
+ """
223
+ Get the value currently stored in the :class:`Ref <blu.Ref>`.
224
+
225
+ .. code-block:: python
226
+
227
+ from blu import client
228
+ from blu.html import button
229
+
230
+ __client__ = True
231
+
232
+
233
+ @client
234
+ def TwoButtons():
235
+ ref = use_ref('Hello')
236
+ ref[:] # 'Hello'
237
+
238
+ :param empty_slice: Must be an empty slice, i.e. you must put a
239
+ single colon between the square brackets, without any
240
+ numbers.
241
+ :return: The value currently stored in ``self``.
242
+ """
243
+ if empty_slice.start or empty_slice.stop or empty_slice.step:
244
+ raise
245
+ return self._value
246
+
247
+ def __setitem__(self, empty_slice: slice, new_value: T):
248
+ """
249
+ Set the :class:`Ref <blu.Ref>` to point to a different value.
250
+
251
+ .. code-block:: python
252
+
253
+ from blu import client
254
+ from blu.tml import button
255
+
256
+ __client__ = True
257
+
258
+
259
+ @client
260
+ def TwoButtons():
261
+ ref = use_ref('Hello')
262
+ ref[:] = 'Goodbye'
263
+ ref[:] # 'Goodbye'
264
+
265
+ :param empty_slice: Must be an empty slice, i.e. you must put a
266
+ single colon between the square brackets, without any
267
+ numbers.
268
+ :param new_value: The new value that should be stored in
269
+ ``self``.
270
+
271
+ Once this method has been called, the :class:`Ref <blu.Ref>`\\'s
272
+ stored value will be set to ``new_value``.
273
+ """
274
+ if empty_slice.start or empty_slice.stop or empty_slice.step:
275
+ raise
276
+ self._value = new_value
277
+
278
+
279
+ def use_ref[T](init: T = None) -> Ref[T]:
280
+ """
281
+ .. include:: /_includes/hook-note.rst
282
+
283
+ Store a value that doesn't change between renders unless explicitly
284
+ set to a new value.
285
+
286
+ .. code-block:: python
287
+
288
+ from blu import client, is_client, use_ref
289
+ from blu.html import button
290
+
291
+ if is_client:
292
+ from js import alert
293
+
294
+ __client__ = True
295
+
296
+
297
+ @client
298
+ def MyElement():
299
+ click_count_ref = use_ref(0)
300
+
301
+ def handle_click(e):
302
+ click_count_ref[:] = click_count_ref[:] + 1
303
+ count = click_count_ref[:]
304
+ alert(f'You\\'ve clicked the button {count} times.')
305
+
306
+ return button['Click me!']
307
+
308
+ :param init: Any value.
309
+ :return: A :class:`Ref <blu.Ref>`. This will be the same
310
+ :class:`Ref <blu.Ref>` object on every render of the same
311
+ element. On the initial render, the value stored in the
312
+ :class:`Ref <blu.Ref>` will be ``init``, but you can set this to
313
+ a new value at any time (see :class:`blu.Ref` for how to set a
314
+ :class:`Ref <blu.Ref>`\\'s value).
315
+
316
+ .. note::
317
+
318
+ Setting a :class:`Ref <blu.Ref>`\\'s value does not trigger a
319
+ re-render.
320
+ """
321
+ ref: Ref[T] = Ref()
322
+ ref[:] = init
323
+ return useRefObj(ref)
blu/_http.py ADDED
@@ -0,0 +1,219 @@
1
+ from collections import defaultdict
2
+ import urllib.parse
3
+
4
+ from blu._nodes import Node
5
+ from blu._utils.typing import Iterator, Mapping
6
+
7
+ type QueryInit = Mapping[str, str | list[str]]
8
+
9
+
10
+ class QueryParams:
11
+ """
12
+ The query parameters in a request URL.
13
+
14
+ .. code-block:: python
15
+
16
+ from blu import Request
17
+
18
+ request = Request('GET', '/?my-query-param=my-value')
19
+
20
+ request.query().get('my-query-param') == 'my-value'
21
+ """
22
+
23
+ _items: dict[str, list[str]]
24
+
25
+ @classmethod
26
+ def from_query_string(cls, query_string: str) -> "QueryParams":
27
+ """
28
+ Parse a query string.
29
+
30
+ :param query_string: The query string to parse, without the
31
+ leading ``?`` character.
32
+
33
+ :return: The :class:`QueryParams` representation of the query
34
+ string.
35
+ """
36
+ pair_strings = query_string.split("&") if query_string else []
37
+ pairs = [x.split("=") for x in pair_strings]
38
+ query_params_init: dict[str, list[str]] = defaultdict(lambda: [])
39
+ for k, v in pairs:
40
+ decoded = urllib.parse.unquote_plus(v)
41
+ query_params_init[k].append(decoded)
42
+ return QueryParams({**query_params_init})
43
+
44
+ def __init__(self, items: QueryInit):
45
+ """
46
+ .. code-block:: python
47
+
48
+ # ?sortBy='relevance&q=position&q=velocity&q=acceleration
49
+ QueryParams({
50
+ 'sortBy': 'relevance',
51
+ 'q': ['position', 'velocity', 'acceleration'],
52
+ })
53
+ :param items: A mapping of query parameter names to values. If
54
+ a query parameter has only one value, then its value can be
55
+ represented as a :py:class:`str`. If it has more than one
56
+ value, it should be represented as a
57
+ :py:class:`list` of :py:class:`str`\\ s.
58
+ """
59
+ self._items = {k: v if isinstance(v, list) else [v] for k, v in items.items()}
60
+
61
+ def __getitem__(self, key: str) -> str:
62
+ """
63
+ Get the value of a query parameter.
64
+
65
+ .. code-block:: python
66
+
67
+ QueryParams.from_query_string('x=value')['x'] == 'value'
68
+
69
+ :param key: A query parameter name.
70
+
71
+ :return: The value of that query parameter. If the parameter has
72
+ multiple values (e.g. ``a=1&1=34``), returns the first.
73
+
74
+ :raise KeyError: If the :class:`QueryParams` object has no
75
+ query parameter with the name specified by :data:`key`.
76
+ """
77
+ return self._items[key][0]
78
+
79
+ def get[T](self, key: str, default: T = None) -> str | T:
80
+ """
81
+ Get the value of a query parameter.
82
+
83
+ .. code-block:: python
84
+
85
+ QueryParams.from_query_string('a=1').get('a') == '1'
86
+
87
+ :param key: The name of a query parameter.
88
+ :param default: A default value.
89
+
90
+ :return: The value of the query parameter named **key** if it
91
+ exists. Otherwise, **default**. If there are multiple values
92
+ associated with **key**, returns the first.
93
+ """
94
+ try:
95
+ return self._items[key][0]
96
+ except KeyError:
97
+ return default
98
+
99
+ def all(self, key: str) -> list[str]:
100
+ """
101
+ Get all values associated with a query parameter key.
102
+
103
+ .. code-block:: python
104
+
105
+ q = QueryParams.from_query_string('a=1&a=2')
106
+ q.all('a') == ['1', '2']
107
+
108
+ :param key: A query parameter key.
109
+
110
+ :return: A :py:class:`list` of all values associated with the
111
+ given query parameter key, in the order they appear in the
112
+ query string, or, if the :class:`QueryParams` object was
113
+ not created from a query string, the order they were defined
114
+ in the data passed into the :class:`QueryParams`
115
+ constructor.
116
+ """
117
+ return self._items.get(key, [])
118
+
119
+ def __iter__(self) -> Iterator[tuple[str, list[str]]]:
120
+ """
121
+ Iterate over the keys and value lists of ``self``.
122
+
123
+ .. code-block:: python
124
+
125
+ q = QueryParams.from_query_string('a=1&b=2&b=3')
126
+ for name, values in q:
127
+ print(f'{name}:', ', '.join(values))
128
+
129
+ .. code-block:: console
130
+
131
+ a: 1
132
+ b: 2, 3
133
+
134
+ :return: An iterator of key-value pairs, where the key is a
135
+ query parameter name, and the value is a list of all string
136
+ values associated with that name in the query string. Pairs
137
+ occur in the order of names' first appearance in the query
138
+ string, and query values within a pair are in the order in
139
+ which they are set in the query string.
140
+ """
141
+ return ((k, v) for k, v in self._items.items())
142
+
143
+ def to_query_string(self) -> str:
144
+ """
145
+ Serialize ``self`` to a query string.
146
+
147
+ .. code-block:: python
148
+
149
+ q = QueryParams.from_query_string('a=1&b=2')
150
+ q.to_query_string() == 'a=1&b=2'
151
+
152
+ :return: The query string representation of the query parameters
153
+ in ``self``.
154
+ """
155
+ pairs: list[str] = []
156
+ for k, vals in self:
157
+ for v in vals:
158
+ pairs.append(f"{k}={v}")
159
+ return "&".join(pairs)
160
+
161
+
162
+ class Request:
163
+ method: str
164
+ path: str
165
+ query: QueryParams
166
+ headers: dict[str, str]
167
+
168
+ def __init__(
169
+ self,
170
+ path: str,
171
+ query: QueryParams | QueryInit = QueryParams({}),
172
+ headers: Mapping[str, str] = {},
173
+ ):
174
+ self.path = path
175
+ if isinstance(query, QueryParams):
176
+ self.query = query
177
+ else:
178
+ self.query = QueryParams(query)
179
+ self.headers = {**headers}
180
+
181
+
182
+ class Response:
183
+ """
184
+ An HTTP response for a page in a web application. Return a
185
+ :class:`Response <blu.Response>` from a ``__page__`` function (see
186
+ :ref:`File Conventions`) to set HTTP status and headers for that
187
+ page.
188
+
189
+ .. code-block:: python
190
+ :caption: __index__.py
191
+
192
+ def __page__():
193
+ return Response(
194
+ p['Hello.'],
195
+ status=404,
196
+ headers={
197
+ 'Cache-Control': 'no-cache',
198
+ 'Last-Modified': 'Tue, 10 Dec 2024 10:00:00 GMT',
199
+ },
200
+ )
201
+
202
+ :param body: The React node to render when displaying the page.
203
+ :param status: The status code to send in the HTTP response.
204
+ :param headers: The HTTP headers to send in the HTTP response.
205
+ """
206
+
207
+ _body: Node
208
+ _status: int
209
+ _headers: dict[str, str]
210
+
211
+ def __init__(
212
+ self,
213
+ body: Node = None,
214
+ status: int = 200,
215
+ headers: Mapping[str, str] = {},
216
+ ):
217
+ self._body = body
218
+ self._status = status
219
+ self._headers = {**headers}