pulse-framework 0.1.71__py3-none-any.whl → 0.1.73__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.
@@ -0,0 +1,538 @@
1
+ """
2
+ Query parameter bindings for State properties.
3
+ """
4
+
5
+ import sys
6
+ import warnings
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from datetime import date, datetime, timedelta, timezone
10
+ from types import UnionType
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ Any,
14
+ Generic,
15
+ TypeAlias,
16
+ TypeVar,
17
+ cast,
18
+ get_args,
19
+ get_origin,
20
+ override,
21
+ )
22
+ from urllib.parse import urlencode
23
+
24
+ from pulse.context import PulseContext
25
+ from pulse.helpers import Disposable, values_equal
26
+ from pulse.reactive import Effect, Scope, Signal, Untrack
27
+ from pulse.reactive_extensions import reactive, unwrap
28
+ from pulse.state.property import InitializableProperty, StateProperty
29
+
30
+ T = TypeVar("T")
31
+
32
+ if TYPE_CHECKING:
33
+ from pulse.render_session import RenderSession
34
+ from pulse.routing import RouteContext
35
+ from pulse.state.state import State
36
+
37
+
38
+ if TYPE_CHECKING:
39
+ if sys.version_info >= (3, 12):
40
+ type QueryParam[T] = T
41
+ else:
42
+ QueryParam: TypeAlias = T
43
+ else:
44
+
45
+ class QueryParam(Generic[T]):
46
+ """
47
+ Query parameter binding for State properties.
48
+
49
+ Usage:
50
+ q: QueryParam[str] = ""
51
+ page: QueryParam[int] = 1
52
+
53
+ At type-check time, QueryParam[T] is treated as T.
54
+ At runtime, QueryParam[T] is detected by StateMeta and converted to QueryParamProperty.
55
+ """
56
+
57
+ pass
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class QueryParamCodec:
62
+ kind: str
63
+ label: str
64
+ optional: bool = False
65
+ item: "QueryParamCodec | None" = None
66
+
67
+
68
+ def _query_param_warning(message: str) -> None:
69
+ warnings.warn(message, stacklevel=3)
70
+
71
+
72
+ def _coerce_datetime(value: datetime, *, param: str) -> datetime:
73
+ if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
74
+ _query_param_warning(
75
+ "[Pulse] QueryParam '" + param + "' received naive datetime; assuming UTC."
76
+ )
77
+ return value.replace(tzinfo=timezone.utc)
78
+ return value
79
+
80
+
81
+ def _parse_bool(raw: str, *, param: str) -> bool:
82
+ normalized = raw.strip().lower()
83
+ if normalized in ("true", "1"):
84
+ return True
85
+ if normalized in ("false", "0"):
86
+ return False
87
+ raise ValueError(f"QueryParam '{param}' expected bool, got '{raw}'")
88
+
89
+
90
+ def _parse_date(raw: str, *, param: str) -> date:
91
+ try:
92
+ return date.fromisoformat(raw)
93
+ except ValueError as exc:
94
+ raise ValueError(
95
+ f"QueryParam '{param}' expected date (YYYY-MM-DD), got '{raw}'"
96
+ ) from exc
97
+
98
+
99
+ def _parse_datetime(raw: str, *, param: str) -> datetime:
100
+ value = raw
101
+ if value.endswith("Z") or value.endswith("z"):
102
+ value = value[:-1] + "+00:00"
103
+ try:
104
+ parsed = datetime.fromisoformat(value)
105
+ except ValueError as exc:
106
+ raise ValueError(
107
+ f"QueryParam '{param}' expected datetime (ISO 8601), got '{raw}'"
108
+ ) from exc
109
+ return _coerce_datetime(parsed, param=param)
110
+
111
+
112
+ def _serialize_datetime(value: datetime, *, param: str) -> str:
113
+ coerced = _coerce_datetime(value, param=param)
114
+ result = coerced.isoformat()
115
+ if coerced.utcoffset() == timedelta(0) and result.endswith("+00:00"):
116
+ return result[:-6] + "Z"
117
+ return result
118
+
119
+
120
+ def _escape_list_item(value: str) -> str:
121
+ return value.replace("\\", "\\\\").replace(",", "\\,")
122
+
123
+
124
+ def _split_list_items(raw: str, *, param: str) -> list[str]:
125
+ if raw == "":
126
+ return []
127
+ items: list[str] = []
128
+ buf: list[str] = []
129
+ escaping = False
130
+ for ch in raw:
131
+ if escaping:
132
+ if ch not in ("\\", ","):
133
+ raise ValueError(f"QueryParam '{param}' has invalid escape '\\{ch}'")
134
+ buf.append(ch)
135
+ escaping = False
136
+ continue
137
+ if ch == "\\":
138
+ escaping = True
139
+ continue
140
+ if ch == ",":
141
+ items.append("".join(buf))
142
+ buf = []
143
+ continue
144
+ buf.append(ch)
145
+ if escaping:
146
+ raise ValueError(f"QueryParam '{param}' has trailing escape '\\\\'")
147
+ items.append("".join(buf))
148
+ return items
149
+
150
+
151
+ def _is_union_origin(origin: Any) -> bool:
152
+ return origin is UnionType or (
153
+ getattr(origin, "__module__", "") == "typing"
154
+ and getattr(origin, "__qualname__", "") == "Union"
155
+ )
156
+
157
+
158
+ def _build_query_param_codec(value_type: Any) -> QueryParamCodec:
159
+ origin = get_origin(value_type)
160
+ args = get_args(value_type)
161
+ if _is_union_origin(origin):
162
+ non_none = [arg for arg in args if arg is not type(None)]
163
+ if len(non_none) != len(args) - 1:
164
+ raise TypeError("QueryParam Optional types must include exactly one None")
165
+ if len(non_none) != 1:
166
+ raise TypeError("QueryParam Optional types must wrap a single type")
167
+ inner = _build_query_param_codec(non_none[0])
168
+ return QueryParamCodec(
169
+ kind=inner.kind,
170
+ label=inner.label,
171
+ optional=True,
172
+ item=inner.item,
173
+ )
174
+ if origin is list:
175
+ if len(args) != 1:
176
+ raise TypeError("QueryParam list types must specify an item type")
177
+ item_codec = _build_query_param_codec(args[0])
178
+ if item_codec.kind == "list":
179
+ raise TypeError("QueryParam list types cannot be nested")
180
+ return QueryParamCodec(
181
+ kind="list",
182
+ label=f"list[{item_codec.label}]",
183
+ item=item_codec,
184
+ )
185
+ if value_type is str:
186
+ return QueryParamCodec(kind="str", label="str")
187
+ if value_type is int:
188
+ return QueryParamCodec(kind="int", label="int")
189
+ if value_type is float:
190
+ return QueryParamCodec(kind="float", label="float")
191
+ if value_type is bool:
192
+ return QueryParamCodec(kind="bool", label="bool")
193
+ if value_type is date:
194
+ return QueryParamCodec(kind="date", label="date")
195
+ if value_type is datetime:
196
+ return QueryParamCodec(kind="datetime", label="datetime")
197
+ raise TypeError(f"Unsupported QueryParam type: {value_type!r}")
198
+
199
+
200
+ def _parse_query_param_scalar(raw: str, *, codec: QueryParamCodec, param: str) -> Any:
201
+ if raw == "" and codec.optional:
202
+ return None
203
+ if codec.kind == "str":
204
+ return raw
205
+ if codec.kind == "int":
206
+ try:
207
+ return int(raw)
208
+ except ValueError as exc:
209
+ raise ValueError(f"QueryParam '{param}' expected int, got '{raw}'") from exc
210
+ if codec.kind == "float":
211
+ try:
212
+ return float(raw)
213
+ except ValueError as exc:
214
+ raise ValueError(
215
+ f"QueryParam '{param}' expected float, got '{raw}'"
216
+ ) from exc
217
+ if codec.kind == "bool":
218
+ return _parse_bool(raw, param=param)
219
+ if codec.kind == "date":
220
+ return _parse_date(raw, param=param)
221
+ if codec.kind == "datetime":
222
+ return _parse_datetime(raw, param=param)
223
+ raise TypeError(f"Unsupported QueryParam codec '{codec.kind}'")
224
+
225
+
226
+ def _parse_query_param_value(
227
+ raw: str | None,
228
+ *,
229
+ default: Any,
230
+ codec: QueryParamCodec,
231
+ param: str,
232
+ ) -> Any:
233
+ if raw is None:
234
+ return default
235
+ if raw == "" and codec.optional:
236
+ return None
237
+ if codec.kind == "list":
238
+ assert codec.item is not None
239
+ items: list[Any] = []
240
+ for token in _split_list_items(raw, param=param):
241
+ if token == "" and codec.item.optional:
242
+ items.append(None)
243
+ continue
244
+ items.append(
245
+ _parse_query_param_scalar(token, codec=codec.item, param=param)
246
+ )
247
+ return reactive(items)
248
+ return _parse_query_param_scalar(raw, codec=codec, param=param)
249
+
250
+
251
+ def _serialize_query_param_scalar(
252
+ value: Any, *, codec: QueryParamCodec, param: str
253
+ ) -> str:
254
+ if codec.kind == "str":
255
+ if not isinstance(value, str):
256
+ raise TypeError(f"QueryParam '{param}' expected str, got {type(value)!r}")
257
+ return value
258
+ if codec.kind == "int":
259
+ if not isinstance(value, int) or isinstance(value, bool):
260
+ raise TypeError(f"QueryParam '{param}' expected int, got {type(value)!r}")
261
+ return str(value)
262
+ if codec.kind == "float":
263
+ if not isinstance(value, float):
264
+ raise TypeError(f"QueryParam '{param}' expected float, got {type(value)!r}")
265
+ return str(value)
266
+ if codec.kind == "bool":
267
+ if not isinstance(value, bool):
268
+ raise TypeError(f"QueryParam '{param}' expected bool, got {type(value)!r}")
269
+ return "true" if value else "false"
270
+ if codec.kind == "date":
271
+ if not isinstance(value, date) or isinstance(value, datetime):
272
+ raise TypeError(f"QueryParam '{param}' expected date, got {type(value)!r}")
273
+ return value.isoformat()
274
+ if codec.kind == "datetime":
275
+ if not isinstance(value, datetime):
276
+ raise TypeError(
277
+ f"QueryParam '{param}' expected datetime, got {type(value)!r}"
278
+ )
279
+ return _serialize_datetime(value, param=param)
280
+ raise TypeError(f"Unsupported QueryParam codec '{codec.kind}'")
281
+
282
+
283
+ def _serialize_query_param_value(
284
+ value: Any,
285
+ *,
286
+ default: Any,
287
+ codec: QueryParamCodec,
288
+ param: str,
289
+ ) -> str | None:
290
+ if value is None:
291
+ return None
292
+ if values_equal(value, default):
293
+ return None
294
+ if codec.kind == "list":
295
+ if not isinstance(value, list):
296
+ raise TypeError(f"QueryParam '{param}' expected list, got {type(value)!r}")
297
+ assert codec.item is not None
298
+ items = cast(list[Any], value)
299
+ if len(items) == 0:
300
+ if values_equal(value, default):
301
+ return None
302
+ return ""
303
+ parts: list[str] = []
304
+ for item in items:
305
+ if item is None:
306
+ if codec.item.optional:
307
+ parts.append("")
308
+ continue
309
+ raise TypeError(f"QueryParam '{param}' list items cannot be None")
310
+ parts.append(
311
+ _escape_list_item(
312
+ _serialize_query_param_scalar(item, codec=codec.item, param=param)
313
+ )
314
+ )
315
+ return ",".join(parts)
316
+ return _serialize_query_param_scalar(value, codec=codec, param=param)
317
+
318
+
319
+ def extract_query_param(annotation: Any) -> tuple[Any, bool]:
320
+ """Extract the inner type from QueryParam[T] if present."""
321
+ origin = get_origin(annotation)
322
+ if origin is QueryParam:
323
+ args = get_args(annotation)
324
+ if len(args) != 1:
325
+ raise TypeError(
326
+ "QueryParam expects a single type argument (e.g. QueryParam[str])."
327
+ )
328
+ return args[0], True
329
+ return annotation, False
330
+
331
+
332
+ class QueryParamProperty(StateProperty, InitializableProperty):
333
+ value_type: Any
334
+ param_name: str
335
+ codec: QueryParamCodec
336
+ default_value: Any
337
+
338
+ def __init__(
339
+ self,
340
+ name: str,
341
+ default: Any,
342
+ value_type: Any,
343
+ ):
344
+ self.default_value = unwrap(default, untrack=True)
345
+ super().__init__(name, default)
346
+ self.value_type = value_type
347
+ self.param_name = name
348
+ self.codec = _build_query_param_codec(value_type)
349
+
350
+ @override
351
+ def __set_name__(self, owner: type[Any], name: str) -> None:
352
+ super().__set_name__(owner, name)
353
+ self.param_name = name
354
+
355
+ @override
356
+ def initialize(self, state: "State", name: str) -> None:
357
+ ctx = PulseContext.get()
358
+ if ctx.render is None or ctx.route is None:
359
+ raise RuntimeError(
360
+ "QueryParam properties require a route render context. Create the state inside a component render."
361
+ )
362
+ sync = ctx.route.query_param_sync
363
+ registration = sync.register(state, name, self)
364
+ setattr(state, f"_query_param_reg_{name}", registration)
365
+
366
+
367
+ @dataclass
368
+ class QueryParamBinding:
369
+ param: str
370
+ state: "State"
371
+ prop: QueryParamProperty
372
+ attr_name: str
373
+
374
+ def signal(self) -> Signal[Any]:
375
+ return self.prop.get_signal(self.state)
376
+
377
+ def default(self) -> Any:
378
+ return self.prop.default_value
379
+
380
+ def codec(self) -> QueryParamCodec:
381
+ return self.prop.codec
382
+
383
+
384
+ class QueryParamRegistration(Disposable):
385
+ _sync: "QueryParamSync"
386
+ _param: str
387
+
388
+ def __init__(self, sync: "QueryParamSync", param: str) -> None:
389
+ self._sync = sync
390
+ self._param = param
391
+
392
+ @override
393
+ def dispose(self) -> None:
394
+ self._sync.unregister(self._param)
395
+
396
+
397
+ class QueryParamSync(Disposable):
398
+ route: "RouteContext"
399
+ render: "RenderSession"
400
+ _bindings: dict[str, QueryParamBinding]
401
+ _route_effect: Effect | None
402
+ _state_effect: Effect | None
403
+
404
+ def __init__(self, render: "RenderSession", route: "RouteContext") -> None:
405
+ self.render = render
406
+ self.route = route
407
+ self._bindings = {}
408
+ self._route_effect = None
409
+ self._state_effect = None
410
+
411
+ def register(
412
+ self, state: "State", attr_name: str, prop: QueryParamProperty
413
+ ) -> QueryParamRegistration:
414
+ param = prop.param_name
415
+ if not param:
416
+ raise RuntimeError("QueryParam param name was not resolved")
417
+ if param in self._bindings:
418
+ raise ValueError(f"QueryParam '{param}' is already bound in this route")
419
+ binding = QueryParamBinding(
420
+ param=param,
421
+ state=state,
422
+ prop=prop,
423
+ attr_name=attr_name,
424
+ )
425
+ self._bindings[param] = binding
426
+ self._ensure_effects()
427
+ self._apply_route_to_binding(binding)
428
+ self._prime_effects()
429
+ return QueryParamRegistration(self, param)
430
+
431
+ def unregister(self, param: str) -> None:
432
+ binding = self._bindings.pop(param, None)
433
+ if binding is None:
434
+ return
435
+ if not self._bindings:
436
+ if self._route_effect:
437
+ self._route_effect.dispose()
438
+ self._route_effect = None
439
+ if self._state_effect:
440
+ self._state_effect.dispose()
441
+ self._state_effect = None
442
+
443
+ def _ensure_effects(self) -> None:
444
+ if self._route_effect is None or self._state_effect is None:
445
+ with Scope():
446
+ if self._route_effect is None:
447
+ self._route_effect = Effect(
448
+ self._sync_from_route,
449
+ name="QueryParamSync:route",
450
+ lazy=True,
451
+ )
452
+ if self._state_effect is None:
453
+ self._state_effect = Effect(
454
+ self._sync_to_route,
455
+ name="QueryParamSync:state",
456
+ lazy=True,
457
+ )
458
+
459
+ def _prime_effects(self) -> None:
460
+ if self._route_effect:
461
+ self._route_effect.run()
462
+ if self._state_effect:
463
+ self._state_effect.run()
464
+
465
+ def _apply_route_to_binding(self, binding: QueryParamBinding) -> None:
466
+ query_params = self.route.queryParams
467
+ raw = query_params.get(binding.param)
468
+ parsed = _parse_query_param_value(
469
+ raw,
470
+ default=binding.default(),
471
+ codec=binding.codec(),
472
+ param=binding.param,
473
+ )
474
+ signal = binding.signal()
475
+ current = signal.value
476
+ if values_equal(current, parsed):
477
+ return
478
+ binding.prop.__set__(binding.state, parsed)
479
+
480
+ def _sync_from_route(self) -> None:
481
+ _ = self.route.queryParams
482
+ for binding in self._bindings.values():
483
+ self._apply_route_to_binding(binding)
484
+
485
+ def _sync_to_route(self) -> None:
486
+ with Untrack():
487
+ info = self.route.info
488
+ raw_params = info["queryParams"]
489
+ current_params = dict(cast(Mapping[str, str], raw_params))
490
+ pathname = info["pathname"]
491
+ hash_frag = info["hash"]
492
+ query_params = dict(current_params)
493
+ for binding in self._bindings.values():
494
+ signal = binding.signal()
495
+ value = signal.read()
496
+ codec = binding.codec()
497
+ if codec.kind == "list" and value is not None:
498
+ value = unwrap(value)
499
+ serialized = _serialize_query_param_value(
500
+ value,
501
+ default=binding.default(),
502
+ codec=codec,
503
+ param=binding.param,
504
+ )
505
+ if serialized is None:
506
+ query_params.pop(binding.param, None)
507
+ else:
508
+ query_params[binding.param] = serialized
509
+
510
+ if query_params == current_params:
511
+ return
512
+ path = pathname
513
+ query = urlencode(query_params)
514
+ if query:
515
+ path += "?" + query
516
+ if hash_frag:
517
+ if hash_frag.startswith("#"):
518
+ path += hash_frag
519
+ else:
520
+ path += "#" + hash_frag
521
+ self.render.send(
522
+ {
523
+ "type": "navigate_to",
524
+ "path": path,
525
+ "replace": True,
526
+ "hard": False,
527
+ }
528
+ )
529
+
530
+ @override
531
+ def dispose(self) -> None:
532
+ if self._route_effect:
533
+ self._route_effect.dispose()
534
+ self._route_effect = None
535
+ if self._state_effect:
536
+ self._state_effect.dispose()
537
+ self._state_effect = None
538
+ self._bindings.clear()