pulse-framework 0.1.62__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 (126) hide show
  1. pulse/__init__.py +1493 -0
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +1086 -0
  4. pulse/channel.py +607 -0
  5. pulse/cli/__init__.py +0 -0
  6. pulse/cli/cmd.py +575 -0
  7. pulse/cli/dependencies.py +181 -0
  8. pulse/cli/folder_lock.py +134 -0
  9. pulse/cli/helpers.py +271 -0
  10. pulse/cli/logging.py +102 -0
  11. pulse/cli/models.py +35 -0
  12. pulse/cli/packages.py +262 -0
  13. pulse/cli/processes.py +292 -0
  14. pulse/cli/secrets.py +39 -0
  15. pulse/cli/uvicorn_log_config.py +87 -0
  16. pulse/code_analysis.py +38 -0
  17. pulse/codegen/__init__.py +0 -0
  18. pulse/codegen/codegen.py +359 -0
  19. pulse/codegen/templates/__init__.py +0 -0
  20. pulse/codegen/templates/layout.py +106 -0
  21. pulse/codegen/templates/route.py +345 -0
  22. pulse/codegen/templates/routes_ts.py +42 -0
  23. pulse/codegen/utils.py +20 -0
  24. pulse/component.py +237 -0
  25. pulse/components/__init__.py +0 -0
  26. pulse/components/for_.py +83 -0
  27. pulse/components/if_.py +86 -0
  28. pulse/components/react_router.py +94 -0
  29. pulse/context.py +108 -0
  30. pulse/cookies.py +322 -0
  31. pulse/decorators.py +344 -0
  32. pulse/dom/__init__.py +0 -0
  33. pulse/dom/elements.py +1024 -0
  34. pulse/dom/events.py +445 -0
  35. pulse/dom/props.py +1250 -0
  36. pulse/dom/svg.py +0 -0
  37. pulse/dom/tags.py +328 -0
  38. pulse/dom/tags.pyi +480 -0
  39. pulse/env.py +178 -0
  40. pulse/form.py +538 -0
  41. pulse/helpers.py +541 -0
  42. pulse/hooks/__init__.py +0 -0
  43. pulse/hooks/core.py +452 -0
  44. pulse/hooks/effects.py +88 -0
  45. pulse/hooks/init.py +668 -0
  46. pulse/hooks/runtime.py +464 -0
  47. pulse/hooks/setup.py +254 -0
  48. pulse/hooks/stable.py +138 -0
  49. pulse/hooks/state.py +192 -0
  50. pulse/js/__init__.py +125 -0
  51. pulse/js/__init__.pyi +115 -0
  52. pulse/js/_types.py +299 -0
  53. pulse/js/array.py +339 -0
  54. pulse/js/console.py +50 -0
  55. pulse/js/date.py +119 -0
  56. pulse/js/document.py +145 -0
  57. pulse/js/error.py +140 -0
  58. pulse/js/json.py +66 -0
  59. pulse/js/map.py +97 -0
  60. pulse/js/math.py +69 -0
  61. pulse/js/navigator.py +79 -0
  62. pulse/js/number.py +57 -0
  63. pulse/js/obj.py +81 -0
  64. pulse/js/object.py +172 -0
  65. pulse/js/promise.py +172 -0
  66. pulse/js/pulse.py +115 -0
  67. pulse/js/react.py +495 -0
  68. pulse/js/regexp.py +57 -0
  69. pulse/js/set.py +124 -0
  70. pulse/js/string.py +38 -0
  71. pulse/js/weakmap.py +53 -0
  72. pulse/js/weakset.py +48 -0
  73. pulse/js/window.py +205 -0
  74. pulse/messages.py +202 -0
  75. pulse/middleware.py +471 -0
  76. pulse/plugin.py +96 -0
  77. pulse/proxy.py +242 -0
  78. pulse/py.typed +0 -0
  79. pulse/queries/__init__.py +0 -0
  80. pulse/queries/client.py +609 -0
  81. pulse/queries/common.py +101 -0
  82. pulse/queries/effect.py +55 -0
  83. pulse/queries/infinite_query.py +1418 -0
  84. pulse/queries/mutation.py +295 -0
  85. pulse/queries/protocol.py +136 -0
  86. pulse/queries/query.py +1314 -0
  87. pulse/queries/store.py +120 -0
  88. pulse/react_component.py +88 -0
  89. pulse/reactive.py +1208 -0
  90. pulse/reactive_extensions.py +1172 -0
  91. pulse/render_session.py +768 -0
  92. pulse/renderer.py +584 -0
  93. pulse/request.py +205 -0
  94. pulse/routing.py +598 -0
  95. pulse/serializer.py +279 -0
  96. pulse/state.py +556 -0
  97. pulse/test_helpers.py +15 -0
  98. pulse/transpiler/__init__.py +111 -0
  99. pulse/transpiler/assets.py +81 -0
  100. pulse/transpiler/builtins.py +1029 -0
  101. pulse/transpiler/dynamic_import.py +130 -0
  102. pulse/transpiler/emit_context.py +49 -0
  103. pulse/transpiler/errors.py +96 -0
  104. pulse/transpiler/function.py +611 -0
  105. pulse/transpiler/id.py +18 -0
  106. pulse/transpiler/imports.py +341 -0
  107. pulse/transpiler/js_module.py +336 -0
  108. pulse/transpiler/modules/__init__.py +33 -0
  109. pulse/transpiler/modules/asyncio.py +57 -0
  110. pulse/transpiler/modules/json.py +24 -0
  111. pulse/transpiler/modules/math.py +265 -0
  112. pulse/transpiler/modules/pulse/__init__.py +5 -0
  113. pulse/transpiler/modules/pulse/tags.py +250 -0
  114. pulse/transpiler/modules/typing.py +63 -0
  115. pulse/transpiler/nodes.py +1987 -0
  116. pulse/transpiler/py_module.py +135 -0
  117. pulse/transpiler/transpiler.py +1100 -0
  118. pulse/transpiler/vdom.py +256 -0
  119. pulse/types/__init__.py +0 -0
  120. pulse/types/event_handler.py +50 -0
  121. pulse/user_session.py +386 -0
  122. pulse/version.py +69 -0
  123. pulse_framework-0.1.62.dist-info/METADATA +198 -0
  124. pulse_framework-0.1.62.dist-info/RECORD +126 -0
  125. pulse_framework-0.1.62.dist-info/WHEEL +4 -0
  126. pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
pulse/cli/models.py ADDED
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING, Callable, Literal
6
+
7
+ if TYPE_CHECKING:
8
+ from pulse.app import App
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class AppLoadResult:
13
+ """Description of a loaded Pulse app and related filesystem context."""
14
+
15
+ target: str
16
+ mode: Literal["path", "module"]
17
+ app: App
18
+ module_name: str
19
+ app_var: str
20
+ app_file: Path | None
21
+ app_dir: Path | None
22
+ server_cwd: Path | None
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class CommandSpec:
27
+ """Instructions for launching a subprocess associated with the CLI."""
28
+
29
+ name: str
30
+ args: list[str]
31
+ cwd: Path
32
+ env: dict[str, str]
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/packages.py ADDED
@@ -0,0 +1,262 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ # Helpers for parsing JavaScript package specifiers used by Pulse's CLI.
8
+ #
9
+ # Goals:
10
+ # - Extract a canonical npm/bun package name from import sources used in React components
11
+ # (reject relative paths; allow aliases like "@/" and "~/"; ignore URLs/absolute paths).
12
+ # - Parse dependency specs that may include an optional version (e.g., "pkg@^2", "@scope/name@1").
13
+
14
+
15
+ def is_relative_source(src: str) -> bool:
16
+ return src.startswith("./") or src.startswith("../")
17
+
18
+
19
+ def is_alias_source(src: str) -> bool:
20
+ return src.startswith("@/") or src.startswith("~/")
21
+
22
+
23
+ def is_url_or_absolute(src: str) -> bool:
24
+ return (
25
+ src.startswith("http://") or src.startswith("https://") or src.startswith("/")
26
+ )
27
+
28
+
29
+ def parse_dependency_spec(spec: str) -> tuple[str, str | None]:
30
+ """Parse a dependency spec into (name, version) where version may be None.
31
+
32
+ Accepts:
33
+ - "some-pkg"
34
+ - "some-pkg@^1.2.3"
35
+ - "@scope/name"
36
+ - "@scope/name@2"
37
+
38
+ Ignores any subpath suffix (e.g., "pkg/sub/path"). For scoped packages with versions,
39
+ splits on the last '@' to avoid confusing the scope '@'.
40
+ """
41
+
42
+ if spec.startswith("@"):
43
+ # Scoped: @scope/name[@version][/subpath]
44
+ s = spec[1:]
45
+ i = s.find("/")
46
+ if i == -1:
47
+ return "@" + s, None
48
+ scope = s[:i]
49
+ rest = s[i + 1 :]
50
+ # head may be 'name' or 'name@version'
51
+ j = rest.find("/")
52
+ head = rest if j == -1 else rest[:j]
53
+ if "@" in head:
54
+ name_part, ver = head.split("@", 1)
55
+ else:
56
+ name_part, ver = head, None
57
+ return f"@{scope}/{name_part}", (ver or None)
58
+
59
+ # Unscoped: name[@version][/subpath]
60
+ head = spec.split("/", 1)[0]
61
+ if "@" in head:
62
+ name, ver = head.split("@", 1)
63
+ return name, (ver or None)
64
+ return head, None
65
+
66
+
67
+ def parse_install_spec(src_or_spec: str) -> str | None:
68
+ """Unified parser that:
69
+ - Rejects relative paths by raising ValueError.
70
+ - Returns None for alias ("@/", "~/"), URLs, and absolute paths.
71
+ - Returns a normalized install spec: "name" or "name@version".
72
+ - Accepts sources or dependency specs, and strips subpaths.
73
+ """
74
+
75
+ if is_relative_source(src_or_spec):
76
+ raise ValueError(
77
+ f"React component import source '{src_or_spec}' must not be relative (./ or ../). Use a package, '@/...' or '~/...' alias instead."
78
+ )
79
+ if is_alias_source(src_or_spec) or is_url_or_absolute(src_or_spec):
80
+ return None
81
+ name, ver = parse_dependency_spec(src_or_spec)
82
+ return f"{name}@{ver}" if ver else name
83
+
84
+
85
+ # ---------------------------- Version resolution ----------------------------
86
+
87
+
88
+ class VersionConflict(Exception):
89
+ pass
90
+
91
+
92
+ def pick_more_specific(a: str | None, b: str | None) -> str | None:
93
+ """Pick the more specific semver constraint between two strings.
94
+
95
+ Heuristic only (no full semver solver):
96
+ - Exact versions (e.g., "1.2.3") outrank range prefixes ("^", "~", ">=", "<=", ">", "<").
97
+ - Between ranges, prefer the one with a longer string (assume more specific).
98
+ - If one is None, return the other.
99
+ - If equal, return either.
100
+ """
101
+
102
+ if not a:
103
+ return b
104
+ if not b:
105
+ return a
106
+
107
+ # Exact version detection: purely digits and dots
108
+ def is_exact(v: str) -> bool:
109
+ return bool(v) and all(part.isdigit() for part in v.split("."))
110
+
111
+ a_exact = is_exact(a)
112
+ b_exact = is_exact(b)
113
+ if a_exact and b_exact:
114
+ return a if a == b else None # same exact or conflict
115
+ if a_exact:
116
+ return a
117
+ if b_exact:
118
+ return b
119
+
120
+ # If both are ranges, prefer higher version if possible (heuristic)
121
+ if a.startswith(("^", "~")) and b.startswith(("^", "~")):
122
+ av = a[1:]
123
+ bv = b[1:]
124
+ # Basic version comparison for digits
125
+ try:
126
+ a_parts = [int(p) for p in av.split(".") if p.isdigit()]
127
+ b_parts = [int(p) for p in bv.split(".") if p.isdigit()]
128
+ if a_parts > b_parts:
129
+ return a
130
+ if b_parts > a_parts:
131
+ return b
132
+ except ValueError:
133
+ pass
134
+
135
+ # Prefer longer constraint as proxy for specificity
136
+ return a if len(a) >= len(b) else b
137
+
138
+
139
+ def resolve_versions(
140
+ constraints: dict[str, list[str | None]],
141
+ ) -> dict[str, str | None]:
142
+ """Resolve version constraints per package.
143
+
144
+ Input: { name: [ver1, ver2, None, ...] }
145
+ Output: { name: resolved_version_or_None }
146
+ Raises VersionConflict if constraints are incompatible under our heuristic.
147
+ """
148
+ resolved: dict[str, str | None] = {}
149
+ for name, vers in constraints.items():
150
+ cur: str | None = None
151
+ for v in vers:
152
+ cur = pick_more_specific(cur, v)
153
+ if cur is None and v is not None:
154
+ # irreconcilable (two different exact versions)
155
+ raise VersionConflict(f"Conflicting versions for {name}: {vers}")
156
+ resolved[name] = cur
157
+ return resolved
158
+
159
+
160
+ # ----------------------------- package.json utils ----------------------------
161
+
162
+
163
+ def load_package_json(web_root: Path) -> dict[str, Any]:
164
+ pkg_path = web_root / "package.json"
165
+ try:
166
+ data = json.loads(pkg_path.read_text())
167
+ if isinstance(data, dict):
168
+ return data
169
+ except Exception:
170
+ pass
171
+ return {}
172
+
173
+
174
+ def get_pkg_spec(pkg_json: dict[str, Any], name: str) -> str | None:
175
+ for field in ("dependencies", "devDependencies"):
176
+ section = pkg_json.get(field)
177
+ if isinstance(section, dict) and name in section:
178
+ spec = section.get(name)
179
+ if isinstance(spec, str):
180
+ return spec.strip()
181
+ return None
182
+
183
+
184
+ def is_workspace_spec(spec: str) -> bool:
185
+ return spec.strip().startswith("workspace:")
186
+
187
+
188
+ def _split_constraint(spec: str) -> tuple[str, str | None]:
189
+ s = spec.strip()
190
+ if not s:
191
+ return "unknown", None
192
+ if s[0] in ("^", "~"):
193
+ return (s[0], s[1:])
194
+ # exact version like 1 or 1.2 or 1.2.3
195
+ if all(part.isdigit() for part in s.split(".")):
196
+ return ("=", s)
197
+ return "unknown", s
198
+
199
+
200
+ def _parse_major_minor(ver: str | None) -> tuple[int | None, int | None]:
201
+ if not ver:
202
+ return None, None
203
+ try:
204
+ parts = ver.split(".")
205
+ major = int(parts[0]) if len(parts) >= 1 and parts[0].isdigit() else None
206
+ minor = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else None
207
+ return major, minor
208
+ except Exception:
209
+ return None, None
210
+
211
+
212
+ def spec_satisfies(required: str | None, existing: str | None) -> bool:
213
+ if required is None:
214
+ return True
215
+ if not existing:
216
+ return False
217
+ if is_workspace_spec(existing):
218
+ return True
219
+ rk, rv = _split_constraint(required)
220
+ ek, ev = _split_constraint(existing)
221
+ rmaj, rmin = _parse_major_minor(rv)
222
+ emaj, emin = _parse_major_minor(ev)
223
+
224
+ # Exact required
225
+ if rk == "=":
226
+ if ek == "=" and rv == ev:
227
+ return True
228
+ # Accept common ranges that include the exact version
229
+ if ek == "^" and emaj is not None and rmaj is not None and emaj == rmaj:
230
+ return True
231
+ if (
232
+ ek == "~"
233
+ and emaj is not None
234
+ and emin is not None
235
+ and rmaj is not None
236
+ and rmin is not None
237
+ and emaj == rmaj
238
+ and emin == rmin
239
+ ):
240
+ return True
241
+ return False
242
+
243
+ # Caret required: same major acceptable
244
+ if rk == "^" and rmaj is not None:
245
+ if ek == "=" and emaj == rmaj:
246
+ return True
247
+ if ek == "^" and emaj == rmaj:
248
+ return True
249
+ if ek == "~" and emaj == rmaj:
250
+ return True
251
+ return False
252
+
253
+ # Tilde required: same major+minor acceptable
254
+ if rk == "~" and rmaj is not None and rmin is not None:
255
+ if ek == "=" and emaj == rmaj and emin == rmin:
256
+ return True
257
+ if ek == "~" and emaj == rmaj and emin == rmin:
258
+ return True
259
+ return False
260
+
261
+ # Unknown required; fallback to equality
262
+ return required == existing
pulse/cli/processes.py ADDED
@@ -0,0 +1,292 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import os
5
+ import pty
6
+ import re
7
+ import select
8
+ import signal
9
+ import subprocess
10
+ import sys
11
+ from collections.abc import Sequence
12
+ from io import TextIOBase
13
+ from typing import TypeVar, cast
14
+
15
+ from pulse.cli.helpers import os_family
16
+ from pulse.cli.logging import TagMode
17
+ from pulse.cli.models import CommandSpec
18
+
19
+ _K = TypeVar("_K", int, str)
20
+
21
+ # ANSI color codes for tagged output
22
+ ANSI_CODES = {
23
+ "cyan": "\033[36m",
24
+ "orange1": "\033[38;5;208m",
25
+ "reset": "\033[0m",
26
+ }
27
+
28
+ # Tag colors mapping (used only in colored mode)
29
+ TAG_COLORS = {"server": "cyan", "web": "orange1"}
30
+
31
+ # Regex to strip ANSI escape codes
32
+ ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
33
+
34
+
35
+ def execute_commands(
36
+ commands: Sequence[CommandSpec],
37
+ *,
38
+ tag_mode: TagMode = "colored",
39
+ ) -> int:
40
+ """Run the provided commands, streaming tagged output to stdout.
41
+
42
+ Args:
43
+ commands: List of command specifications to run
44
+ tag_mode: How to display process tags:
45
+ - "colored": Show [server]/[web] with ANSI colors (dev mode)
46
+ - "plain": Show [server]/[web] without colors (ci/prod mode)
47
+ """
48
+ if not commands:
49
+ return 0
50
+
51
+ # Avoid pty.fork() in multi-threaded environments (like pytest) to prevent
52
+ # "DeprecationWarning: This process is multi-threaded, use of forkpty() may lead to deadlocks"
53
+ # Also skip pty on Windows or if fork is unavailable
54
+ in_pytest = "pytest" in sys.modules
55
+ if os_family() == "windows" or not hasattr(pty, "fork") or in_pytest:
56
+ return _run_without_pty(commands, tag_mode=tag_mode)
57
+
58
+ return _run_with_pty(commands, tag_mode=tag_mode)
59
+
60
+
61
+ def _call_on_spawn(spec: CommandSpec) -> None:
62
+ """Call the on_spawn callback if it exists."""
63
+ if spec.on_spawn:
64
+ try:
65
+ spec.on_spawn()
66
+ except Exception:
67
+ pass
68
+
69
+
70
+ def _check_on_ready(
71
+ spec: CommandSpec,
72
+ line: str,
73
+ ready_flags: dict[_K, bool],
74
+ key: _K,
75
+ ) -> None:
76
+ """Check if line matches ready_pattern and call on_ready if needed."""
77
+ if spec.ready_pattern and not ready_flags[key]:
78
+ # Strip ANSI codes before matching
79
+ clean_line = ANSI_ESCAPE.sub("", line)
80
+ if re.search(spec.ready_pattern, clean_line):
81
+ ready_flags[key] = True
82
+ if spec.on_ready:
83
+ try:
84
+ spec.on_ready()
85
+ except Exception:
86
+ pass
87
+
88
+
89
+ def _run_with_pty(
90
+ commands: Sequence[CommandSpec],
91
+ *,
92
+ tag_mode: TagMode,
93
+ ) -> int:
94
+ procs: list[tuple[str, int, int]] = []
95
+ fd_to_spec: dict[int, CommandSpec] = {}
96
+ buffers: dict[int, bytearray] = {}
97
+ ready_flags: dict[int, bool] = {}
98
+
99
+ try:
100
+ for spec in commands:
101
+ pid, fd = pty.fork()
102
+ if pid == 0:
103
+ if spec.cwd:
104
+ os.chdir(spec.cwd)
105
+ os.execvpe(spec.args[0], spec.args, spec.env)
106
+ else:
107
+ fcntl = __import__("fcntl")
108
+ fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
109
+ procs.append((spec.name, pid, fd))
110
+ fd_to_spec[fd] = spec
111
+ buffers[fd] = bytearray()
112
+ ready_flags[fd] = False
113
+ _call_on_spawn(spec)
114
+
115
+ while procs:
116
+ for tag, pid, fd in list(procs):
117
+ try:
118
+ wpid, status = os.waitpid(pid, os.WNOHANG)
119
+ if wpid == pid:
120
+ procs.remove((tag, pid, fd))
121
+ _close_fd(fd)
122
+ except ChildProcessError:
123
+ procs.remove((tag, pid, fd))
124
+ _close_fd(fd)
125
+
126
+ if not procs:
127
+ break
128
+
129
+ readable = [fd for _, _, fd in procs]
130
+ try:
131
+ ready, _, _ = select.select(readable, [], [], 0.1)
132
+ except (OSError, ValueError):
133
+ break
134
+
135
+ for fd in ready:
136
+ try:
137
+ data = os.read(fd, 4096)
138
+ if not data:
139
+ continue
140
+ buffers[fd].extend(data)
141
+ while b"\n" in buffers[fd]:
142
+ line, remainder = buffers[fd].split(b"\n", 1)
143
+ buffers[fd] = remainder
144
+ decoded = line.decode(errors="replace")
145
+ if decoded:
146
+ spec = fd_to_spec[fd]
147
+ _write_tagged_line(spec.name, decoded, tag_mode)
148
+ _check_on_ready(spec, decoded, ready_flags, fd)
149
+ except OSError:
150
+ continue
151
+
152
+ exit_codes: list[int] = []
153
+ for _tag, pid, fd in procs:
154
+ try:
155
+ _, status = os.waitpid(pid, 0)
156
+ exit_codes.append(os.WEXITSTATUS(status) if os.WIFEXITED(status) else 1)
157
+ except Exception:
158
+ pass
159
+ _close_fd(fd)
160
+
161
+ return max(exit_codes) if exit_codes else 0
162
+
163
+ except KeyboardInterrupt:
164
+ for _tag, pid, _fd in procs:
165
+ try:
166
+ os.kill(pid, signal.SIGTERM)
167
+ except Exception:
168
+ pass
169
+ return 130
170
+ finally:
171
+ for _tag, pid, fd in procs:
172
+ try:
173
+ os.kill(pid, signal.SIGKILL)
174
+ except Exception:
175
+ pass
176
+ _close_fd(fd)
177
+
178
+
179
+ def _run_without_pty(
180
+ commands: Sequence[CommandSpec],
181
+ *,
182
+ tag_mode: TagMode,
183
+ ) -> int:
184
+ from selectors import EVENT_READ, DefaultSelector
185
+
186
+ procs: list[tuple[str, subprocess.Popen[str], CommandSpec]] = []
187
+ completed_codes: list[int] = []
188
+ selector = DefaultSelector()
189
+ ready_flags: dict[str, bool] = {}
190
+
191
+ try:
192
+ for spec in commands:
193
+ proc = subprocess.Popen(
194
+ spec.args,
195
+ cwd=spec.cwd,
196
+ env=spec.env,
197
+ stdout=subprocess.PIPE,
198
+ stderr=subprocess.STDOUT,
199
+ text=True,
200
+ bufsize=1,
201
+ universal_newlines=True,
202
+ )
203
+ _call_on_spawn(spec)
204
+ if proc.stdout:
205
+ selector.register(proc.stdout, EVENT_READ, data=spec.name)
206
+ ready_flags[spec.name] = False
207
+ procs.append((spec.name, proc, spec))
208
+
209
+ while procs:
210
+ events = selector.select(timeout=0.1)
211
+ for key, _mask in events:
212
+ name = key.data
213
+ stream = key.fileobj
214
+ if isinstance(stream, int):
215
+ continue
216
+ # stream is now guaranteed to be a file-like object
217
+ line = cast(TextIOBase, stream).readline()
218
+ if line:
219
+ _write_tagged_line(name, line.rstrip("\n"), tag_mode)
220
+ spec = next((s for n, _, s in procs if n == name), None)
221
+ if spec:
222
+ _check_on_ready(spec, line, ready_flags, name)
223
+ else:
224
+ selector.unregister(stream)
225
+ remaining: list[tuple[str, subprocess.Popen[str], CommandSpec]] = []
226
+ for name, proc, spec in procs:
227
+ code = proc.poll()
228
+ if code is None:
229
+ remaining.append((name, proc, spec))
230
+ else:
231
+ completed_codes.append(code)
232
+ if proc.stdout:
233
+ with contextlib.suppress(Exception):
234
+ selector.unregister(proc.stdout)
235
+ proc.stdout.close()
236
+ procs = remaining
237
+ except KeyboardInterrupt:
238
+ for _name, proc, _spec in procs:
239
+ with contextlib.suppress(Exception):
240
+ proc.terminate()
241
+ return 130
242
+ finally:
243
+ for _name, proc, _spec in procs:
244
+ with contextlib.suppress(Exception):
245
+ proc.terminate()
246
+ with contextlib.suppress(Exception):
247
+ proc.wait(timeout=1)
248
+ for key in list(selector.get_map().values()):
249
+ with contextlib.suppress(Exception):
250
+ selector.unregister(key.fileobj)
251
+ selector.close()
252
+
253
+ exit_codes = completed_codes + [
254
+ proc.returncode or 0 for _name, proc, _spec in procs
255
+ ]
256
+ return max(exit_codes) if exit_codes else 0
257
+
258
+
259
+ def _write_tagged_line(name: str, message: str, tag_mode: TagMode) -> None:
260
+ """Write a line of output with optional process tag.
261
+
262
+ Args:
263
+ name: Process name (e.g., "server", "web")
264
+ message: The line of output to write
265
+ tag_mode: How to display the tag:
266
+ - "colored": Show [name] with ANSI colors
267
+ - "plain": Show [name] without colors
268
+ """
269
+ # Filter out unwanted web server messages
270
+ clean_message = ANSI_ESCAPE.sub("", message)
271
+ if (
272
+ "Network: use --host to expose" in clean_message
273
+ or "press h + enter to show help" in clean_message
274
+ or "➜ Local:" in clean_message
275
+ ):
276
+ return
277
+
278
+ if tag_mode == "colored":
279
+ color = ANSI_CODES.get(TAG_COLORS.get(name, ""), "")
280
+ if color:
281
+ sys.stdout.write(f"{color}[{name}]{ANSI_CODES['reset']} {message}\n")
282
+ else:
283
+ sys.stdout.write(f"[{name}] {message}\n")
284
+ else:
285
+ # Plain mode: tags without color
286
+ sys.stdout.write(f"[{name}] {message}\n")
287
+ sys.stdout.flush()
288
+
289
+
290
+ def _close_fd(fd: int) -> None:
291
+ with contextlib.suppress(Exception):
292
+ 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": "%(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
+ }