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.
- pulse/__init__.py +1493 -0
- pulse/_examples.py +29 -0
- pulse/app.py +1086 -0
- pulse/channel.py +607 -0
- pulse/cli/__init__.py +0 -0
- pulse/cli/cmd.py +575 -0
- pulse/cli/dependencies.py +181 -0
- pulse/cli/folder_lock.py +134 -0
- pulse/cli/helpers.py +271 -0
- pulse/cli/logging.py +102 -0
- pulse/cli/models.py +35 -0
- pulse/cli/packages.py +262 -0
- pulse/cli/processes.py +292 -0
- pulse/cli/secrets.py +39 -0
- pulse/cli/uvicorn_log_config.py +87 -0
- pulse/code_analysis.py +38 -0
- pulse/codegen/__init__.py +0 -0
- pulse/codegen/codegen.py +359 -0
- pulse/codegen/templates/__init__.py +0 -0
- pulse/codegen/templates/layout.py +106 -0
- pulse/codegen/templates/route.py +345 -0
- pulse/codegen/templates/routes_ts.py +42 -0
- pulse/codegen/utils.py +20 -0
- pulse/component.py +237 -0
- pulse/components/__init__.py +0 -0
- pulse/components/for_.py +83 -0
- pulse/components/if_.py +86 -0
- pulse/components/react_router.py +94 -0
- pulse/context.py +108 -0
- pulse/cookies.py +322 -0
- pulse/decorators.py +344 -0
- pulse/dom/__init__.py +0 -0
- pulse/dom/elements.py +1024 -0
- pulse/dom/events.py +445 -0
- pulse/dom/props.py +1250 -0
- pulse/dom/svg.py +0 -0
- pulse/dom/tags.py +328 -0
- pulse/dom/tags.pyi +480 -0
- pulse/env.py +178 -0
- pulse/form.py +538 -0
- pulse/helpers.py +541 -0
- pulse/hooks/__init__.py +0 -0
- pulse/hooks/core.py +452 -0
- pulse/hooks/effects.py +88 -0
- pulse/hooks/init.py +668 -0
- pulse/hooks/runtime.py +464 -0
- pulse/hooks/setup.py +254 -0
- pulse/hooks/stable.py +138 -0
- pulse/hooks/state.py +192 -0
- pulse/js/__init__.py +125 -0
- pulse/js/__init__.pyi +115 -0
- pulse/js/_types.py +299 -0
- pulse/js/array.py +339 -0
- pulse/js/console.py +50 -0
- pulse/js/date.py +119 -0
- pulse/js/document.py +145 -0
- pulse/js/error.py +140 -0
- pulse/js/json.py +66 -0
- pulse/js/map.py +97 -0
- pulse/js/math.py +69 -0
- pulse/js/navigator.py +79 -0
- pulse/js/number.py +57 -0
- pulse/js/obj.py +81 -0
- pulse/js/object.py +172 -0
- pulse/js/promise.py +172 -0
- pulse/js/pulse.py +115 -0
- pulse/js/react.py +495 -0
- pulse/js/regexp.py +57 -0
- pulse/js/set.py +124 -0
- pulse/js/string.py +38 -0
- pulse/js/weakmap.py +53 -0
- pulse/js/weakset.py +48 -0
- pulse/js/window.py +205 -0
- pulse/messages.py +202 -0
- pulse/middleware.py +471 -0
- pulse/plugin.py +96 -0
- pulse/proxy.py +242 -0
- pulse/py.typed +0 -0
- pulse/queries/__init__.py +0 -0
- pulse/queries/client.py +609 -0
- pulse/queries/common.py +101 -0
- pulse/queries/effect.py +55 -0
- pulse/queries/infinite_query.py +1418 -0
- pulse/queries/mutation.py +295 -0
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +1314 -0
- pulse/queries/store.py +120 -0
- pulse/react_component.py +88 -0
- pulse/reactive.py +1208 -0
- pulse/reactive_extensions.py +1172 -0
- pulse/render_session.py +768 -0
- pulse/renderer.py +584 -0
- pulse/request.py +205 -0
- pulse/routing.py +598 -0
- pulse/serializer.py +279 -0
- pulse/state.py +556 -0
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +111 -0
- pulse/transpiler/assets.py +81 -0
- pulse/transpiler/builtins.py +1029 -0
- pulse/transpiler/dynamic_import.py +130 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +96 -0
- pulse/transpiler/function.py +611 -0
- pulse/transpiler/id.py +18 -0
- pulse/transpiler/imports.py +341 -0
- pulse/transpiler/js_module.py +336 -0
- pulse/transpiler/modules/__init__.py +33 -0
- pulse/transpiler/modules/asyncio.py +57 -0
- pulse/transpiler/modules/json.py +24 -0
- pulse/transpiler/modules/math.py +265 -0
- pulse/transpiler/modules/pulse/__init__.py +5 -0
- pulse/transpiler/modules/pulse/tags.py +250 -0
- pulse/transpiler/modules/typing.py +63 -0
- pulse/transpiler/nodes.py +1987 -0
- pulse/transpiler/py_module.py +135 -0
- pulse/transpiler/transpiler.py +1100 -0
- pulse/transpiler/vdom.py +256 -0
- pulse/types/__init__.py +0 -0
- pulse/types/event_handler.py +50 -0
- pulse/user_session.py +386 -0
- pulse/version.py +69 -0
- pulse_framework-0.1.62.dist-info/METADATA +198 -0
- pulse_framework-0.1.62.dist-info/RECORD +126 -0
- pulse_framework-0.1.62.dist-info/WHEEL +4 -0
- 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
|
+
}
|