pulse-framework 0.1.51__py3-none-any.whl → 0.1.53__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 +542 -562
- pulse/_examples.py +29 -0
- pulse/app.py +0 -14
- pulse/cli/cmd.py +96 -80
- pulse/cli/dependencies.py +10 -41
- pulse/cli/folder_lock.py +3 -3
- pulse/cli/helpers.py +40 -67
- pulse/cli/logging.py +102 -0
- pulse/cli/packages.py +16 -0
- pulse/cli/processes.py +40 -23
- pulse/codegen/codegen.py +70 -35
- pulse/codegen/js.py +2 -4
- pulse/codegen/templates/route.py +94 -146
- pulse/component.py +115 -0
- pulse/components/for_.py +1 -1
- pulse/components/if_.py +1 -1
- pulse/components/react_router.py +16 -22
- pulse/{html → dom}/events.py +1 -1
- pulse/{html → dom}/props.py +6 -6
- pulse/{html → dom}/tags.py +11 -11
- pulse/dom/tags.pyi +480 -0
- pulse/form.py +7 -6
- pulse/hooks/init.py +1 -13
- pulse/js/__init__.py +37 -41
- pulse/js/__init__.pyi +22 -2
- pulse/js/_types.py +5 -3
- pulse/js/array.py +121 -38
- pulse/js/console.py +9 -9
- pulse/js/date.py +22 -19
- pulse/js/document.py +8 -4
- pulse/js/error.py +12 -14
- pulse/js/json.py +4 -3
- pulse/js/map.py +17 -7
- pulse/js/math.py +2 -2
- pulse/js/navigator.py +4 -4
- pulse/js/number.py +8 -8
- pulse/js/object.py +9 -13
- pulse/js/promise.py +25 -9
- pulse/js/regexp.py +6 -6
- pulse/js/set.py +20 -8
- pulse/js/string.py +7 -7
- pulse/js/weakmap.py +6 -6
- pulse/js/weakset.py +6 -6
- pulse/js/window.py +17 -14
- pulse/messages.py +1 -4
- pulse/react_component.py +3 -1001
- pulse/render_session.py +74 -66
- pulse/renderer.py +311 -238
- pulse/routing.py +1 -10
- pulse/transpiler/__init__.py +84 -114
- pulse/transpiler/builtins.py +661 -343
- pulse/transpiler/errors.py +78 -2
- pulse/transpiler/function.py +463 -133
- pulse/transpiler/id.py +18 -0
- pulse/transpiler/imports.py +230 -325
- pulse/transpiler/js_module.py +218 -209
- pulse/transpiler/modules/__init__.py +16 -13
- pulse/transpiler/modules/asyncio.py +45 -26
- pulse/transpiler/modules/json.py +12 -8
- pulse/transpiler/modules/math.py +161 -216
- pulse/transpiler/modules/pulse/__init__.py +5 -0
- pulse/transpiler/modules/pulse/tags.py +231 -0
- pulse/transpiler/modules/typing.py +33 -28
- pulse/transpiler/nodes.py +1607 -923
- pulse/transpiler/py_module.py +118 -95
- pulse/transpiler/react_component.py +51 -0
- pulse/transpiler/transpiler.py +593 -437
- pulse/transpiler/vdom.py +255 -0
- {pulse_framework-0.1.51.dist-info → pulse_framework-0.1.53.dist-info}/METADATA +1 -1
- pulse_framework-0.1.53.dist-info/RECORD +120 -0
- pulse/html/tags.pyi +0 -470
- pulse/transpiler/constants.py +0 -110
- pulse/transpiler/context.py +0 -26
- pulse/transpiler/ids.py +0 -16
- pulse/transpiler/modules/re.py +0 -466
- pulse/transpiler/modules/tags.py +0 -268
- pulse/transpiler/utils.py +0 -4
- pulse/vdom.py +0 -599
- pulse_framework-0.1.51.dist-info/RECORD +0 -119
- /pulse/{html → dom}/__init__.py +0 -0
- /pulse/{html → dom}/elements.py +0 -0
- /pulse/{html → dom}/svg.py +0 -0
- {pulse_framework-0.1.51.dist-info → pulse_framework-0.1.53.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.51.dist-info → pulse_framework-0.1.53.dist-info}/entry_points.txt +0 -0
pulse/cli/helpers.py
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import importlib
|
|
2
4
|
import importlib.util
|
|
3
5
|
import platform
|
|
4
6
|
import sys
|
|
5
7
|
from pathlib import Path
|
|
6
|
-
from typing import Literal, TypedDict
|
|
8
|
+
from typing import TYPE_CHECKING, Literal, TypedDict
|
|
7
9
|
|
|
8
10
|
import typer
|
|
9
|
-
from rich.console import Console
|
|
10
11
|
|
|
11
12
|
from pulse.cli.models import AppLoadResult
|
|
12
13
|
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from pulse.cli.logging import CLILogger
|
|
16
|
+
|
|
13
17
|
|
|
14
18
|
def os_family() -> Literal["windows", "mac", "linux"]:
|
|
15
19
|
s = platform.system().lower()
|
|
@@ -110,66 +114,35 @@ def parse_app_target(target: str) -> ParsedAppTarget:
|
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
|
|
113
|
-
def
|
|
114
|
-
"""Load
|
|
117
|
+
def load_app_from_target(target: str, logger: CLILogger | None = None) -> AppLoadResult:
|
|
118
|
+
"""Load an App instance from either a file path (with optional :var) or a module path (uvicorn style).
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
target: The app target string (file path or module path)
|
|
122
|
+
logger: Optional CLILogger for error output. If not provided, uses basic print/traceback.
|
|
123
|
+
"""
|
|
115
124
|
# Avoid circular import
|
|
116
125
|
from pulse.app import App
|
|
117
126
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if not file_path.suffix == ".py":
|
|
125
|
-
typer.echo(f"❌ File must be a Python file (.py): {file_path}")
|
|
126
|
-
raise typer.Exit(1)
|
|
127
|
-
|
|
128
|
-
# clear_routes()
|
|
129
|
-
sys.path.insert(0, str(file_path.parent.absolute()))
|
|
130
|
-
|
|
131
|
-
try:
|
|
132
|
-
spec = importlib.util.spec_from_file_location("user_app", file_path)
|
|
133
|
-
if spec is None or spec.loader is None:
|
|
134
|
-
typer.echo(f"❌ Could not load module from: {file_path}")
|
|
135
|
-
raise typer.Exit(1)
|
|
136
|
-
|
|
137
|
-
module = importlib.util.module_from_spec(spec)
|
|
138
|
-
spec.loader.exec_module(module)
|
|
139
|
-
|
|
140
|
-
if hasattr(module, "app") and isinstance(module.app, App):
|
|
141
|
-
app_instance = module.app
|
|
142
|
-
if not app_instance.routes:
|
|
143
|
-
typer.echo(f"⚠️ No routes found in {file_path}")
|
|
144
|
-
return AppLoadResult(
|
|
145
|
-
target=str(file_path),
|
|
146
|
-
mode="path",
|
|
147
|
-
app=app_instance,
|
|
148
|
-
module_name="user_app",
|
|
149
|
-
app_var="app",
|
|
150
|
-
app_file=file_path.resolve(),
|
|
151
|
-
app_dir=file_path.parent.resolve(),
|
|
152
|
-
server_cwd=file_path.parent.resolve(),
|
|
153
|
-
)
|
|
154
|
-
|
|
155
|
-
typer.echo(f"⚠️ No app found in {file_path}")
|
|
156
|
-
raise typer.Exit(1)
|
|
157
|
-
|
|
158
|
-
except Exception:
|
|
159
|
-
console = Console()
|
|
160
|
-
console.log(f"❌ Error loading {file_path}")
|
|
161
|
-
console.print_exception()
|
|
162
|
-
raise typer.Exit(1) from None
|
|
163
|
-
finally:
|
|
164
|
-
if str(file_path.parent.absolute()) in sys.path:
|
|
165
|
-
sys.path.remove(str(file_path.parent.absolute()))
|
|
127
|
+
def _log_error(message: str) -> None:
|
|
128
|
+
if logger:
|
|
129
|
+
logger.error(message)
|
|
130
|
+
else:
|
|
131
|
+
print(f"Error: {message}")
|
|
166
132
|
|
|
133
|
+
def _log_warning(message: str) -> None:
|
|
134
|
+
if logger:
|
|
135
|
+
logger.warning(message)
|
|
136
|
+
else:
|
|
137
|
+
print(f"Warning: {message}")
|
|
167
138
|
|
|
168
|
-
def
|
|
169
|
-
|
|
139
|
+
def _print_exception() -> None:
|
|
140
|
+
if logger:
|
|
141
|
+
logger.print_exception()
|
|
142
|
+
else:
|
|
143
|
+
import traceback
|
|
170
144
|
|
|
171
|
-
|
|
172
|
-
from pulse.app import App
|
|
145
|
+
traceback.print_exc()
|
|
173
146
|
|
|
174
147
|
parsed = parse_app_target(target)
|
|
175
148
|
|
|
@@ -181,22 +154,23 @@ def load_app_from_target(target: str) -> AppLoadResult:
|
|
|
181
154
|
if parsed["mode"] == "path":
|
|
182
155
|
file_path = parsed["file_path"]
|
|
183
156
|
if file_path is None:
|
|
184
|
-
|
|
157
|
+
_log_error(f"Could not determine a Python file from: {target}")
|
|
185
158
|
raise typer.Exit(1)
|
|
186
159
|
|
|
187
160
|
sys.path.insert(0, str(file_path.parent.absolute()))
|
|
188
161
|
try:
|
|
189
162
|
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
190
163
|
if spec is None or spec.loader is None:
|
|
191
|
-
|
|
164
|
+
_log_error(f"Could not load module from: {file_path}")
|
|
192
165
|
raise typer.Exit(1)
|
|
193
166
|
module = importlib.util.module_from_spec(spec)
|
|
194
167
|
sys.modules[spec.name] = module
|
|
195
168
|
spec.loader.exec_module(module)
|
|
169
|
+
except typer.Exit:
|
|
170
|
+
raise
|
|
196
171
|
except Exception:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
console.print_exception()
|
|
172
|
+
_log_error(f"Error loading {file_path}")
|
|
173
|
+
_print_exception()
|
|
200
174
|
raise typer.Exit(1) from None
|
|
201
175
|
finally:
|
|
202
176
|
if str(file_path.parent.absolute()) in sys.path:
|
|
@@ -210,9 +184,8 @@ def load_app_from_target(target: str) -> AppLoadResult:
|
|
|
210
184
|
try:
|
|
211
185
|
module = importlib.import_module(module_name) # type: ignore[name-defined]
|
|
212
186
|
except Exception:
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
console.print_exception()
|
|
187
|
+
_log_error(f"Error importing module: {module_name}")
|
|
188
|
+
_print_exception()
|
|
216
189
|
raise typer.Exit(1) from None
|
|
217
190
|
|
|
218
191
|
# Try to set env paths from the resolved module file
|
|
@@ -225,14 +198,14 @@ def load_app_from_target(target: str) -> AppLoadResult:
|
|
|
225
198
|
|
|
226
199
|
# Fetch the app attribute
|
|
227
200
|
if not hasattr(loaded_module, app_var):
|
|
228
|
-
|
|
201
|
+
_log_error(f"App variable '{app_var}' not found in {module_name}")
|
|
229
202
|
raise typer.Exit(1)
|
|
230
203
|
app_candidate = getattr(loaded_module, app_var)
|
|
231
204
|
if not isinstance(app_candidate, App):
|
|
232
|
-
|
|
205
|
+
_log_error(f"'{app_var}' in {module_name} is not a pulse.App instance")
|
|
233
206
|
raise typer.Exit(1)
|
|
234
207
|
if not app_candidate.routes:
|
|
235
|
-
|
|
208
|
+
_log_warning("No routes found")
|
|
236
209
|
return AppLoadResult(
|
|
237
210
|
target=target,
|
|
238
211
|
mode=parsed["mode"],
|
pulse/cli/logging.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mode-aware CLI logging for Pulse.
|
|
3
|
+
|
|
4
|
+
In dev mode, uses Rich Console with colors.
|
|
5
|
+
In ci/prod mode or with --plain, uses plain print().
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from typing import TYPE_CHECKING, Literal
|
|
12
|
+
|
|
13
|
+
from pulse.env import PulseEnv
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
TagMode = Literal["colored", "plain"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CLILogger:
|
|
22
|
+
"""Mode-aware CLI logger that adapts output based on pulse environment.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
mode: The pulse environment mode (dev, ci, prod)
|
|
26
|
+
plain: Force plain output without colors, even in dev mode
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
mode: PulseEnv
|
|
30
|
+
plain: bool
|
|
31
|
+
_console: Console | None
|
|
32
|
+
|
|
33
|
+
def __init__(self, mode: PulseEnv = "dev", *, plain: bool = False):
|
|
34
|
+
self.mode = mode
|
|
35
|
+
self.plain = plain
|
|
36
|
+
self._console = None
|
|
37
|
+
if mode == "dev" and not plain:
|
|
38
|
+
from rich.console import Console
|
|
39
|
+
|
|
40
|
+
self._console = Console()
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_plain(self) -> bool:
|
|
44
|
+
"""Return True if using plain output (ci/prod mode or --plain flag)."""
|
|
45
|
+
return self.mode != "dev" or self.plain
|
|
46
|
+
|
|
47
|
+
def print(self, message: str) -> None:
|
|
48
|
+
"""Print a message."""
|
|
49
|
+
if self._console:
|
|
50
|
+
self._console.print(message)
|
|
51
|
+
else:
|
|
52
|
+
print(message)
|
|
53
|
+
|
|
54
|
+
def error(self, message: str) -> None:
|
|
55
|
+
"""Print an error message."""
|
|
56
|
+
if self._console:
|
|
57
|
+
self._console.print(f"[red]Error:[/red] {message}")
|
|
58
|
+
else:
|
|
59
|
+
print(f"Error: {message}")
|
|
60
|
+
|
|
61
|
+
def success(self, message: str) -> None:
|
|
62
|
+
"""Print a success message."""
|
|
63
|
+
if self._console:
|
|
64
|
+
self._console.print(f"[green]✓[/green] {message}")
|
|
65
|
+
else:
|
|
66
|
+
print(f"Done: {message}")
|
|
67
|
+
|
|
68
|
+
def warning(self, message: str) -> None:
|
|
69
|
+
"""Print a warning message."""
|
|
70
|
+
if self._console:
|
|
71
|
+
self._console.print(f"[yellow]Warning:[/yellow] {message}")
|
|
72
|
+
else:
|
|
73
|
+
print(f"Warning: {message}")
|
|
74
|
+
|
|
75
|
+
def print_exception(self) -> None:
|
|
76
|
+
"""Print the current exception."""
|
|
77
|
+
if self._console:
|
|
78
|
+
self._console.print_exception()
|
|
79
|
+
else:
|
|
80
|
+
import traceback
|
|
81
|
+
|
|
82
|
+
traceback.print_exc()
|
|
83
|
+
|
|
84
|
+
def get_tag_mode(self) -> TagMode:
|
|
85
|
+
"""Return tag mode for process output: colored in dev, plain in ci/prod."""
|
|
86
|
+
return "plain" if self.is_plain else "colored"
|
|
87
|
+
|
|
88
|
+
def write_ready_announcement(
|
|
89
|
+
self, address: str, port: int, server_url: str
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Write the 'Pulse is ready' announcement."""
|
|
92
|
+
if self._console:
|
|
93
|
+
self._console.print("")
|
|
94
|
+
self._console.print(
|
|
95
|
+
f"[bold green]Ready:[/bold green] [bold cyan][link={server_url}]{server_url}[/link][/bold cyan]"
|
|
96
|
+
)
|
|
97
|
+
self._console.print("")
|
|
98
|
+
else:
|
|
99
|
+
print("")
|
|
100
|
+
print(f"Ready: {server_url}")
|
|
101
|
+
print("")
|
|
102
|
+
sys.stdout.flush()
|
pulse/cli/packages.py
CHANGED
|
@@ -116,6 +116,22 @@ def pick_more_specific(a: str | None, b: str | None) -> str | None:
|
|
|
116
116
|
return a
|
|
117
117
|
if b_exact:
|
|
118
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
|
+
|
|
119
135
|
# Prefer longer constraint as proxy for specificity
|
|
120
136
|
return a if len(a) >= len(b) else b
|
|
121
137
|
|
pulse/cli/processes.py
CHANGED
|
@@ -8,23 +8,26 @@ import select
|
|
|
8
8
|
import signal
|
|
9
9
|
import subprocess
|
|
10
10
|
import sys
|
|
11
|
-
from collections.abc import
|
|
11
|
+
from collections.abc import Sequence
|
|
12
12
|
from io import TextIOBase
|
|
13
13
|
from typing import TypeVar, cast
|
|
14
14
|
|
|
15
|
-
from rich.console import Console
|
|
16
|
-
|
|
17
15
|
from pulse.cli.helpers import os_family
|
|
16
|
+
from pulse.cli.logging import TagMode
|
|
18
17
|
from pulse.cli.models import CommandSpec
|
|
19
18
|
|
|
20
19
|
_K = TypeVar("_K", int, str)
|
|
21
20
|
|
|
21
|
+
# ANSI color codes for tagged output
|
|
22
22
|
ANSI_CODES = {
|
|
23
23
|
"cyan": "\033[36m",
|
|
24
24
|
"orange1": "\033[38;5;208m",
|
|
25
|
-
"
|
|
25
|
+
"reset": "\033[0m",
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
# Tag colors mapping (used only in colored mode)
|
|
29
|
+
TAG_COLORS = {"server": "cyan", "web": "orange1"}
|
|
30
|
+
|
|
28
31
|
# Regex to strip ANSI escape codes
|
|
29
32
|
ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
30
33
|
|
|
@@ -32,23 +35,27 @@ ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
|
32
35
|
def execute_commands(
|
|
33
36
|
commands: Sequence[CommandSpec],
|
|
34
37
|
*,
|
|
35
|
-
|
|
36
|
-
tag_colors: Mapping[str, str] | None = None,
|
|
38
|
+
tag_mode: TagMode = "colored",
|
|
37
39
|
) -> int:
|
|
38
|
-
"""Run the provided commands, streaming tagged output to stdout.
|
|
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
|
+
"""
|
|
39
48
|
if not commands:
|
|
40
49
|
return 0
|
|
41
50
|
|
|
42
|
-
color_lookup = dict(tag_colors or {})
|
|
43
|
-
|
|
44
51
|
# Avoid pty.fork() in multi-threaded environments (like pytest) to prevent
|
|
45
52
|
# "DeprecationWarning: This process is multi-threaded, use of forkpty() may lead to deadlocks"
|
|
46
53
|
# Also skip pty on Windows or if fork is unavailable
|
|
47
54
|
in_pytest = "pytest" in sys.modules
|
|
48
55
|
if os_family() == "windows" or not hasattr(pty, "fork") or in_pytest:
|
|
49
|
-
return _run_without_pty(commands,
|
|
56
|
+
return _run_without_pty(commands, tag_mode=tag_mode)
|
|
50
57
|
|
|
51
|
-
return _run_with_pty(commands,
|
|
58
|
+
return _run_with_pty(commands, tag_mode=tag_mode)
|
|
52
59
|
|
|
53
60
|
|
|
54
61
|
def _call_on_spawn(spec: CommandSpec) -> None:
|
|
@@ -82,8 +89,7 @@ def _check_on_ready(
|
|
|
82
89
|
def _run_with_pty(
|
|
83
90
|
commands: Sequence[CommandSpec],
|
|
84
91
|
*,
|
|
85
|
-
|
|
86
|
-
colors: Mapping[str, str],
|
|
92
|
+
tag_mode: TagMode,
|
|
87
93
|
) -> int:
|
|
88
94
|
procs: list[tuple[str, int, int]] = []
|
|
89
95
|
fd_to_spec: dict[int, CommandSpec] = {}
|
|
@@ -138,7 +144,7 @@ def _run_with_pty(
|
|
|
138
144
|
decoded = line.decode(errors="replace")
|
|
139
145
|
if decoded:
|
|
140
146
|
spec = fd_to_spec[fd]
|
|
141
|
-
_write_tagged_line(spec.name, decoded,
|
|
147
|
+
_write_tagged_line(spec.name, decoded, tag_mode)
|
|
142
148
|
_check_on_ready(spec, decoded, ready_flags, fd)
|
|
143
149
|
except OSError:
|
|
144
150
|
continue
|
|
@@ -173,8 +179,7 @@ def _run_with_pty(
|
|
|
173
179
|
def _run_without_pty(
|
|
174
180
|
commands: Sequence[CommandSpec],
|
|
175
181
|
*,
|
|
176
|
-
|
|
177
|
-
colors: Mapping[str, str],
|
|
182
|
+
tag_mode: TagMode,
|
|
178
183
|
) -> int:
|
|
179
184
|
from selectors import EVENT_READ, DefaultSelector
|
|
180
185
|
|
|
@@ -211,7 +216,7 @@ def _run_without_pty(
|
|
|
211
216
|
# stream is now guaranteed to be a file-like object
|
|
212
217
|
line = cast(TextIOBase, stream).readline()
|
|
213
218
|
if line:
|
|
214
|
-
_write_tagged_line(name, line.rstrip("\n"),
|
|
219
|
+
_write_tagged_line(name, line.rstrip("\n"), tag_mode)
|
|
215
220
|
spec = next((s for n, _, s in procs if n == name), None)
|
|
216
221
|
if spec:
|
|
217
222
|
_check_on_ready(spec, line, ready_flags, name)
|
|
@@ -251,7 +256,16 @@ def _run_without_pty(
|
|
|
251
256
|
return max(exit_codes) if exit_codes else 0
|
|
252
257
|
|
|
253
258
|
|
|
254
|
-
def _write_tagged_line(name: str, message: str,
|
|
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
|
+
"""
|
|
255
269
|
# Filter out unwanted web server messages
|
|
256
270
|
clean_message = ANSI_ESCAPE.sub("", message)
|
|
257
271
|
if (
|
|
@@ -261,12 +275,15 @@ def _write_tagged_line(name: str, message: str, colors: Mapping[str, str]) -> No
|
|
|
261
275
|
):
|
|
262
276
|
return
|
|
263
277
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
color
|
|
267
|
-
|
|
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")
|
|
268
284
|
else:
|
|
269
|
-
|
|
285
|
+
# Plain mode: tags without color
|
|
286
|
+
sys.stdout.write(f"[{name}] {message}\n")
|
|
270
287
|
sys.stdout.flush()
|
|
271
288
|
|
|
272
289
|
|
pulse/codegen/codegen.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import shutil
|
|
2
3
|
from collections.abc import Sequence
|
|
3
4
|
from dataclasses import dataclass
|
|
4
5
|
from pathlib import Path
|
|
@@ -13,7 +14,7 @@ from pulse.codegen.templates.routes_ts import (
|
|
|
13
14
|
)
|
|
14
15
|
from pulse.env import env
|
|
15
16
|
from pulse.routing import Layout, Route, RouteTree
|
|
16
|
-
from pulse.transpiler
|
|
17
|
+
from pulse.transpiler import get_registered_imports
|
|
17
18
|
|
|
18
19
|
if TYPE_CHECKING:
|
|
19
20
|
from pulse.app import ConnectionStatusConfig
|
|
@@ -99,14 +100,16 @@ class Codegen:
|
|
|
99
100
|
def __init__(self, routes: RouteTree, config: CodegenConfig) -> None:
|
|
100
101
|
self.cfg = config
|
|
101
102
|
self.routes = routes
|
|
102
|
-
self.
|
|
103
|
-
# Maps source path -> destination path for CSS files
|
|
104
|
-
self._css_dest_paths: dict[str, Path] = {}
|
|
103
|
+
self._copied_files: set[Path] = set()
|
|
105
104
|
|
|
106
105
|
@property
|
|
107
106
|
def output_folder(self):
|
|
108
107
|
return self.cfg.pulse_path
|
|
109
108
|
|
|
109
|
+
@property
|
|
110
|
+
def assets_folder(self):
|
|
111
|
+
return self.output_folder / "assets"
|
|
112
|
+
|
|
110
113
|
def generate_all(
|
|
111
114
|
self,
|
|
112
115
|
server_address: str,
|
|
@@ -117,11 +120,10 @@ class Codegen:
|
|
|
117
120
|
# Ensure generated files are gitignored
|
|
118
121
|
ensure_gitignore_has(self.cfg.web_root, f"app/{self.cfg.pulse_dir}/")
|
|
119
122
|
|
|
120
|
-
self.
|
|
121
|
-
self._css_dest_paths = {}
|
|
123
|
+
self._copied_files = set()
|
|
122
124
|
|
|
123
|
-
# Copy all registered
|
|
124
|
-
self.
|
|
125
|
+
# Copy all registered local files to the assets directory
|
|
126
|
+
asset_import_paths = self._copy_local_files()
|
|
125
127
|
|
|
126
128
|
# Keep track of all generated files
|
|
127
129
|
generated_files = set(
|
|
@@ -135,12 +137,16 @@ class Codegen:
|
|
|
135
137
|
self.generate_routes_ts(),
|
|
136
138
|
self.generate_routes_runtime_ts(),
|
|
137
139
|
*(
|
|
138
|
-
self.generate_route(
|
|
140
|
+
self.generate_route(
|
|
141
|
+
route,
|
|
142
|
+
server_address=server_address,
|
|
143
|
+
asset_import_paths=asset_import_paths,
|
|
144
|
+
)
|
|
139
145
|
for route in self.routes.flat_tree.values()
|
|
140
146
|
),
|
|
141
147
|
]
|
|
142
148
|
)
|
|
143
|
-
generated_files.update(self.
|
|
149
|
+
generated_files.update(self._copied_files)
|
|
144
150
|
|
|
145
151
|
# Clean up any remaining files that are not part of the generated files
|
|
146
152
|
for path in self.output_folder.rglob("*"):
|
|
@@ -151,31 +157,52 @@ class Codegen:
|
|
|
151
157
|
except Exception as e:
|
|
152
158
|
logger.warning(f"Could not remove stale file {path}: {e}")
|
|
153
159
|
|
|
154
|
-
def
|
|
155
|
-
"""Copy all registered local
|
|
156
|
-
|
|
160
|
+
def _copy_local_files(self) -> dict[str, str]:
|
|
161
|
+
"""Copy all registered local files to the assets directory.
|
|
162
|
+
|
|
163
|
+
Collects all Import objects with is_local=True and copies their
|
|
164
|
+
source files to the assets folder, returning an import path mapping.
|
|
165
|
+
"""
|
|
166
|
+
imports = get_registered_imports()
|
|
167
|
+
local_imports = [imp for imp in imports if imp.is_local]
|
|
168
|
+
|
|
169
|
+
if not local_imports:
|
|
170
|
+
return {}
|
|
157
171
|
|
|
158
|
-
|
|
172
|
+
self.assets_folder.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
asset_import_paths: dict[str, str] = {}
|
|
159
174
|
|
|
160
|
-
for imp in
|
|
161
|
-
if
|
|
175
|
+
for imp in local_imports:
|
|
176
|
+
if imp.source_path is None:
|
|
162
177
|
continue
|
|
163
178
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
generated_filename = imp.generated_filename
|
|
167
|
-
assert source_path is not None and generated_filename is not None
|
|
179
|
+
asset_filename = imp.asset_filename()
|
|
180
|
+
dest_path = self.assets_folder / asset_filename
|
|
168
181
|
|
|
169
|
-
if
|
|
170
|
-
|
|
171
|
-
|
|
182
|
+
# Copy file if source exists
|
|
183
|
+
if imp.source_path.exists():
|
|
184
|
+
shutil.copy2(imp.source_path, dest_path)
|
|
185
|
+
self._copied_files.add(dest_path)
|
|
186
|
+
logger.debug(f"Copied {imp.source_path} -> {dest_path}")
|
|
187
|
+
|
|
188
|
+
# Store just the asset filename - the relative path is computed per-route
|
|
189
|
+
asset_import_paths[imp.src] = asset_filename
|
|
172
190
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
191
|
+
return asset_import_paths
|
|
192
|
+
|
|
193
|
+
def _compute_asset_prefix(self, route_file_path: str) -> str:
|
|
194
|
+
"""Compute the relative path prefix from a route file to the assets folder.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
route_file_path: The route's file path (e.g., "users/_id_xxx.jsx")
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
The relative path prefix (e.g., "../assets/" or "../../assets/")
|
|
201
|
+
"""
|
|
202
|
+
# Count directory depth: each "/" in the path adds one level
|
|
203
|
+
depth = route_file_path.count("/")
|
|
204
|
+
# Add 1 for the routes/ or layouts/ folder itself
|
|
205
|
+
return "../" * (depth + 1) + "assets/"
|
|
179
206
|
|
|
180
207
|
def generate_layout_tsx(
|
|
181
208
|
self,
|
|
@@ -250,17 +277,25 @@ class Codegen:
|
|
|
250
277
|
)
|
|
251
278
|
return "\n".join(lines)
|
|
252
279
|
|
|
253
|
-
def generate_route(
|
|
280
|
+
def generate_route(
|
|
281
|
+
self,
|
|
282
|
+
route: Route | Layout,
|
|
283
|
+
server_address: str,
|
|
284
|
+
asset_import_paths: dict[str, str],
|
|
285
|
+
):
|
|
286
|
+
route_file_path = route.file_path()
|
|
254
287
|
if isinstance(route, Layout):
|
|
255
|
-
output_path = self.output_folder / "layouts" /
|
|
288
|
+
output_path = self.output_folder / "layouts" / route_file_path
|
|
256
289
|
else:
|
|
257
|
-
output_path = self.output_folder / "routes" /
|
|
290
|
+
output_path = self.output_folder / "routes" / route_file_path
|
|
291
|
+
|
|
292
|
+
# Compute asset prefix based on route depth
|
|
293
|
+
asset_prefix = self._compute_asset_prefix(route_file_path)
|
|
258
294
|
|
|
259
295
|
content = generate_route(
|
|
260
296
|
path=route.unique_path(),
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
css_dir=self.output_folder / "css",
|
|
297
|
+
asset_filenames=asset_import_paths,
|
|
298
|
+
asset_prefix=asset_prefix,
|
|
264
299
|
)
|
|
265
300
|
return write_file_if_changed(output_path, content)
|
|
266
301
|
|
pulse/codegen/js.py
CHANGED
|
@@ -43,10 +43,8 @@ class ExternalJsFunction(Generic[*Args, R]):
|
|
|
43
43
|
is_default: bool,
|
|
44
44
|
hint: Callable[[*Args], R],
|
|
45
45
|
) -> None:
|
|
46
|
-
if is_default
|
|
47
|
-
|
|
48
|
-
else:
|
|
49
|
-
self.import_ = Import.named(name, src)
|
|
46
|
+
kind = "default" if is_default else "named"
|
|
47
|
+
self.import_ = Import(name, src, kind=kind)
|
|
50
48
|
self._prop = prop
|
|
51
49
|
self.hint = hint
|
|
52
50
|
|