pulse-framework 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.
Files changed (50) hide show
  1. pulse/__init__.py +175 -0
  2. pulse/app.py +349 -0
  3. pulse/cmd.py +324 -0
  4. pulse/codegen.py +147 -0
  5. pulse/components/__init__.py +1 -0
  6. pulse/components/react_router.py +43 -0
  7. pulse/context.py +15 -0
  8. pulse/decorators.py +187 -0
  9. pulse/diff.py +252 -0
  10. pulse/flags.py +5 -0
  11. pulse/flatted.py +159 -0
  12. pulse/helpers.py +27 -0
  13. pulse/hooks.py +441 -0
  14. pulse/html/__init__.py +304 -0
  15. pulse/html/attributes.py +930 -0
  16. pulse/html/elements.py +1024 -0
  17. pulse/html/events.py +419 -0
  18. pulse/html/tags.py +171 -0
  19. pulse/html/tags.pyi +390 -0
  20. pulse/messages.py +109 -0
  21. pulse/middleware.py +158 -0
  22. pulse/query.py +286 -0
  23. pulse/react_component.py +803 -0
  24. pulse/reactive.py +514 -0
  25. pulse/reactive_extensions.py +626 -0
  26. pulse/reconciler.py +575 -0
  27. pulse/request.py +162 -0
  28. pulse/routing.py +350 -0
  29. pulse/session.py +310 -0
  30. pulse/state.py +309 -0
  31. pulse/templates.py +171 -0
  32. pulse/tests/__init__.py +0 -0
  33. pulse/tests/old_test_diff.py +174 -0
  34. pulse/tests/test_codegen.py +224 -0
  35. pulse/tests/test_flatted.py +297 -0
  36. pulse/tests/test_nodes.py +439 -0
  37. pulse/tests/test_query.py +391 -0
  38. pulse/tests/test_react.py +797 -0
  39. pulse/tests/test_reactive.py +1203 -0
  40. pulse/tests/test_reconciler.py +1759 -0
  41. pulse/tests/test_routing.py +167 -0
  42. pulse/tests/test_session.py +267 -0
  43. pulse/tests/test_state.py +569 -0
  44. pulse/tests/test_utils.py +101 -0
  45. pulse/vdom.py +381 -0
  46. pulse_framework-0.1.0.dist-info/METADATA +38 -0
  47. pulse_framework-0.1.0.dist-info/RECORD +50 -0
  48. pulse_framework-0.1.0.dist-info/WHEEL +4 -0
  49. pulse_framework-0.1.0.dist-info/entry_points.txt +2 -0
  50. pulse_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
pulse/cmd.py ADDED
@@ -0,0 +1,324 @@
1
+ """
2
+ Command-line interface for Pulse UI.
3
+ This module provides the CLI commands for running the server and generating routes.
4
+ """
5
+
6
+ import asyncio
7
+ import importlib.util
8
+ import os
9
+ import pty
10
+ import socket
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ import typer
15
+ from rich.console import Console
16
+
17
+ from pulse.app import App
18
+ # from pulse.routing import clear_routes
19
+
20
+ from textual.app import App as TextualApp, ComposeResult
21
+ from textual.containers import Container
22
+ from textual.widgets import RichLog
23
+ from rich.text import Text
24
+
25
+ cli = typer.Typer(
26
+ name="pulse",
27
+ help="Pulse UI - Python to TypeScript bridge with server-side callbacks",
28
+ no_args_is_help=True,
29
+ )
30
+
31
+
32
+ def find_available_port(start_port: int = 8000, max_attempts: int = 100) -> int:
33
+ """Find an available port starting from start_port."""
34
+ for port in range(start_port, start_port + max_attempts):
35
+ try:
36
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
37
+ s.bind(("localhost", port))
38
+ return port
39
+ except OSError:
40
+ continue
41
+ raise RuntimeError(
42
+ f"Could not find an available port after {max_attempts} attempts starting from {start_port}"
43
+ )
44
+
45
+
46
+ def load_app_from_file(file_path: str | Path) -> App:
47
+ """Load routes from a Python file (supports both App instances and global @ps.route decorators)."""
48
+ file_path = Path(file_path)
49
+
50
+ if not file_path.exists():
51
+ typer.echo(f"❌ File not found: {file_path}")
52
+ raise typer.Exit(1)
53
+
54
+ if not file_path.suffix == ".py":
55
+ typer.echo(f"❌ File must be a Python file (.py): {file_path}")
56
+ raise typer.Exit(1)
57
+
58
+ # clear_routes()
59
+ sys.path.insert(0, str(file_path.parent.absolute()))
60
+
61
+ try:
62
+ spec = importlib.util.spec_from_file_location("user_app", file_path)
63
+ if spec is None or spec.loader is None:
64
+ typer.echo(f"❌ Could not load module from: {file_path}")
65
+ raise typer.Exit(1)
66
+
67
+ module = importlib.util.module_from_spec(spec)
68
+ spec.loader.exec_module(module)
69
+
70
+ if hasattr(module, "app") and isinstance(module.app, App):
71
+ app_instance = module.app
72
+ if not app_instance.routes:
73
+ typer.echo(f"⚠️ No routes found in {file_path}")
74
+ return app_instance
75
+
76
+ typer.echo(f"⚠️ No app found in {file_path}")
77
+ raise typer.Exit(1)
78
+
79
+ except Exception as e:
80
+ typer.echo(f"❌ Error loading {file_path}: {e}")
81
+ raise typer.Exit(1)
82
+ finally:
83
+ if str(file_path.parent.absolute()) in sys.path:
84
+ sys.path.remove(str(file_path.parent.absolute()))
85
+
86
+
87
+ class Terminal(RichLog):
88
+ """A widget that runs a command in a pseudo-terminal."""
89
+
90
+ def __init__(self, command, cwd, env=None, **kwargs):
91
+ super().__init__(highlight=True, markup=True, wrap=False, **kwargs)
92
+ self.command = command
93
+ self.cwd = cwd
94
+ self.env = env
95
+ self.pid = None
96
+ self.fd = None
97
+
98
+ async def on_mount(self) -> None:
99
+ """Start the command when the widget is mounted."""
100
+ self.pid, self.fd = pty.fork()
101
+
102
+ if self.pid == 0: # Child process
103
+ os.chdir(self.cwd)
104
+ env = os.environ.copy()
105
+ if self.env:
106
+ env.update(self.env)
107
+ os.execvpe(self.command[0], self.command, env)
108
+ else: # Parent process
109
+ loop = asyncio.get_running_loop()
110
+ loop.add_reader(self.fd, self.read_from_pty)
111
+
112
+ def read_from_pty(self) -> None:
113
+ """Read from the PTY and update the widget."""
114
+ if self.fd is None:
115
+ return
116
+ try:
117
+ data = os.read(self.fd, 1024)
118
+ if not data:
119
+ self.update_log_with_exit_message()
120
+ return
121
+ self.write(Text.from_ansi(data.decode(errors="replace")))
122
+ except OSError:
123
+ self.update_log_with_exit_message()
124
+
125
+ def update_log_with_exit_message(self):
126
+ if self.fd:
127
+ asyncio.get_running_loop().remove_reader(self.fd)
128
+ os.close(self.fd)
129
+ self.fd = None
130
+ self.write("\n\n[b red]PROCESS EXITED[/b red]")
131
+ self.border_style = "red"
132
+
133
+ async def on_key(self, event) -> None:
134
+ if self.fd:
135
+ if event.key == "ctrl+c":
136
+ os.write(self.fd, b"\x03")
137
+ else:
138
+ os.write(self.fd, event.key.encode())
139
+
140
+ def on_unmount(self) -> None:
141
+ """Ensure the process is terminated on unmount."""
142
+ if self.pid:
143
+ try:
144
+ os.kill(self.pid, 9)
145
+ except ProcessLookupError:
146
+ pass
147
+
148
+
149
+ class PulseTerminalViewer(TextualApp):
150
+ """A Textual app to view Pulse server logs in interactive terminals."""
151
+
152
+ CSS = """
153
+ Screen {
154
+ background: transparent;
155
+ }
156
+ #main_container {
157
+ layout: horizontal;
158
+ background: transparent;
159
+ }
160
+ Terminal {
161
+ width: 1fr;
162
+ height: 100%;
163
+ margin: 0 1;
164
+ scrollbar-size: 1 1;
165
+ }
166
+ Terminal:focus {
167
+ border: round white;
168
+ }
169
+ #server_term {
170
+ border: round cyan;
171
+ }
172
+ #web_term {
173
+ border: round orange;
174
+ }
175
+ """
176
+
177
+ BINDINGS = [("q", "quit", "Quit")]
178
+
179
+ def __init__(
180
+ self,
181
+ server_command=None,
182
+ server_cwd=None,
183
+ server_env=None,
184
+ web_command=None,
185
+ web_cwd=None,
186
+ web_env=None,
187
+ **kwargs,
188
+ ):
189
+ super().__init__(**kwargs)
190
+ self.server_command = server_command
191
+ self.server_cwd = server_cwd
192
+ self.server_env = server_env
193
+ self.web_command = web_command
194
+ self.web_cwd = web_cwd
195
+ self.web_env = web_env
196
+
197
+ def compose(self) -> ComposeResult:
198
+ with Container(id="main_container"):
199
+ if self.server_command:
200
+ server_term = Terminal(
201
+ self.server_command,
202
+ self.server_cwd,
203
+ self.server_env,
204
+ id="server_term",
205
+ )
206
+ server_term.border_title = "🐍 Python Server"
207
+ yield server_term
208
+
209
+ if self.web_command:
210
+ web_term = Terminal(
211
+ self.web_command, self.web_cwd, self.web_env, id="web_term"
212
+ )
213
+ web_term.border_title = "🌐 Web Server"
214
+ yield web_term
215
+
216
+
217
+ @cli.command("run")
218
+ def run(
219
+ app_file: str = typer.Argument(..., help="Python file with a pulse.App instance"),
220
+ address: str = typer.Option("localhost", "--address"),
221
+ port: int = typer.Option(8000, "--port"),
222
+ server_only: bool = typer.Option(False, "--server-only"),
223
+ web_only: bool = typer.Option(False, "--web-only"),
224
+ no_reload: bool = typer.Option(False, "--no-reload"),
225
+ find_port: bool = typer.Option(True, "--find-port/--no-find-port"),
226
+ ):
227
+ """Run the Pulse server and web development server together."""
228
+ if server_only and web_only:
229
+ typer.echo("❌ Cannot use --server-only and --web-only at the same time.")
230
+ raise typer.Exit(1)
231
+
232
+ if find_port:
233
+ port = find_available_port(port)
234
+
235
+ console = Console()
236
+ console.log(f"📁 Loading app from: {app_file}")
237
+ app_instance = load_app_from_file(app_file)
238
+
239
+ web_dir = Path(app_instance.codegen.cfg.web_dir)
240
+ if not web_dir.exists() and not server_only:
241
+ console.log(f"❌ Directory not found: {web_dir}")
242
+ raise typer.Exit(1)
243
+
244
+ server_command, server_cwd, server_env = None, None, None
245
+ web_command, web_cwd, web_env = None, None, None
246
+
247
+ if not web_only:
248
+ module_name = Path(app_file).stem
249
+ app_import_string = f"{module_name}:app.asgi_factory"
250
+ server_command = [
251
+ sys.executable,
252
+ "-m",
253
+ "uvicorn",
254
+ app_import_string,
255
+ "--host",
256
+ address,
257
+ "--port",
258
+ str(port),
259
+ "--factory",
260
+ ]
261
+ if not no_reload:
262
+ server_command.append("--reload")
263
+
264
+ server_cwd = Path(app_file).parent
265
+ server_env = os.environ.copy()
266
+ server_env.update(
267
+ {
268
+ "PULSE_APP_FILE": app_file,
269
+ "PULSE_HOST": address,
270
+ "PULSE_PORT": str(port),
271
+ "PYTHONUNBUFFERED": "1",
272
+ "FORCE_COLOR": "1",
273
+ }
274
+ )
275
+
276
+ if not server_only:
277
+ web_command = ["bun", "run", "dev"]
278
+ web_cwd = web_dir
279
+ web_env = os.environ.copy()
280
+ web_env.update({"FORCE_COLOR": "1"})
281
+
282
+ app = PulseTerminalViewer(
283
+ server_command=server_command,
284
+ server_cwd=server_cwd,
285
+ server_env=server_env,
286
+ web_command=web_command,
287
+ web_cwd=web_cwd,
288
+ web_env=web_env,
289
+ )
290
+ app.run()
291
+
292
+
293
+ @cli.command("generate")
294
+ def generate(
295
+ app_file: str = typer.Argument(..., help="Path to your Python file with routes"),
296
+ ):
297
+ """Generate TypeScript routes without starting the server."""
298
+ console = Console()
299
+ console.log("🔄 Generating TypeScript routes...")
300
+
301
+ console.log(f"📁 Loading routes from: {app_file}")
302
+ app = load_app_from_file(app_file)
303
+ console.log(f"📋 Found {len(app.routes.flat_tree)} routes")
304
+
305
+ app.run_codegen("127.0.0.1:8000")
306
+
307
+ if len(app.routes.flat_tree) > 0:
308
+ console.log(f"✅ Generated {len(app.routes.flat_tree)} routes successfully!")
309
+ else:
310
+ console.log("⚠️ No routes found to generate")
311
+
312
+
313
+ def main():
314
+ """Main CLI entry point."""
315
+ try:
316
+ cli()
317
+ except Exception as e:
318
+ console = Console()
319
+ console.log(f"❌ Error: {e}")
320
+ raise typer.Exit(1)
321
+
322
+
323
+ if __name__ == "__main__":
324
+ main()
pulse/codegen.py ADDED
@@ -0,0 +1,147 @@
1
+ import logging
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+
5
+ from pulse.templates import LAYOUT_TEMPLATE, ROUTE_TEMPLATE, ROUTES_CONFIG_TEMPLATE
6
+
7
+ from .routing import Layout, Route, RouteTree
8
+
9
+ logger = logging.getLogger(__file__)
10
+
11
+
12
+ @dataclass
13
+ class CodegenConfig:
14
+ """
15
+ Configuration for code generation.
16
+
17
+ Attributes:
18
+ web_dir (str): Root directory for the web output.
19
+ pulse_dir (str): Name of the Pulse app directory.
20
+ lib_path (str): Path to the Pulse library.
21
+ pulse_path (Path): Full path to the generated app directory.
22
+ """
23
+
24
+ web_dir: Path | str = "pulse-web"
25
+ """Root directory for the web output."""
26
+
27
+ pulse_dir: Path | str = "pulse"
28
+ """Name of the Pulse app directory."""
29
+
30
+ lib_path: Path | str = "pulse-ui-client"
31
+ """Path to the Pulse library."""
32
+
33
+ @property
34
+ def pulse_path(self) -> Path:
35
+ """Full path to the generated app directory."""
36
+ return Path(self.web_dir) / "app" / self.pulse_dir
37
+
38
+
39
+ def write_file_if_changed(path: Path, content: str) -> Path:
40
+ """Write content to file only if it has changed."""
41
+ if path.exists():
42
+ try:
43
+ current_content = path.read_text()
44
+ if current_content == content:
45
+ return path # Skip writing, content is the same
46
+ except Exception:
47
+ # If we can't read the file for any reason, just write it
48
+ pass
49
+
50
+ path.parent.mkdir(exist_ok=True, parents=True)
51
+ path.write_text(content)
52
+ return path
53
+
54
+
55
+ class Codegen:
56
+ def __init__(self, routes: RouteTree, config: CodegenConfig) -> None:
57
+ self.cfg = config
58
+ self.routes = routes
59
+
60
+ @property
61
+ def output_folder(self):
62
+ return self.cfg.pulse_path
63
+
64
+ def generate_all(self, server_address: str):
65
+ # Keep track of all generated files
66
+ generated_files = [
67
+ self.generate_layout_tsx(server_address),
68
+ self.generate_routes_ts(),
69
+ *(
70
+ self.generate_route(route, server_address=server_address)
71
+ for route in self.routes.flat_tree.values()
72
+ ),
73
+ ]
74
+ generated_files = set(generated_files)
75
+
76
+ # Clean up any remaining files that are not part of the generated files
77
+ for path in self.output_folder.rglob("*"):
78
+ if path.is_file() and path not in generated_files:
79
+ try:
80
+ path.unlink()
81
+ logger.debug(f"Removed stale file: {path}")
82
+ except Exception as e:
83
+ logger.warning(f"Could not remove stale file {path}: {e}")
84
+
85
+ def generate_layout_tsx(self, server_address: str):
86
+ """Generates the content of _layout.tsx"""
87
+ content = str(
88
+ LAYOUT_TEMPLATE.render_unicode(
89
+ server_address=server_address, lib_path=self.cfg.lib_path
90
+ )
91
+ )
92
+ # The underscore avoids an eventual naming conflict with a generated
93
+ # /layout route.
94
+ return write_file_if_changed(self.output_folder / "_layout.tsx", content)
95
+
96
+ def generate_routes_ts(self):
97
+ """Generate TypeScript code for the routes configuration."""
98
+ routes_str = self._render_routes_ts(self.routes.tree, 2)
99
+ content = str(
100
+ ROUTES_CONFIG_TEMPLATE.render_unicode(
101
+ routes_str=routes_str,
102
+ pulse_dir=self.cfg.pulse_dir,
103
+ )
104
+ )
105
+ return write_file_if_changed(self.output_folder / "routes.ts", content)
106
+
107
+ def _render_routes_ts(self, routes: list[Route | Layout], indent_level: int) -> str:
108
+ lines = []
109
+ indent_str = " " * indent_level
110
+ for route in routes:
111
+ if isinstance(route, Layout):
112
+ children_str = ""
113
+ if route.children:
114
+ children_str = f"\n{self._render_routes_ts(route.children, indent_level + 1)}\n{indent_str}"
115
+ lines.append(
116
+ f'{indent_str}layout("{self.cfg.pulse_dir}/layouts/{route.file_path()}", [{children_str}]),'
117
+ )
118
+ else:
119
+ if route.children:
120
+ children_str = f"\n{self._render_routes_ts(route.children, indent_level + 1)}\n{indent_str}"
121
+ lines.append(
122
+ f'{indent_str}route("{route.path}", "{self.cfg.pulse_dir}/routes/{route.file_path()}", [{children_str}]),'
123
+ )
124
+ elif route.is_index:
125
+ lines.append(
126
+ f'{indent_str}index("{self.cfg.pulse_dir}/routes/{route.file_path()}"),'
127
+ )
128
+ else:
129
+ lines.append(
130
+ f'{indent_str}route("{route.path}", "{self.cfg.pulse_dir}/routes/{route.file_path()}"),'
131
+ )
132
+ return "\n".join(lines)
133
+
134
+ def generate_route(self, route: Route | Layout, server_address: str):
135
+ if isinstance(route, Layout):
136
+ output_path = self.output_folder / "layouts" / route.file_path()
137
+ else:
138
+ output_path = self.output_folder / "routes" / route.file_path()
139
+ content = str(
140
+ ROUTE_TEMPLATE.render_unicode(
141
+ route=route,
142
+ components=route.components or [],
143
+ lib_path=self.cfg.lib_path,
144
+ server_address=server_address,
145
+ )
146
+ )
147
+ return write_file_if_changed(output_path, content)
@@ -0,0 +1 @@
1
+ from .react_router import Link, Outlet
@@ -0,0 +1,43 @@
1
+ from typing import Literal, Optional, TypedDict, Unpack
2
+ from pulse.html.attributes import HTMLAnchorProps
3
+ from pulse.vdom import Child
4
+ from ..react_component import DEFAULT, react_component
5
+
6
+
7
+ class LinkPath(TypedDict):
8
+ pathname: str
9
+ search: str
10
+ hash: str
11
+
12
+
13
+ @react_component("Link", "react-router")
14
+ def Link(
15
+ *children: Child,
16
+ key: Optional[str] = None,
17
+ to: str,
18
+ # Default: render
19
+ discover: Literal["render", "none"] = DEFAULT,
20
+ # The React Router default is 'none' to match the behavior of regular links,
21
+ # but 'intent' is more desirable in general
22
+ prefetch: Literal["none", "intent", "render", "viewport"] = "intent",
23
+ # Default: False
24
+ preventScrollReset: bool = DEFAULT,
25
+ # Default: 'route'
26
+ relative: Literal["route", "path"] = DEFAULT,
27
+ # Default: False
28
+ reloadDocument: bool = DEFAULT,
29
+ # Default: False
30
+ replace: bool = DEFAULT,
31
+ # Default: undefined
32
+ state: dict = DEFAULT,
33
+ # Default: False
34
+ viewTransition=DEFAULT,
35
+ **props: Unpack[HTMLAnchorProps],
36
+ ): ...
37
+
38
+
39
+ @react_component("Outlet", "react-router")
40
+ def Outlet(key: Optional[str] = None): ...
41
+
42
+
43
+ __all__ = ["Link", "Outlet"]
pulse/context.py ADDED
@@ -0,0 +1,15 @@
1
+ # This is more for documentation than actually importing from here
2
+ from .reactive import REACTIVE_CONTEXT
3
+ from .routing import ROUTE_CONTEXT
4
+ # NOTE: SessionContext objecst set both the SESSION_CONTEXT and REACTIVE_CONTEXT
5
+ from .session import SESSION_CONTEXT
6
+ from .hooks import HOOK_CONTEXT
7
+ from .react_component import COMPONENT_REGISTRY
8
+
9
+ __all__ = [
10
+ "REACTIVE_CONTEXT",
11
+ "ROUTE_CONTEXT",
12
+ "SESSION_CONTEXT",
13
+ "HOOK_CONTEXT",
14
+ "COMPONENT_REGISTRY",
15
+ ]