pulse-framework 0.1.37__py3-none-any.whl → 0.1.38a2__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/processes.py ADDED
@@ -0,0 +1,225 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import os
5
+ import pty
6
+ import select
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ from collections.abc import Mapping, Sequence
11
+ from io import TextIOBase
12
+ from typing import cast
13
+
14
+ from rich.console import Console
15
+
16
+ from pulse.cli.helpers import os_family
17
+ from pulse.cli.models import CommandSpec
18
+
19
+ ANSI_CODES = {
20
+ "cyan": "\033[36m",
21
+ "orange1": "\033[38;5;208m",
22
+ "default": "\033[90m",
23
+ }
24
+
25
+
26
+ def execute_commands(
27
+ commands: Sequence[CommandSpec],
28
+ *,
29
+ console: Console,
30
+ tag_colors: Mapping[str, str] | None = None,
31
+ ) -> int:
32
+ """Run the provided commands, streaming tagged output to stdout."""
33
+ if not commands:
34
+ return 0
35
+
36
+ color_lookup = dict(tag_colors or {})
37
+
38
+ # Avoid pty.fork() in multi-threaded environments (like pytest) to prevent
39
+ # "DeprecationWarning: This process is multi-threaded, use of forkpty() may lead to deadlocks"
40
+ # Also skip pty on Windows or if fork is unavailable
41
+ in_pytest = "pytest" in sys.modules
42
+ if os_family() == "windows" or not hasattr(pty, "fork") or in_pytest:
43
+ return _run_without_pty(commands, console=console, colors=color_lookup)
44
+
45
+ return _run_with_pty(commands, console=console, colors=color_lookup)
46
+
47
+
48
+ def _run_with_pty(
49
+ commands: Sequence[CommandSpec],
50
+ *,
51
+ console: Console,
52
+ colors: Mapping[str, str],
53
+ ) -> int:
54
+ procs: list[tuple[str, int, int]] = []
55
+ fd_to_name: dict[int, str] = {}
56
+ buffers: dict[int, bytearray] = {}
57
+
58
+ try:
59
+ for spec in commands:
60
+ pid, fd = pty.fork()
61
+ if pid == 0:
62
+ if spec.cwd:
63
+ os.chdir(spec.cwd)
64
+ os.execvpe(spec.args[0], spec.args, spec.env)
65
+ else:
66
+ fcntl = __import__("fcntl")
67
+ fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
68
+ procs.append((spec.name, pid, fd))
69
+ fd_to_name[fd] = spec.name
70
+ buffers[fd] = bytearray()
71
+ if spec.on_spawn:
72
+ try:
73
+ spec.on_spawn()
74
+ except Exception:
75
+ pass
76
+
77
+ while procs:
78
+ for tag, pid, fd in list(procs):
79
+ try:
80
+ wpid, status = os.waitpid(pid, os.WNOHANG)
81
+ if wpid == pid:
82
+ procs.remove((tag, pid, fd))
83
+ _close_fd(fd)
84
+ except ChildProcessError:
85
+ procs.remove((tag, pid, fd))
86
+ _close_fd(fd)
87
+
88
+ if not procs:
89
+ break
90
+
91
+ readable = [fd for _, _, fd in procs]
92
+ try:
93
+ ready, _, _ = select.select(readable, [], [], 0.1)
94
+ except (OSError, ValueError):
95
+ break
96
+
97
+ for fd in ready:
98
+ try:
99
+ data = os.read(fd, 4096)
100
+ if not data:
101
+ continue
102
+ buffers[fd].extend(data)
103
+ while b"\n" in buffers[fd]:
104
+ line, remainder = buffers[fd].split(b"\n", 1)
105
+ buffers[fd] = remainder
106
+ decoded = line.decode(errors="replace")
107
+ if decoded:
108
+ _write_tagged_line(fd_to_name[fd], decoded, colors)
109
+ except OSError:
110
+ continue
111
+
112
+ exit_codes: list[int] = []
113
+ for _tag, pid, fd in procs:
114
+ try:
115
+ _, status = os.waitpid(pid, 0)
116
+ exit_codes.append(os.WEXITSTATUS(status) if os.WIFEXITED(status) else 1)
117
+ except Exception:
118
+ pass
119
+ _close_fd(fd)
120
+
121
+ return max(exit_codes) if exit_codes else 0
122
+
123
+ except KeyboardInterrupt:
124
+ for _tag, pid, _fd in procs:
125
+ try:
126
+ os.kill(pid, signal.SIGTERM)
127
+ except Exception:
128
+ pass
129
+ return 130
130
+ finally:
131
+ for _tag, pid, fd in procs:
132
+ try:
133
+ os.kill(pid, signal.SIGKILL)
134
+ except Exception:
135
+ pass
136
+ _close_fd(fd)
137
+
138
+
139
+ def _run_without_pty(
140
+ commands: Sequence[CommandSpec],
141
+ *,
142
+ console: Console,
143
+ colors: Mapping[str, str],
144
+ ) -> int:
145
+ from selectors import EVENT_READ, DefaultSelector
146
+
147
+ procs: list[tuple[str, subprocess.Popen[str]]] = []
148
+ completed_codes: list[int] = []
149
+ selector = DefaultSelector()
150
+
151
+ try:
152
+ for spec in commands:
153
+ proc = subprocess.Popen(
154
+ spec.args,
155
+ cwd=spec.cwd,
156
+ env=spec.env,
157
+ stdout=subprocess.PIPE,
158
+ stderr=subprocess.STDOUT,
159
+ text=True,
160
+ bufsize=1,
161
+ universal_newlines=True,
162
+ )
163
+ if spec.on_spawn:
164
+ try:
165
+ spec.on_spawn()
166
+ except Exception:
167
+ pass
168
+ if proc.stdout:
169
+ selector.register(proc.stdout, EVENT_READ, data=spec.name)
170
+ procs.append((spec.name, proc))
171
+
172
+ while procs:
173
+ events = selector.select(timeout=0.1)
174
+ for key, _mask in events:
175
+ name = key.data
176
+ stream = key.fileobj
177
+ if isinstance(stream, int):
178
+ continue
179
+ # stream is now guaranteed to be a file-like object
180
+ line = cast(TextIOBase, stream).readline()
181
+ if line:
182
+ _write_tagged_line(name, line.rstrip("\n"), colors)
183
+ else:
184
+ selector.unregister(stream)
185
+ remaining: list[tuple[str, subprocess.Popen[str]]] = []
186
+ for name, proc in procs:
187
+ code = proc.poll()
188
+ if code is None:
189
+ remaining.append((name, proc))
190
+ else:
191
+ completed_codes.append(code)
192
+ if proc.stdout:
193
+ with contextlib.suppress(Exception):
194
+ selector.unregister(proc.stdout)
195
+ proc.stdout.close()
196
+ procs = remaining
197
+ except KeyboardInterrupt:
198
+ for _name, proc in procs:
199
+ with contextlib.suppress(Exception):
200
+ proc.terminate()
201
+ return 130
202
+ finally:
203
+ for _name, proc in procs:
204
+ with contextlib.suppress(Exception):
205
+ proc.terminate()
206
+ with contextlib.suppress(Exception):
207
+ proc.wait(timeout=1)
208
+ for key in list(selector.get_map().values()):
209
+ with contextlib.suppress(Exception):
210
+ selector.unregister(key.fileobj)
211
+ selector.close()
212
+
213
+ exit_codes = completed_codes + [proc.returncode or 0 for _name, proc in procs]
214
+ return max(exit_codes) if exit_codes else 0
215
+
216
+
217
+ def _write_tagged_line(name: str, message: str, colors: Mapping[str, str]) -> None:
218
+ color = ANSI_CODES.get(colors.get(name, ""), ANSI_CODES["default"])
219
+ sys.stdout.write(f"{color}[{name}]\033[0m {message}\n")
220
+ sys.stdout.flush()
221
+
222
+
223
+ def _close_fd(fd: int) -> None:
224
+ with contextlib.suppress(Exception):
225
+ os.close(fd)
pulse/cli/secrets.py ADDED
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from pathlib import Path
5
+
6
+ from pulse.cli.helpers import ensure_gitignore_has
7
+
8
+
9
+ def resolve_dev_secret(app_path: Path | None) -> str | None:
10
+ """Return or create a persisted development secret for the given app path."""
11
+ if app_path is None:
12
+ return None
13
+
14
+ try:
15
+ root = app_path if app_path.is_dir() else app_path.parent
16
+ secret_dir = root / ".pulse"
17
+ secret_dir.mkdir(parents=True, exist_ok=True)
18
+
19
+ secret_file = secret_dir / "secret"
20
+ if secret_file.exists():
21
+ try:
22
+ content = secret_file.read_text().strip()
23
+ if content:
24
+ return content
25
+ except Exception:
26
+ return None
27
+
28
+ secret_value = secrets.token_urlsafe(32)
29
+ try:
30
+ secret_file.write_text(secret_value)
31
+ except Exception:
32
+ # Best effort; secret still returned for current session
33
+ pass
34
+
35
+ ensure_gitignore_has(root, ".pulse/")
36
+
37
+ return secret_value
38
+ except Exception:
39
+ return None
@@ -0,0 +1,87 @@
1
+ """Custom logging configuration for uvicorn to filter noisy requests."""
2
+
3
+ import logging
4
+ from typing import Any, override
5
+
6
+
7
+ class FilterNoisyRequests(logging.Filter):
8
+ """Filter out noisy requests from uvicorn access logs."""
9
+
10
+ # Patterns to suppress (static assets, node_modules, vite internals)
11
+ SUPPRESS_PATTERNS: tuple[str, ...] = (
12
+ "/node_modules/",
13
+ "/@vite/",
14
+ "/%40vite/", # URL-encoded @vite
15
+ "/@fs/",
16
+ "/%40fs/", # URL-encoded @fs
17
+ "/@id/",
18
+ "/%40id/", # URL-encoded @id
19
+ "/@react-refresh",
20
+ "/app/", # React Router source files served by Vite
21
+ ".js?v=",
22
+ ".css?v=",
23
+ ".css ", # CSS files (space indicates end of path in log)
24
+ ".tsx ", # TSX source files
25
+ ".ts ", # TS source files
26
+ ".js ", # JS files
27
+ ".map",
28
+ "/favicon.ico",
29
+ "/.well-known/", # Browser/DevTools well-known endpoints
30
+ "connection open",
31
+ "connection closed",
32
+ "connection rejected",
33
+ "304 Not Modified", # Usually just cache hits, not interesting
34
+ )
35
+
36
+ @override
37
+ def filter(self, record: logging.LogRecord) -> bool:
38
+ """Return False to suppress log records matching noise patterns."""
39
+ message = record.getMessage()
40
+ # Suppress if message contains any noise pattern
41
+ return not any(pattern in message for pattern in self.SUPPRESS_PATTERNS)
42
+
43
+
44
+ def get_log_config(default_level: str = "info") -> dict[str, Any]:
45
+ """Get uvicorn logging config with noise filter."""
46
+ return {
47
+ "version": 1,
48
+ "disable_existing_loggers": False,
49
+ "filters": {
50
+ "filter_noisy_requests": {
51
+ "()": "pulse.cli.uvicorn_log_config.FilterNoisyRequests",
52
+ },
53
+ },
54
+ "formatters": {
55
+ "default": {
56
+ "()": "uvicorn.logging.DefaultFormatter",
57
+ "fmt": "%(levelprefix)s %(message)s",
58
+ "use_colors": None,
59
+ },
60
+ "access": {
61
+ "()": "uvicorn.logging.AccessFormatter",
62
+ "fmt": '%(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s',
63
+ },
64
+ },
65
+ "handlers": {
66
+ "default": {
67
+ "formatter": "default",
68
+ "class": "logging.StreamHandler",
69
+ "stream": "ext://sys.stderr",
70
+ },
71
+ "access": {
72
+ "formatter": "access",
73
+ "class": "logging.StreamHandler",
74
+ "stream": "ext://sys.stdout",
75
+ "filters": ["filter_noisy_requests"],
76
+ },
77
+ },
78
+ "loggers": {
79
+ "uvicorn": {"handlers": ["default"], "level": default_level.upper()},
80
+ "uvicorn.error": {"level": "INFO"},
81
+ "uvicorn.access": {
82
+ "handlers": ["access"],
83
+ "level": "INFO",
84
+ "propagate": False,
85
+ },
86
+ },
87
+ }
pulse/codegen/codegen.py CHANGED
@@ -4,6 +4,7 @@ from collections.abc import Sequence
4
4
  from dataclasses import dataclass
5
5
  from pathlib import Path
6
6
 
7
+ from pulse.cli.helpers import ensure_gitignore_has
7
8
  from pulse.codegen.templates.layout import LAYOUT_TEMPLATE
8
9
  from pulse.codegen.templates.route import CssModuleImport, render_route
9
10
  from pulse.codegen.templates.routes_ts import (
@@ -107,8 +108,14 @@ class Codegen:
107
108
  return self.cfg.pulse_path
108
109
 
109
110
  def generate_all(
110
- self, server_address: str, internal_server_address: str | None = None
111
+ self,
112
+ server_address: str,
113
+ internal_server_address: str | None = None,
114
+ api_prefix: str = "",
111
115
  ):
116
+ # Ensure generated files are gitignored
117
+ ensure_gitignore_has(self.cfg.web_root, f"app/{self.cfg.pulse_dir}/")
118
+
112
119
  self._copied_css_modules = set()
113
120
  self._css_module_dest = {}
114
121
  self._css_name_registry = NameRegistry()
@@ -116,7 +123,9 @@ class Codegen:
116
123
  # Keep track of all generated files
117
124
  generated_files = set(
118
125
  [
119
- self.generate_layout_tsx(server_address, internal_server_address),
126
+ self.generate_layout_tsx(
127
+ server_address, internal_server_address, api_prefix
128
+ ),
120
129
  self.generate_routes_ts(),
121
130
  self.generate_routes_runtime_ts(),
122
131
  *(
@@ -137,13 +146,17 @@ class Codegen:
137
146
  logger.warning(f"Could not remove stale file {path}: {e}")
138
147
 
139
148
  def generate_layout_tsx(
140
- self, server_address: str, internal_server_address: str | None = None
149
+ self,
150
+ server_address: str,
151
+ internal_server_address: str | None = None,
152
+ api_prefix: str = "",
141
153
  ):
142
154
  """Generates the content of _layout.tsx"""
143
155
  content = str(
144
156
  LAYOUT_TEMPLATE.render_unicode(
145
157
  server_address=server_address,
146
158
  internal_server_address=internal_server_address or server_address,
159
+ api_prefix=api_prefix,
147
160
  )
148
161
  )
149
162
  # The underscore avoids an eventual naming conflict with a generated
@@ -10,6 +10,7 @@ import { useLoaderData } from "react-router";
10
10
  // This config is used to initialize the client
11
11
  export const config: PulseConfig = {
12
12
  serverAddress: "${server_address}",
13
+ apiPrefix: "${api_prefix}",
13
14
  };
14
15
 
15
16
 
@@ -26,7 +27,7 @@ export async function loader(args: LoaderFunctionArgs) {
26
27
  if (cookie) fwd.set("cookie", cookie);
27
28
  if (authorization) fwd.set("authorization", authorization);
28
29
  fwd.set("content-type", "application/json");
29
- const res = await fetch("${internal_server_address}/prerender", {
30
+ const res = await fetch(`${internal_server_address}$${"{"}config.apiPrefix}/prerender`, {
30
31
  method: "POST",
31
32
  headers: fwd,
32
33
  body: JSON.stringify({ paths, routeInfo: extractServerRouteInfo(args) }),
@@ -53,7 +54,7 @@ export async function clientLoader(args: ClientLoaderFunctionArgs) {
53
54
  typeof window !== "undefined" && typeof sessionStorage !== "undefined"
54
55
  ? (sessionStorage.getItem("__PULSE_RENDER_ID") ?? undefined)
55
56
  : undefined;
56
- const res = await fetch("${server_address}/prerender", {
57
+ const res = await fetch(`$${"{"}config.serverAddress}$${"{"}config.apiPrefix}/prerender`, {
57
58
  method: "POST",
58
59
  headers: { "content-type": "application/json" },
59
60
  credentials: "include",
pulse/cookies.py CHANGED
@@ -8,7 +8,7 @@ from fastapi import Request, Response
8
8
  from pulse.hooks.runtime import set_cookie
9
9
 
10
10
  if TYPE_CHECKING:
11
- from pulse.app import DeploymentMode
11
+ from pulse.app import PulseMode
12
12
 
13
13
 
14
14
  @dataclass
@@ -73,11 +73,11 @@ class SetCookie(Cookie):
73
73
 
74
74
 
75
75
  def session_cookie(
76
- mode: "DeploymentMode",
76
+ mode: "PulseMode",
77
77
  name: str = "pulse.sid",
78
78
  max_age_seconds: int = 7 * 24 * 3600,
79
79
  ):
80
- if mode == "dev":
80
+ if mode == "single-server":
81
81
  return Cookie(
82
82
  name,
83
83
  domain=None,
@@ -85,7 +85,7 @@ def session_cookie(
85
85
  samesite="lax",
86
86
  max_age_seconds=max_age_seconds,
87
87
  )
88
- elif mode == "same_host" or mode == "subdomains":
88
+ elif mode == "subdomains":
89
89
  return Cookie(
90
90
  name,
91
91
  domain=None, # to be set later
@@ -137,34 +137,35 @@ def _base_domain(host: str) -> str:
137
137
  return host[i + 1 :] if i != -1 else host
138
138
 
139
139
 
140
- def compute_cookie_domain(mode: "DeploymentMode", server_address: str) -> str | None:
140
+ def compute_cookie_domain(mode: "PulseMode", server_address: str) -> str | None:
141
141
  host = _parse_host(server_address)
142
- if mode == "dev" or not host:
142
+ if mode == "single-server" or not host:
143
143
  return None
144
- if mode == "same_host":
145
- return host
146
144
  if mode == "subdomains":
147
145
  return "." + _base_domain(host)
148
146
  return None
149
147
 
150
148
 
151
- def cors_options(mode: "DeploymentMode", server_address: str) -> CORSOptions:
149
+ def cors_options(mode: "PulseMode", server_address: str) -> CORSOptions:
152
150
  host = _parse_host(server_address) or "localhost"
153
151
  opts: CORSOptions = {
154
152
  "allow_credentials": True,
155
153
  "allow_methods": ["*"],
156
154
  "allow_headers": ["*"],
157
155
  }
158
- if mode == "dev":
159
- opts["allow_origin_regex"] = ".*"
160
- return opts
161
- elif mode == "same_host":
162
- opts["allow_origin_regex"] = rf"^https?://{host}(:\\d+)?$"
163
- return opts
164
- elif mode == "subdomains":
156
+ if mode == "subdomains":
165
157
  base = _base_domain(host)
158
+ # Escape dots in base domain for regex (doesn't affect localhost since it has no dots)
159
+ base = base.replace(".", r"\.")
160
+ # Allow any subdomain and any port for the base domain
166
161
  opts["allow_origin_regex"] = rf"^https?://([a-z0-9-]+\\.)?{base}(:\\d+)?$"
167
162
  return opts
163
+ elif mode == "single-server":
164
+ # For single-server mode, allow same origin
165
+ # Escape dots in host for regex (doesn't affect localhost since it has no dots)
166
+ host = host.replace(".", r"\.")
167
+ opts["allow_origin_regex"] = rf"^https?://{host}(:\\d+)?$"
168
+ return opts
168
169
  else:
169
170
  raise ValueError(f"Unsupported deployment mode '{mode}'")
170
171
 
pulse/env.py CHANGED
@@ -17,7 +17,7 @@ import os
17
17
  from typing import Literal
18
18
 
19
19
  # Types
20
- PulseMode = Literal["dev", "ci", "prod"]
20
+ PulseEnv = Literal["dev", "ci", "prod"]
21
21
 
22
22
  # Keys
23
23
  ENV_PULSE_MODE = "PULSE_MODE"
@@ -26,11 +26,10 @@ ENV_PULSE_APP_DIR = "PULSE_APP_DIR"
26
26
  ENV_PULSE_HOST = "PULSE_HOST"
27
27
  ENV_PULSE_PORT = "PULSE_PORT"
28
28
  ENV_PULSE_SECRET = "PULSE_SECRET"
29
- ENV_PULSE_LOCK_MANAGED_BY_CLI = "PULSE_LOCK_MANAGED_BY_CLI"
30
29
  ENV_PULSE_DISABLE_CODEGEN = "PULSE_DISABLE_CODEGEN"
31
30
 
32
31
 
33
- class Env:
32
+ class EnvVars:
34
33
  def _get(self, key: str) -> str | None:
35
34
  return os.environ.get(key)
36
35
 
@@ -40,16 +39,15 @@ class Env:
40
39
  else:
41
40
  os.environ[key] = value
42
41
 
43
- # Pulse mode
44
42
  @property
45
- def pulse_mode(self) -> PulseMode:
43
+ def pulse_env(self) -> PulseEnv:
46
44
  value = (self._get(ENV_PULSE_MODE) or "dev").lower()
47
45
  if value not in ("dev", "ci", "prod"):
48
46
  value = "dev"
49
- return value # type: ignore[return-value]
47
+ return value
50
48
 
51
- @pulse_mode.setter
52
- def pulse_mode(self, value: PulseMode) -> None:
49
+ @pulse_env.setter
50
+ def pulse_env(self, value: PulseEnv) -> None:
53
51
  self._set(ENV_PULSE_MODE, value)
54
52
 
55
53
  # App file/dir
@@ -99,14 +97,6 @@ class Env:
99
97
  self._set(ENV_PULSE_SECRET, value)
100
98
 
101
99
  # Flags
102
- @property
103
- def lock_managed_by_cli(self) -> bool:
104
- return self._get(ENV_PULSE_LOCK_MANAGED_BY_CLI) == "1"
105
-
106
- @lock_managed_by_cli.setter
107
- def lock_managed_by_cli(self, value: bool) -> None:
108
- self._set(ENV_PULSE_LOCK_MANAGED_BY_CLI, "1" if value else None)
109
-
110
100
  @property
111
101
  def codegen_disabled(self) -> bool:
112
102
  return self._get(ENV_PULSE_DISABLE_CODEGEN) == "1"
@@ -117,9 +107,9 @@ class Env:
117
107
 
118
108
 
119
109
  # Singleton
120
- env = Env()
110
+ env = EnvVars()
121
111
 
122
112
 
123
113
  # Commonly used helpesr
124
114
  def mode():
125
- return env.pulse_mode
115
+ return env.pulse_env