pulse-framework 0.1.39__py3-none-any.whl → 0.1.41__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.
pulse/cli/cmd.py CHANGED
@@ -12,7 +12,7 @@ import subprocess
12
12
  import sys
13
13
  from collections.abc import Sequence
14
14
  from pathlib import Path
15
- from typing import cast
15
+ from typing import Callable, cast
16
16
 
17
17
  import typer
18
18
  from rich.console import Console
@@ -110,14 +110,14 @@ def run(
110
110
  port = find_available_port(port)
111
111
 
112
112
  console = Console()
113
- console.log(f"📁 Loading app from: {app_file}")
113
+ console.print(f"📁 Loading app from: {app_file}")
114
114
  app_ctx = load_app_from_target(app_file)
115
115
  _apply_app_context_to_env(app_ctx)
116
116
  app_instance = app_ctx.app
117
117
 
118
118
  is_single_server = app_instance.mode == "single-server"
119
119
  if is_single_server:
120
- console.log("🔧 [cyan]Single-server mode[/cyan]")
120
+ console.print("🔧 [cyan]Single-server mode[/cyan]")
121
121
 
122
122
  # In single-server + server-only mode, require explicit React server address
123
123
  if is_single_server and server_only:
@@ -168,41 +168,76 @@ def run(
168
168
  web_args = extra_flags if web_only else []
169
169
 
170
170
  commands: list[CommandSpec] = []
171
+
172
+ # Track readiness for announcement
173
+ server_ready = {"server": False, "web": False}
174
+ announced = False
175
+
176
+ def mark_web_ready() -> None:
177
+ server_ready["web"] = True
178
+ check_and_announce()
179
+
180
+ def mark_server_ready() -> None:
181
+ server_ready["server"] = True
182
+ check_and_announce()
183
+
184
+ def check_and_announce() -> None:
185
+ """Announce when all required servers are ready."""
186
+ nonlocal announced
187
+ if announced:
188
+ return
189
+
190
+ needs_server = not web_only
191
+ needs_web = not server_only
192
+
193
+ if needs_server and not server_ready["server"]:
194
+ return
195
+ if needs_web and not server_ready["web"]:
196
+ return
197
+
198
+ # All required servers are ready, show announcement
199
+ announced = True
200
+ protocol = "http" if address in ("127.0.0.1", "localhost") else "https"
201
+ server_url = f"{protocol}://{address}:{port}"
202
+ console.print("")
203
+ console.print(
204
+ f"✨ [bold green]Pulse running at:[/bold green] [bold cyan][link={server_url}]{server_url}[/link][/bold cyan]"
205
+ )
206
+ console.print(f" [dim]API: {server_url}/_pulse/...[/dim]")
207
+ console.print("")
208
+
171
209
  # Build web command first (when needed) so we can set PULSE_REACT_SERVER_ADDRESS
172
210
  # before building the uvicorn command, which needs that env var
173
211
  if not server_only:
174
- if is_single_server:
175
- web_port = find_available_port(5173)
176
- commands.append(
177
- build_web_command(
178
- web_root=web_root,
179
- extra_args=web_args,
180
- port=web_port,
181
- mode=app_instance.env,
182
- )
183
- )
184
- # Set env var so app can read the React server address
185
- react_server_address = f"http://localhost:{web_port}"
186
- os.environ[ENV_PULSE_REACT_SERVER_ADDRESS] = react_server_address
187
- else:
188
- commands.append(build_web_command(web_root=web_root, extra_args=web_args))
212
+ web_port = find_available_port(5173)
213
+ web_cmd = build_web_command(
214
+ web_root=web_root,
215
+ extra_args=web_args,
216
+ port=web_port,
217
+ mode=app_instance.env,
218
+ ready_pattern=r"localhost:\d+",
219
+ on_ready=mark_web_ready,
220
+ )
221
+ commands.append(web_cmd)
222
+ # Set env var so app can read the React server address (only used in single-server mode)
223
+ env.react_server_address = f"http://localhost:{web_port}"
189
224
 
190
225
  if not web_only:
191
- commands.append(
192
- build_uvicorn_command(
193
- app_ctx=app_ctx,
194
- address=address,
195
- port=port,
196
- reload_enabled=reload,
197
- extra_args=server_args,
198
- dev_secret=dev_secret,
199
- server_only=server_only,
200
- console=console,
201
- web_root=web_root,
202
- announce_url=is_single_server,
203
- verbose=verbose,
204
- )
226
+ server_cmd = build_uvicorn_command(
227
+ app_ctx=app_ctx,
228
+ address=address,
229
+ port=port,
230
+ reload_enabled=reload,
231
+ extra_args=server_args,
232
+ dev_secret=dev_secret,
233
+ server_only=server_only,
234
+ console=console,
235
+ web_root=web_root,
236
+ verbose=verbose,
237
+ ready_pattern=r"Application startup complete",
238
+ on_ready=mark_server_ready,
205
239
  )
240
+ commands.append(server_cmd)
206
241
 
207
242
  # Only add tags in dev mode to avoid breaking structured output (e.g., CloudWatch EMF metrics)
208
243
  tag_colors = (
@@ -340,8 +375,9 @@ def build_uvicorn_command(
340
375
  server_only: bool,
341
376
  console: Console,
342
377
  web_root: Path,
343
- announce_url: bool,
344
378
  verbose: bool = False,
379
+ ready_pattern: str | None = None,
380
+ on_ready: Callable[[], None] | None = None,
345
381
  ) -> CommandSpec:
346
382
  app_import = f"{app_ctx.module_name}:{app_ctx.app_var}.asgi_factory"
347
383
  args: list[str] = [
@@ -401,22 +437,13 @@ def build_uvicorn_command(
401
437
  log_config_file.write_text(json.dumps(log_config))
402
438
  args.extend(["--log-config", str(log_config_file)])
403
439
 
404
- def _announce() -> None:
405
- protocol = "http" if address in ("127.0.0.1", "localhost") else "https"
406
- server_url = f"{protocol}://{address}:{port}"
407
- console.log("")
408
- console.log(
409
- f"✨ [bold green]Pulse running at:[/bold green] [bold cyan][link={server_url}]{server_url}[/link][/bold cyan]"
410
- )
411
- console.log(f" [dim]API: {server_url}/_pulse/...[/dim]")
412
- console.log("")
413
-
414
440
  return CommandSpec(
415
441
  name="server",
416
442
  args=args,
417
443
  cwd=cwd,
418
444
  env=command_env,
419
- on_spawn=_announce if announce_url else None,
445
+ ready_pattern=ready_pattern,
446
+ on_ready=on_ready,
420
447
  )
421
448
 
422
449
 
@@ -426,6 +453,8 @@ def build_web_command(
426
453
  extra_args: Sequence[str],
427
454
  port: int | None = None,
428
455
  mode: PulseEnv = "dev",
456
+ ready_pattern: str | None = None,
457
+ on_ready: Callable[[], None] | None = None,
429
458
  ) -> CommandSpec:
430
459
  command_env = os.environ.copy()
431
460
  if mode == "prod":
@@ -453,7 +482,14 @@ def build_web_command(
453
482
  }
454
483
  )
455
484
 
456
- return CommandSpec(name="web", args=args, cwd=web_root, env=command_env)
485
+ return CommandSpec(
486
+ name="web",
487
+ args=args,
488
+ cwd=web_root,
489
+ env=command_env,
490
+ ready_pattern=ready_pattern,
491
+ on_ready=on_ready,
492
+ )
457
493
 
458
494
 
459
495
  def _apply_app_context_to_env(app_ctx: AppLoadResult) -> None:
pulse/cli/models.py CHANGED
@@ -31,3 +31,5 @@ class CommandSpec:
31
31
  cwd: Path
32
32
  env: dict[str, str]
33
33
  on_spawn: Callable[[], None] | None = None
34
+ ready_pattern: str | None = None # Regex pattern to detect when command is ready
35
+ on_ready: Callable[[], None] | None = None # Callback when ready_pattern matches
pulse/cli/processes.py CHANGED
@@ -3,25 +3,31 @@ from __future__ import annotations
3
3
  import contextlib
4
4
  import os
5
5
  import pty
6
+ import re
6
7
  import select
7
8
  import signal
8
9
  import subprocess
9
10
  import sys
10
11
  from collections.abc import Mapping, Sequence
11
12
  from io import TextIOBase
12
- from typing import cast
13
+ from typing import TypeVar, cast
13
14
 
14
15
  from rich.console import Console
15
16
 
16
17
  from pulse.cli.helpers import os_family
17
18
  from pulse.cli.models import CommandSpec
18
19
 
20
+ _K = TypeVar("_K", int, str)
21
+
19
22
  ANSI_CODES = {
20
23
  "cyan": "\033[36m",
21
24
  "orange1": "\033[38;5;208m",
22
25
  "default": "\033[90m",
23
26
  }
24
27
 
28
+ # Regex to strip ANSI escape codes
29
+ ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
30
+
25
31
 
26
32
  def execute_commands(
27
33
  commands: Sequence[CommandSpec],
@@ -45,6 +51,34 @@ def execute_commands(
45
51
  return _run_with_pty(commands, console=console, colors=color_lookup)
46
52
 
47
53
 
54
+ def _call_on_spawn(spec: CommandSpec) -> None:
55
+ """Call the on_spawn callback if it exists."""
56
+ if spec.on_spawn:
57
+ try:
58
+ spec.on_spawn()
59
+ except Exception:
60
+ pass
61
+
62
+
63
+ def _check_on_ready(
64
+ spec: CommandSpec,
65
+ line: str,
66
+ ready_flags: dict[_K, bool],
67
+ key: _K,
68
+ ) -> None:
69
+ """Check if line matches ready_pattern and call on_ready if needed."""
70
+ if spec.ready_pattern and not ready_flags[key]:
71
+ # Strip ANSI codes before matching
72
+ clean_line = ANSI_ESCAPE.sub("", line)
73
+ if re.search(spec.ready_pattern, clean_line):
74
+ ready_flags[key] = True
75
+ if spec.on_ready:
76
+ try:
77
+ spec.on_ready()
78
+ except Exception:
79
+ pass
80
+
81
+
48
82
  def _run_with_pty(
49
83
  commands: Sequence[CommandSpec],
50
84
  *,
@@ -52,8 +86,9 @@ def _run_with_pty(
52
86
  colors: Mapping[str, str],
53
87
  ) -> int:
54
88
  procs: list[tuple[str, int, int]] = []
55
- fd_to_name: dict[int, str] = {}
89
+ fd_to_spec: dict[int, CommandSpec] = {}
56
90
  buffers: dict[int, bytearray] = {}
91
+ ready_flags: dict[int, bool] = {}
57
92
 
58
93
  try:
59
94
  for spec in commands:
@@ -66,13 +101,10 @@ def _run_with_pty(
66
101
  fcntl = __import__("fcntl")
67
102
  fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
68
103
  procs.append((spec.name, pid, fd))
69
- fd_to_name[fd] = spec.name
104
+ fd_to_spec[fd] = spec
70
105
  buffers[fd] = bytearray()
71
- if spec.on_spawn:
72
- try:
73
- spec.on_spawn()
74
- except Exception:
75
- pass
106
+ ready_flags[fd] = False
107
+ _call_on_spawn(spec)
76
108
 
77
109
  while procs:
78
110
  for tag, pid, fd in list(procs):
@@ -105,7 +137,9 @@ def _run_with_pty(
105
137
  buffers[fd] = remainder
106
138
  decoded = line.decode(errors="replace")
107
139
  if decoded:
108
- _write_tagged_line(fd_to_name[fd], decoded, colors)
140
+ spec = fd_to_spec[fd]
141
+ _write_tagged_line(spec.name, decoded, colors)
142
+ _check_on_ready(spec, decoded, ready_flags, fd)
109
143
  except OSError:
110
144
  continue
111
145
 
@@ -144,9 +178,10 @@ def _run_without_pty(
144
178
  ) -> int:
145
179
  from selectors import EVENT_READ, DefaultSelector
146
180
 
147
- procs: list[tuple[str, subprocess.Popen[str]]] = []
181
+ procs: list[tuple[str, subprocess.Popen[str], CommandSpec]] = []
148
182
  completed_codes: list[int] = []
149
183
  selector = DefaultSelector()
184
+ ready_flags: dict[str, bool] = {}
150
185
 
151
186
  try:
152
187
  for spec in commands:
@@ -160,14 +195,11 @@ def _run_without_pty(
160
195
  bufsize=1,
161
196
  universal_newlines=True,
162
197
  )
163
- if spec.on_spawn:
164
- try:
165
- spec.on_spawn()
166
- except Exception:
167
- pass
198
+ _call_on_spawn(spec)
168
199
  if proc.stdout:
169
200
  selector.register(proc.stdout, EVENT_READ, data=spec.name)
170
- procs.append((spec.name, proc))
201
+ ready_flags[spec.name] = False
202
+ procs.append((spec.name, proc, spec))
171
203
 
172
204
  while procs:
173
205
  events = selector.select(timeout=0.1)
@@ -180,13 +212,16 @@ def _run_without_pty(
180
212
  line = cast(TextIOBase, stream).readline()
181
213
  if line:
182
214
  _write_tagged_line(name, line.rstrip("\n"), colors)
215
+ spec = next((s for n, _, s in procs if n == name), None)
216
+ if spec:
217
+ _check_on_ready(spec, line, ready_flags, name)
183
218
  else:
184
219
  selector.unregister(stream)
185
- remaining: list[tuple[str, subprocess.Popen[str]]] = []
186
- for name, proc in procs:
220
+ remaining: list[tuple[str, subprocess.Popen[str], CommandSpec]] = []
221
+ for name, proc, spec in procs:
187
222
  code = proc.poll()
188
223
  if code is None:
189
- remaining.append((name, proc))
224
+ remaining.append((name, proc, spec))
190
225
  else:
191
226
  completed_codes.append(code)
192
227
  if proc.stdout:
@@ -195,12 +230,12 @@ def _run_without_pty(
195
230
  proc.stdout.close()
196
231
  procs = remaining
197
232
  except KeyboardInterrupt:
198
- for _name, proc in procs:
233
+ for _name, proc, _spec in procs:
199
234
  with contextlib.suppress(Exception):
200
235
  proc.terminate()
201
236
  return 130
202
237
  finally:
203
- for _name, proc in procs:
238
+ for _name, proc, _spec in procs:
204
239
  with contextlib.suppress(Exception):
205
240
  proc.terminate()
206
241
  with contextlib.suppress(Exception):
@@ -210,11 +245,21 @@ def _run_without_pty(
210
245
  selector.unregister(key.fileobj)
211
246
  selector.close()
212
247
 
213
- exit_codes = completed_codes + [proc.returncode or 0 for _name, proc in procs]
248
+ exit_codes = completed_codes + [
249
+ proc.returncode or 0 for _name, proc, _spec in procs
250
+ ]
214
251
  return max(exit_codes) if exit_codes else 0
215
252
 
216
253
 
217
254
  def _write_tagged_line(name: str, message: str, colors: Mapping[str, str]) -> None:
255
+ # Filter out unwanted web server messages
256
+ clean_message = ANSI_ESCAPE.sub("", message)
257
+ if (
258
+ "Network: use --host to expose" in clean_message
259
+ or "press h + enter to show help" in clean_message
260
+ ):
261
+ return
262
+
218
263
  # Only add tags if colors dict is not empty (i.e., tagging is enabled)
219
264
  if colors:
220
265
  color = ANSI_CODES.get(colors.get(name, ""), ANSI_CODES["default"])
@@ -54,7 +54,7 @@ def get_log_config(default_level: str = "info") -> dict[str, Any]:
54
54
  "formatters": {
55
55
  "default": {
56
56
  "()": "uvicorn.logging.DefaultFormatter",
57
- "fmt": "%(levelprefix)s %(message)s",
57
+ "fmt": "%(message)s",
58
58
  "use_colors": None,
59
59
  },
60
60
  "access": {
pulse/codegen/codegen.py CHANGED
@@ -3,6 +3,7 @@ import os
3
3
  from collections.abc import Sequence
4
4
  from dataclasses import dataclass
5
5
  from pathlib import Path
6
+ from typing import TYPE_CHECKING
6
7
 
7
8
  from pulse.cli.helpers import ensure_gitignore_has
8
9
  from pulse.codegen.templates.layout import LAYOUT_TEMPLATE
@@ -16,6 +17,9 @@ from pulse.css import CssImport, CssModule
16
17
  from pulse.env import env
17
18
  from pulse.routing import Layout, Route, RouteTree
18
19
 
20
+ if TYPE_CHECKING:
21
+ from pulse.app import ConnectionStatusConfig
22
+
19
23
  logger = logging.getLogger(__file__)
20
24
 
21
25
 
@@ -112,6 +116,7 @@ class Codegen:
112
116
  server_address: str,
113
117
  internal_server_address: str | None = None,
114
118
  api_prefix: str = "",
119
+ connection_status: "ConnectionStatusConfig | None" = None,
115
120
  ):
116
121
  # Ensure generated files are gitignored
117
122
  ensure_gitignore_has(self.cfg.web_root, f"app/{self.cfg.pulse_dir}/")
@@ -124,7 +129,10 @@ class Codegen:
124
129
  generated_files = set(
125
130
  [
126
131
  self.generate_layout_tsx(
127
- server_address, internal_server_address, api_prefix
132
+ server_address,
133
+ internal_server_address,
134
+ api_prefix,
135
+ connection_status,
128
136
  ),
129
137
  self.generate_routes_ts(),
130
138
  self.generate_routes_runtime_ts(),
@@ -150,13 +158,18 @@ class Codegen:
150
158
  server_address: str,
151
159
  internal_server_address: str | None = None,
152
160
  api_prefix: str = "",
161
+ connection_status: "ConnectionStatusConfig | None" = None,
153
162
  ):
154
163
  """Generates the content of _layout.tsx"""
164
+ from pulse.app import ConnectionStatusConfig
165
+
166
+ connection_status = connection_status or ConnectionStatusConfig()
155
167
  content = str(
156
168
  LAYOUT_TEMPLATE.render_unicode(
157
169
  server_address=server_address,
158
170
  internal_server_address=internal_server_address or server_address,
159
171
  api_prefix=api_prefix,
172
+ connection_status=connection_status,
160
173
  )
161
174
  )
162
175
  # The underscore avoids an eventual naming conflict with a generated
@@ -11,6 +11,11 @@ import { useLoaderData } from "react-router";
11
11
  export const config: PulseConfig = {
12
12
  serverAddress: "${server_address}",
13
13
  apiPrefix: "${api_prefix}",
14
+ connectionStatus: {
15
+ initialConnectingDelay: ${int(connection_status.initial_connecting_delay * 1000)},
16
+ initialErrorDelay: ${int(connection_status.initial_error_delay * 1000)},
17
+ reconnectErrorDelay: ${int(connection_status.reconnect_error_delay * 1000)},
18
+ },
14
19
  };
15
20
 
16
21
 
@@ -35,7 +40,10 @@ export async function loader(args: LoaderFunctionArgs) {
35
40
  if (!res.ok) throw new Error("Failed to prerender batch:" + res.status);
36
41
  const body = await res.json();
37
42
  if (body.redirect) return new Response(null, { status: 302, headers: { Location: body.redirect } });
38
- if (body.notFound) return new Response(null, { status: 404 });
43
+ if (body.notFound) {
44
+ console.error("Not found:", url.pathname);
45
+ throw new Response("Not Found", { status: 404 });
46
+ }
39
47
  const prerenderData = deserialize(body) as PulsePrerender;
40
48
  const setCookies =
41
49
  (res.headers.getSetCookie?.() as string[] | undefined) ??
@@ -73,7 +81,7 @@ export async function clientLoader(args: ClientLoaderFunctionArgs) {
73
81
  if (!res.ok) throw new Error("Failed to prerender batch:" + res.status);
74
82
  const body = await res.json();
75
83
  if (body.redirect) return new Response(null, { status: 302, headers: { Location: body.redirect } });
76
- if (body.notFound) return new Response(null, { status: 404 });
84
+ if (body.notFound) throw new Response("Not Found", { status: 404 });
77
85
  const prerenderData = deserialize(body) as PulsePrerender;
78
86
  if (typeof window !== "undefined" && typeof sessionStorage !== "undefined" && prerenderData.directives) {
79
87
  sessionStorage.setItem("__PULSE_DIRECTIVES", JSON.stringify(prerenderData.directives));