pulse-framework 0.1.44__py3-none-any.whl → 0.1.47__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.
Files changed (80) hide show
  1. pulse/__init__.py +10 -24
  2. pulse/app.py +3 -25
  3. pulse/codegen/codegen.py +43 -88
  4. pulse/codegen/js.py +35 -5
  5. pulse/codegen/templates/route.py +341 -254
  6. pulse/form.py +1 -1
  7. pulse/helpers.py +40 -8
  8. pulse/hooks/core.py +2 -2
  9. pulse/hooks/effects.py +1 -1
  10. pulse/hooks/init.py +2 -1
  11. pulse/hooks/setup.py +1 -1
  12. pulse/hooks/stable.py +2 -2
  13. pulse/hooks/states.py +2 -2
  14. pulse/html/props.py +3 -2
  15. pulse/html/tags.py +135 -0
  16. pulse/html/tags.pyi +4 -0
  17. pulse/js/__init__.py +110 -0
  18. pulse/js/__init__.pyi +95 -0
  19. pulse/js/_types.py +297 -0
  20. pulse/js/array.py +253 -0
  21. pulse/js/console.py +47 -0
  22. pulse/js/date.py +113 -0
  23. pulse/js/document.py +138 -0
  24. pulse/js/error.py +139 -0
  25. pulse/js/json.py +62 -0
  26. pulse/js/map.py +84 -0
  27. pulse/js/math.py +66 -0
  28. pulse/js/navigator.py +76 -0
  29. pulse/js/number.py +54 -0
  30. pulse/js/object.py +173 -0
  31. pulse/js/promise.py +150 -0
  32. pulse/js/regexp.py +54 -0
  33. pulse/js/set.py +109 -0
  34. pulse/js/string.py +35 -0
  35. pulse/js/weakmap.py +50 -0
  36. pulse/js/weakset.py +45 -0
  37. pulse/js/window.py +199 -0
  38. pulse/messages.py +22 -3
  39. pulse/queries/client.py +7 -7
  40. pulse/queries/effect.py +16 -0
  41. pulse/queries/infinite_query.py +138 -29
  42. pulse/queries/mutation.py +1 -15
  43. pulse/queries/protocol.py +136 -0
  44. pulse/queries/query.py +610 -174
  45. pulse/queries/store.py +11 -14
  46. pulse/react_component.py +167 -14
  47. pulse/reactive.py +19 -1
  48. pulse/reactive_extensions.py +5 -5
  49. pulse/render_session.py +185 -59
  50. pulse/renderer.py +80 -158
  51. pulse/routing.py +1 -18
  52. pulse/transpiler/__init__.py +131 -0
  53. pulse/transpiler/builtins.py +731 -0
  54. pulse/transpiler/constants.py +110 -0
  55. pulse/transpiler/context.py +26 -0
  56. pulse/transpiler/errors.py +2 -0
  57. pulse/transpiler/function.py +250 -0
  58. pulse/transpiler/ids.py +16 -0
  59. pulse/transpiler/imports.py +409 -0
  60. pulse/transpiler/js_module.py +274 -0
  61. pulse/transpiler/modules/__init__.py +30 -0
  62. pulse/transpiler/modules/asyncio.py +38 -0
  63. pulse/transpiler/modules/json.py +20 -0
  64. pulse/transpiler/modules/math.py +320 -0
  65. pulse/transpiler/modules/re.py +466 -0
  66. pulse/transpiler/modules/tags.py +268 -0
  67. pulse/transpiler/modules/typing.py +59 -0
  68. pulse/transpiler/nodes.py +1216 -0
  69. pulse/transpiler/py_module.py +119 -0
  70. pulse/transpiler/transpiler.py +938 -0
  71. pulse/transpiler/utils.py +4 -0
  72. pulse/types/event_handler.py +3 -2
  73. pulse/vdom.py +212 -13
  74. {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/METADATA +1 -1
  75. pulse_framework-0.1.47.dist-info/RECORD +119 -0
  76. pulse/codegen/imports.py +0 -204
  77. pulse/css.py +0 -155
  78. pulse_framework-0.1.44.dist-info/RECORD +0 -79
  79. {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/WHEEL +0 -0
  80. {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/entry_points.txt +0 -0
pulse/js/window.py ADDED
@@ -0,0 +1,199 @@
1
+ """Browser window global Any.
2
+
3
+ Usage:
4
+ from pulse.js import window
5
+ window.alert("Hello!") # -> window.alert("Hello!")
6
+ window.innerWidth # -> window.innerWidth
7
+ """
8
+
9
+ from collections.abc import Callable as _Callable
10
+ from typing import Any as _Any
11
+
12
+ from pulse.js._types import Element as _Element
13
+ from pulse.js._types import Selection as _Selection
14
+ from pulse.transpiler.js_module import register_js_module as _register_js_module
15
+
16
+ # Dimensions
17
+ innerWidth: int
18
+ innerHeight: int
19
+ outerWidth: int
20
+ outerHeight: int
21
+
22
+ # Scroll position
23
+ scrollX: float
24
+ scrollY: float
25
+ pageXOffset: float # Alias for scrollX
26
+ pageYOffset: float # Alias for scrollY
27
+
28
+ # Screen information
29
+ devicePixelRatio: float
30
+
31
+ # Location and history (typed as Any since they're complex interfaces)
32
+ location: _Any
33
+ history: _Any
34
+ navigator: _Any
35
+ document: _Any
36
+
37
+ # Storage
38
+ localStorage: _Any
39
+ sessionStorage: _Any
40
+
41
+ # Performance
42
+ performance: _Any
43
+
44
+
45
+ # Dialog methods
46
+ def alert(message: str = "") -> None:
47
+ """Display an alert dialog with the given message."""
48
+ ...
49
+
50
+
51
+ def confirm(message: str = "") -> bool:
52
+ """Display a confirmation dialog. Returns True if user clicks OK."""
53
+ ...
54
+
55
+
56
+ def prompt(message: str = "", default: str = "") -> str | None:
57
+ """Display a prompt dialog. Returns input or None if cancelled."""
58
+ ...
59
+
60
+
61
+ # Scroll methods
62
+ def scrollTo(x: float | dict[str, float], y: float | None = None) -> None:
63
+ """Scroll to the given position."""
64
+ ...
65
+
66
+
67
+ def scrollBy(x: float | dict[str, float], y: float | None = None) -> None:
68
+ """Scroll by the given amount."""
69
+ ...
70
+
71
+
72
+ def scroll(x: float | dict[str, float], y: float | None = None) -> None:
73
+ """Alias for scrollTo."""
74
+ ...
75
+
76
+
77
+ # Selection
78
+ def getSelection() -> _Selection | None:
79
+ """Return the current text selection."""
80
+ ...
81
+
82
+
83
+ def getComputedStyle(element: _Element, pseudoElt: str | None = None) -> _Any:
84
+ """Return the computed style of an element."""
85
+ ...
86
+
87
+
88
+ # Focus
89
+ def focus() -> None:
90
+ """Give focus to the window."""
91
+ ...
92
+
93
+
94
+ def blur() -> None:
95
+ """Remove focus from the window."""
96
+ ...
97
+
98
+
99
+ # Open/close
100
+ def open(
101
+ url: str = "",
102
+ target: str = "_blank",
103
+ features: str = "",
104
+ ) -> _Any | None:
105
+ """Open a new window. Returns the new window Any or None."""
106
+ ...
107
+
108
+
109
+ def close() -> None:
110
+ """Close the window (only works for windows opened by script)."""
111
+ ...
112
+
113
+
114
+ # Timers (these return timer IDs)
115
+ def setTimeout(handler: _Callable[..., None], timeout: int = 0, *args: _Any) -> int:
116
+ """Schedule a function to run after a delay. Returns timer ID."""
117
+ ...
118
+
119
+
120
+ def clearTimeout(timeoutId: int) -> None:
121
+ """Cancel a timeout scheduled with setTimeout."""
122
+ ...
123
+
124
+
125
+ def setInterval(handler: _Callable[..., None], timeout: int = 0, *args: _Any) -> int:
126
+ """Schedule a function to run repeatedly. Returns timer ID."""
127
+ ...
128
+
129
+
130
+ def clearInterval(intervalId: int) -> None:
131
+ """Cancel an interval scheduled with setInterval."""
132
+ ...
133
+
134
+
135
+ # Animation
136
+ def requestAnimationFrame(callback: _Callable[[float], None]) -> int:
137
+ """Request a callback before the next repaint. Returns request ID."""
138
+ ...
139
+
140
+
141
+ def cancelAnimationFrame(requestId: int) -> None:
142
+ """Cancel an animation frame request."""
143
+ ...
144
+
145
+
146
+ # Event listeners
147
+ def addEventListener(
148
+ type: str,
149
+ listener: _Callable[..., None],
150
+ options: bool | dict[str, bool] | None = None,
151
+ ) -> None:
152
+ """Add an event listener to the window."""
153
+ ...
154
+
155
+
156
+ def removeEventListener(
157
+ type: str,
158
+ listener: _Callable[..., None],
159
+ options: bool | dict[str, bool] | None = None,
160
+ ) -> None:
161
+ """Remove an event listener from the window."""
162
+ ...
163
+
164
+
165
+ def dispatchEvent(event: _Any) -> bool:
166
+ """Dispatch an event to the window."""
167
+ ...
168
+
169
+
170
+ # Encoding
171
+ def atob(encoded: str) -> str:
172
+ """Decode a base64 encoded string."""
173
+ ...
174
+
175
+
176
+ def btoa(data: str) -> str:
177
+ """Encode a string as base64."""
178
+ ...
179
+
180
+
181
+ # Misc
182
+ def matchMedia(query: str) -> _Any:
183
+ """Return a MediaQueryList for the given media query."""
184
+ ...
185
+
186
+
187
+ def print_() -> None:
188
+ """Open the print dialog."""
189
+ ...
190
+
191
+
192
+ def postMessage(
193
+ message: _Any, targetOrigin: str, transfer: list[_Any] | None = None
194
+ ) -> None:
195
+ """Post a message to another window."""
196
+ ...
197
+
198
+
199
+ _register_js_module(name="window")
pulse/messages.py CHANGED
@@ -1,8 +1,7 @@
1
1
  from typing import Any, Literal, NotRequired, TypedDict
2
2
 
3
- from pulse.renderer import VDOMOperation
4
3
  from pulse.routing import RouteInfo
5
- from pulse.vdom import VDOM
4
+ from pulse.vdom import VDOM, VDOMOperation
6
5
 
7
6
 
8
7
  # ====================
@@ -14,7 +13,7 @@ class ServerInitMessage(TypedDict):
14
13
  vdom: VDOM
15
14
  callbacks: list[str]
16
15
  render_props: list[str]
17
- css_refs: list[str]
16
+ jsexpr_paths: list[str] # paths containing JS expressions
18
17
 
19
18
 
20
19
  class ServerUpdateMessage(TypedDict):
@@ -82,6 +81,15 @@ class ServerChannelResponseMessage(TypedDict):
82
81
  error: NotRequired[Any]
83
82
 
84
83
 
84
+ class ServerJsExecMessage(TypedDict):
85
+ """Execute JavaScript code on the client."""
86
+
87
+ type: Literal["js_exec"]
88
+ path: str
89
+ id: str
90
+ code: str
91
+
92
+
85
93
  # ====================
86
94
  # Client messages
87
95
  # ====================
@@ -136,6 +144,15 @@ class ClientChannelResponseMessage(TypedDict):
136
144
  error: NotRequired[Any]
137
145
 
138
146
 
147
+ class ClientJsResultMessage(TypedDict):
148
+ """Result of client-side JS execution."""
149
+
150
+ type: Literal["js_result"]
151
+ id: str
152
+ result: Any
153
+ error: str | None
154
+
155
+
139
156
  ServerChannelMessage = ServerChannelRequestMessage | ServerChannelResponseMessage
140
157
  ServerMessage = (
141
158
  ServerInitMessage
@@ -144,6 +161,7 @@ ServerMessage = (
144
161
  | ServerApiCallMessage
145
162
  | ServerNavigateToMessage
146
163
  | ServerChannelMessage
164
+ | ServerJsExecMessage
147
165
  )
148
166
 
149
167
 
@@ -153,6 +171,7 @@ ClientPulseMessage = (
153
171
  | ClientNavigateMessage
154
172
  | ClientUnmountMessage
155
173
  | ClientApiResultMessage
174
+ | ClientJsResultMessage
156
175
  )
157
176
  ClientChannelMessage = ClientChannelRequestMessage | ClientChannelResponseMessage
158
177
  ClientMessage = ClientPulseMessage | ClientChannelMessage
pulse/queries/client.py CHANGED
@@ -5,7 +5,7 @@ from typing import Any, TypeVar, overload
5
5
  from pulse.context import PulseContext
6
6
  from pulse.queries.common import ActionResult, QueryKey
7
7
  from pulse.queries.infinite_query import InfiniteQuery, Page
8
- from pulse.queries.query import Query
8
+ from pulse.queries.query import KeyedQuery
9
9
 
10
10
  T = TypeVar("T")
11
11
 
@@ -62,11 +62,11 @@ class QueryClient:
62
62
  # Query accessors
63
63
  # ─────────────────────────────────────────────────────────────────────────
64
64
 
65
- def get(self, key: QueryKey) -> Query[Any] | None:
65
+ def get(self, key: QueryKey):
66
66
  """Get an existing regular query by key, or None if not found."""
67
67
  return self._get_store().get(key)
68
68
 
69
- def get_infinite(self, key: QueryKey) -> InfiniteQuery[Any, Any] | None:
69
+ def get_infinite(self, key: QueryKey):
70
70
  """Get an existing infinite query by key, or None if not found."""
71
71
  return self._get_store().get_infinite(key)
72
72
 
@@ -75,7 +75,7 @@ class QueryClient:
75
75
  filter: QueryFilter | None = None,
76
76
  *,
77
77
  include_infinite: bool = True,
78
- ) -> list[Query[Any] | InfiniteQuery[Any, Any]]:
78
+ ) -> list[KeyedQuery[Any] | InfiniteQuery[Any, Any]]:
79
79
  """
80
80
  Get all queries matching the filter.
81
81
 
@@ -89,7 +89,7 @@ class QueryClient:
89
89
  """
90
90
  store = self._get_store()
91
91
  predicate = _normalize_filter(filter)
92
- results: list[Query[Any] | InfiniteQuery[Any, Any]] = []
92
+ results: list[KeyedQuery[Any] | InfiniteQuery[Any, Any]] = []
93
93
 
94
94
  for key, entry in store.items():
95
95
  if predicate is not None and not predicate(key):
@@ -100,11 +100,11 @@ class QueryClient:
100
100
 
101
101
  return results
102
102
 
103
- def get_queries(self, filter: QueryFilter | None = None) -> list[Query[Any]]:
103
+ def get_queries(self, filter: QueryFilter | None = None) -> list[KeyedQuery[Any]]:
104
104
  """Get all regular queries matching the filter."""
105
105
  store = self._get_store()
106
106
  predicate = _normalize_filter(filter)
107
- results: list[Query[Any]] = []
107
+ results: list[KeyedQuery[Any]] = []
108
108
 
109
109
  for key, entry in store.items():
110
110
  if isinstance(entry, InfiniteQuery):
pulse/queries/effect.py CHANGED
@@ -2,6 +2,7 @@ import asyncio
2
2
  from collections.abc import Awaitable, Callable
3
3
  from typing import (
4
4
  Any,
5
+ Literal,
5
6
  Protocol,
6
7
  override,
7
8
  )
@@ -11,15 +12,21 @@ from pulse.reactive import AsyncEffect, Computed, Signal
11
12
 
12
13
  class Fetcher(Protocol):
13
14
  is_fetching: Signal[bool]
15
+ data: Signal[Any]
16
+ status: Signal[Literal["loading", "success", "error"]]
14
17
 
15
18
 
16
19
  class AsyncQueryEffect(AsyncEffect):
17
20
  """
18
21
  Specialized AsyncEffect for queries that synchronously sets loading state
19
22
  when rescheduled/run.
23
+
24
+ For unkeyed queries (deps=None), also resets data/status when re-running
25
+ due to dependency changes, to behave like keyed queries on key change.
20
26
  """
21
27
 
22
28
  fetcher: Fetcher
29
+ _is_unkeyed: bool
23
30
 
24
31
  def __init__(
25
32
  self,
@@ -30,10 +37,19 @@ class AsyncQueryEffect(AsyncEffect):
30
37
  deps: list[Signal[Any] | Computed[Any]] | None = None,
31
38
  ):
32
39
  self.fetcher = fetcher
40
+ # Unkeyed queries have deps=None (auto-track), keyed have deps=[] (no auto-track)
41
+ self._is_unkeyed = deps is None
33
42
  super().__init__(fn, name=name, lazy=lazy, deps=deps)
34
43
 
35
44
  @override
36
45
  def run(self) -> asyncio.Task[Any]:
37
46
  # Immediately set loading state before running the effect
38
47
  self.fetcher.is_fetching.write(True)
48
+
49
+ # For unkeyed queries on re-run (dependency changed), reset data/status
50
+ # to behave like keyed queries when key changes (new Query with data=None)
51
+ if self._is_unkeyed and self.runs > 0:
52
+ self.fetcher.data.write(None)
53
+ self.fetcher.status.write("loading")
54
+
39
55
  return super().run()