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/cmd.py
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for Pulse UI.
|
|
3
|
+
This module provides the CLI commands for running the server and generating routes.
|
|
4
|
+
"""
|
|
5
|
+
# typer relies on function calls used as default values
|
|
6
|
+
# pyright: reportCallInDefaultInitializer=false
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Callable, cast
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from pulse.cli.dependencies import (
|
|
20
|
+
DependencyError,
|
|
21
|
+
DependencyPlan,
|
|
22
|
+
DependencyResolutionError,
|
|
23
|
+
check_web_dependencies,
|
|
24
|
+
prepare_web_dependencies,
|
|
25
|
+
)
|
|
26
|
+
from pulse.cli.folder_lock import FolderLock
|
|
27
|
+
from pulse.cli.helpers import load_app_from_target
|
|
28
|
+
from pulse.cli.logging import CLILogger
|
|
29
|
+
from pulse.cli.models import AppLoadResult, CommandSpec
|
|
30
|
+
from pulse.cli.processes import execute_commands
|
|
31
|
+
from pulse.cli.secrets import resolve_dev_secret
|
|
32
|
+
from pulse.cli.uvicorn_log_config import get_log_config
|
|
33
|
+
from pulse.env import (
|
|
34
|
+
ENV_PULSE_DISABLE_CODEGEN,
|
|
35
|
+
ENV_PULSE_HOST,
|
|
36
|
+
ENV_PULSE_PORT,
|
|
37
|
+
ENV_PULSE_REACT_SERVER_ADDRESS,
|
|
38
|
+
ENV_PULSE_SECRET,
|
|
39
|
+
PulseEnv,
|
|
40
|
+
env,
|
|
41
|
+
)
|
|
42
|
+
from pulse.helpers import find_available_port
|
|
43
|
+
from pulse.version import __version__ as PULSE_PY_VERSION
|
|
44
|
+
|
|
45
|
+
cli = typer.Typer(
|
|
46
|
+
name="pulse",
|
|
47
|
+
help="Pulse UI - Python to TypeScript bridge with server-side callbacks",
|
|
48
|
+
no_args_is_help=True,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@cli.command(
|
|
53
|
+
"run", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}
|
|
54
|
+
)
|
|
55
|
+
def run(
|
|
56
|
+
ctx: typer.Context,
|
|
57
|
+
app_file: str = typer.Argument(
|
|
58
|
+
...,
|
|
59
|
+
help=("App target: 'path/to/app.py[:var]' (default :app) or 'module.path:var'"),
|
|
60
|
+
),
|
|
61
|
+
address: str = typer.Option(
|
|
62
|
+
"localhost",
|
|
63
|
+
"--address",
|
|
64
|
+
help="Host uvicorn binds to",
|
|
65
|
+
),
|
|
66
|
+
port: int = typer.Option(8000, "--port", help="Port uvicorn binds to"),
|
|
67
|
+
# Env flags
|
|
68
|
+
dev: bool = typer.Option(False, "--dev", help="Run in development mode"),
|
|
69
|
+
prod: bool = typer.Option(False, "--prod", help="Run in production mode"),
|
|
70
|
+
plain: bool = typer.Option(
|
|
71
|
+
False, "--plain", help="Use plain output without colors or emojis"
|
|
72
|
+
),
|
|
73
|
+
server_only: bool = typer.Option(False, "--server-only", "--backend-only"),
|
|
74
|
+
web_only: bool = typer.Option(False, "--web-only"),
|
|
75
|
+
react_server_address: str | None = typer.Option(
|
|
76
|
+
None,
|
|
77
|
+
"--react-server-address",
|
|
78
|
+
help="Full URL of React server (required for single-server + --server-only)",
|
|
79
|
+
),
|
|
80
|
+
reload: bool | None = typer.Option(None, "--reload/--no-reload"),
|
|
81
|
+
find_port: bool = typer.Option(True, "--find-port/--no-find-port"),
|
|
82
|
+
verbose: bool = typer.Option(
|
|
83
|
+
False, "--verbose", help="Show all logs without filtering"
|
|
84
|
+
),
|
|
85
|
+
):
|
|
86
|
+
"""Run the Pulse server and web development server together."""
|
|
87
|
+
extra_flags = list(ctx.args)
|
|
88
|
+
|
|
89
|
+
# Validate mode flags (dev is default if neither specified)
|
|
90
|
+
if dev and prod:
|
|
91
|
+
logger = CLILogger("dev", plain=plain)
|
|
92
|
+
logger.error("Please specify only one of --dev or --prod.")
|
|
93
|
+
raise typer.Exit(1)
|
|
94
|
+
|
|
95
|
+
# Set mode: prod if specified, otherwise dev (default)
|
|
96
|
+
mode: PulseEnv = "prod" if prod else "dev"
|
|
97
|
+
env.pulse_env = mode
|
|
98
|
+
logger = CLILogger(mode, plain=plain)
|
|
99
|
+
|
|
100
|
+
# Turn on reload in dev only
|
|
101
|
+
if reload is None:
|
|
102
|
+
reload = env.pulse_env == "dev"
|
|
103
|
+
|
|
104
|
+
if server_only and web_only:
|
|
105
|
+
logger.error("Cannot use --server-only and --web-only at the same time.")
|
|
106
|
+
raise typer.Exit(1)
|
|
107
|
+
|
|
108
|
+
if find_port:
|
|
109
|
+
port = find_available_port(port)
|
|
110
|
+
|
|
111
|
+
logger.print(f"Loading app from {app_file}")
|
|
112
|
+
app_ctx = load_app_from_target(app_file, logger)
|
|
113
|
+
_apply_app_context_to_env(app_ctx)
|
|
114
|
+
app_instance = app_ctx.app
|
|
115
|
+
|
|
116
|
+
is_single_server = app_instance.mode == "single-server"
|
|
117
|
+
if is_single_server:
|
|
118
|
+
logger.print("Single-server mode")
|
|
119
|
+
|
|
120
|
+
# In single-server + server-only mode, require explicit React server address
|
|
121
|
+
if is_single_server and server_only:
|
|
122
|
+
if not react_server_address:
|
|
123
|
+
logger.error(
|
|
124
|
+
"--react-server-address is required when using single-server mode with --server-only."
|
|
125
|
+
)
|
|
126
|
+
raise typer.Exit(1)
|
|
127
|
+
os.environ[ENV_PULSE_REACT_SERVER_ADDRESS] = react_server_address
|
|
128
|
+
|
|
129
|
+
web_root = app_instance.codegen.cfg.web_root
|
|
130
|
+
if not web_root.exists() and not server_only:
|
|
131
|
+
logger.error(f"Directory not found: {web_root.absolute()}")
|
|
132
|
+
raise typer.Exit(1)
|
|
133
|
+
|
|
134
|
+
dev_secret: str | None = None
|
|
135
|
+
if app_instance.env != "prod":
|
|
136
|
+
dev_secret = os.environ.get(ENV_PULSE_SECRET) or resolve_dev_secret(
|
|
137
|
+
web_root if web_root.exists() else app_ctx.app_file
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if env.pulse_env == "dev" and not server_only:
|
|
141
|
+
try:
|
|
142
|
+
to_add = check_web_dependencies(
|
|
143
|
+
web_root,
|
|
144
|
+
pulse_version=PULSE_PY_VERSION,
|
|
145
|
+
)
|
|
146
|
+
except DependencyResolutionError as exc:
|
|
147
|
+
logger.error(str(exc))
|
|
148
|
+
raise typer.Exit(1) from None
|
|
149
|
+
except DependencyError as exc:
|
|
150
|
+
logger.error(str(exc))
|
|
151
|
+
raise typer.Exit(1) from None
|
|
152
|
+
|
|
153
|
+
if to_add:
|
|
154
|
+
try:
|
|
155
|
+
dep_plan = prepare_web_dependencies(
|
|
156
|
+
web_root,
|
|
157
|
+
pulse_version=PULSE_PY_VERSION,
|
|
158
|
+
)
|
|
159
|
+
if dep_plan:
|
|
160
|
+
_run_dependency_plan(logger, web_root, dep_plan)
|
|
161
|
+
except subprocess.CalledProcessError:
|
|
162
|
+
logger.error("Failed to install web dependencies with Bun.")
|
|
163
|
+
raise typer.Exit(1) from None
|
|
164
|
+
|
|
165
|
+
server_args = extra_flags if not web_only else []
|
|
166
|
+
web_args = extra_flags if web_only else []
|
|
167
|
+
|
|
168
|
+
commands: list[CommandSpec] = []
|
|
169
|
+
|
|
170
|
+
# Track readiness for announcement
|
|
171
|
+
server_ready = {"server": False, "web": False}
|
|
172
|
+
announced = False
|
|
173
|
+
|
|
174
|
+
def mark_web_ready() -> None:
|
|
175
|
+
server_ready["web"] = True
|
|
176
|
+
check_and_announce()
|
|
177
|
+
|
|
178
|
+
def mark_server_ready() -> None:
|
|
179
|
+
server_ready["server"] = True
|
|
180
|
+
check_and_announce()
|
|
181
|
+
|
|
182
|
+
def check_and_announce() -> None:
|
|
183
|
+
"""Announce when all required servers are ready."""
|
|
184
|
+
nonlocal announced
|
|
185
|
+
if announced:
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
needs_server = not web_only
|
|
189
|
+
needs_web = not server_only
|
|
190
|
+
|
|
191
|
+
if needs_server and not server_ready["server"]:
|
|
192
|
+
return
|
|
193
|
+
if needs_web and not server_ready["web"]:
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
# All required servers are ready, show announcement
|
|
197
|
+
announced = True
|
|
198
|
+
protocol = "http" if address in ("127.0.0.1", "localhost") else "https"
|
|
199
|
+
server_url = f"{protocol}://{address}:{port}"
|
|
200
|
+
logger.write_ready_announcement(address, port, server_url)
|
|
201
|
+
|
|
202
|
+
# Build web command first (when needed) so we can set PULSE_REACT_SERVER_ADDRESS
|
|
203
|
+
# before building the uvicorn command, which needs that env var
|
|
204
|
+
if not server_only:
|
|
205
|
+
web_port = find_available_port(5173)
|
|
206
|
+
web_cmd = build_web_command(
|
|
207
|
+
web_root=web_root,
|
|
208
|
+
extra_args=web_args,
|
|
209
|
+
port=web_port,
|
|
210
|
+
mode=app_instance.env,
|
|
211
|
+
ready_pattern=r"localhost:\d+",
|
|
212
|
+
on_ready=mark_web_ready,
|
|
213
|
+
plain=plain,
|
|
214
|
+
)
|
|
215
|
+
commands.append(web_cmd)
|
|
216
|
+
# Set env var so app can read the React server address (only used in single-server mode)
|
|
217
|
+
env.react_server_address = f"http://localhost:{web_port}"
|
|
218
|
+
|
|
219
|
+
if not web_only:
|
|
220
|
+
server_cmd = build_uvicorn_command(
|
|
221
|
+
app_ctx=app_ctx,
|
|
222
|
+
address=address,
|
|
223
|
+
port=port,
|
|
224
|
+
reload_enabled=reload,
|
|
225
|
+
extra_args=server_args,
|
|
226
|
+
dev_secret=dev_secret,
|
|
227
|
+
server_only=server_only,
|
|
228
|
+
web_root=web_root,
|
|
229
|
+
verbose=verbose,
|
|
230
|
+
ready_pattern=r"Application startup complete",
|
|
231
|
+
on_ready=mark_server_ready,
|
|
232
|
+
plain=plain,
|
|
233
|
+
)
|
|
234
|
+
commands.append(server_cmd)
|
|
235
|
+
|
|
236
|
+
with FolderLock(web_root):
|
|
237
|
+
try:
|
|
238
|
+
exit_code = execute_commands(
|
|
239
|
+
commands,
|
|
240
|
+
tag_mode=logger.get_tag_mode(),
|
|
241
|
+
)
|
|
242
|
+
raise typer.Exit(exit_code)
|
|
243
|
+
except RuntimeError as exc:
|
|
244
|
+
logger.error(str(exc))
|
|
245
|
+
raise typer.Exit(1) from None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@cli.command("generate")
|
|
249
|
+
def generate(
|
|
250
|
+
app_file: str = typer.Argument(
|
|
251
|
+
..., help="App target: 'path.py[:var]' (default :app) or 'module:var'"
|
|
252
|
+
),
|
|
253
|
+
# Mode flags
|
|
254
|
+
dev: bool = typer.Option(False, "--dev", help="Generate in development mode"),
|
|
255
|
+
ci: bool = typer.Option(False, "--ci", help="Generate in CI mode"),
|
|
256
|
+
prod: bool = typer.Option(False, "--prod", help="Generate in production mode"),
|
|
257
|
+
plain: bool = typer.Option(
|
|
258
|
+
False, "--plain", help="Use plain output without colors or emojis"
|
|
259
|
+
),
|
|
260
|
+
):
|
|
261
|
+
"""Generate TypeScript routes without starting the server."""
|
|
262
|
+
# Validate mode flags
|
|
263
|
+
mode_flags = [
|
|
264
|
+
name for flag, name in [(dev, "dev"), (ci, "ci"), (prod, "prod")] if flag
|
|
265
|
+
]
|
|
266
|
+
if len(mode_flags) > 1:
|
|
267
|
+
logger = CLILogger("dev", plain=plain)
|
|
268
|
+
logger.error("Please specify only one of --dev, --ci, or --prod.")
|
|
269
|
+
raise typer.Exit(1)
|
|
270
|
+
|
|
271
|
+
# Set mode: use specified mode, otherwise dev (default)
|
|
272
|
+
mode: PulseEnv = cast(PulseEnv, mode_flags[0]) if mode_flags else "dev"
|
|
273
|
+
env.pulse_env = mode
|
|
274
|
+
logger = CLILogger(mode, plain=plain)
|
|
275
|
+
|
|
276
|
+
logger.print(f"Generating routes from {app_file}")
|
|
277
|
+
env.codegen_disabled = False
|
|
278
|
+
app_ctx = load_app_from_target(app_file, logger)
|
|
279
|
+
_apply_app_context_to_env(app_ctx)
|
|
280
|
+
app = app_ctx.app
|
|
281
|
+
|
|
282
|
+
# In CI or prod mode, server_address must be provided
|
|
283
|
+
if (ci or prod) and not app.server_address:
|
|
284
|
+
logger.error(
|
|
285
|
+
"server_address must be provided when generating in CI or production mode. "
|
|
286
|
+
+ "Set it in your App constructor or via the PULSE_SERVER_ADDRESS environment variable."
|
|
287
|
+
)
|
|
288
|
+
raise typer.Exit(1)
|
|
289
|
+
|
|
290
|
+
addr = app.server_address or "http://localhost:8000"
|
|
291
|
+
try:
|
|
292
|
+
app.run_codegen(addr)
|
|
293
|
+
except Exception:
|
|
294
|
+
logger.error("Failed to generate routes")
|
|
295
|
+
logger.print_exception()
|
|
296
|
+
raise typer.Exit(1) from None
|
|
297
|
+
|
|
298
|
+
route_count = len(app.routes.flat_tree)
|
|
299
|
+
if route_count > 0:
|
|
300
|
+
logger.success(
|
|
301
|
+
f"Generated {route_count} route{'s' if route_count != 1 else ''}"
|
|
302
|
+
)
|
|
303
|
+
else:
|
|
304
|
+
logger.warning("No routes found")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@cli.command("check")
|
|
308
|
+
def check(
|
|
309
|
+
app_file: str = typer.Argument(
|
|
310
|
+
..., help="App target: 'path.py[:var]' (default :app) or 'module:var'"
|
|
311
|
+
),
|
|
312
|
+
fix: bool = typer.Option(
|
|
313
|
+
False, "--fix", help="Install missing or outdated dependencies"
|
|
314
|
+
),
|
|
315
|
+
# Mode flags
|
|
316
|
+
dev: bool = typer.Option(False, "--dev", help="Run in development mode"),
|
|
317
|
+
ci: bool = typer.Option(False, "--ci", help="Run in CI mode"),
|
|
318
|
+
prod: bool = typer.Option(False, "--prod", help="Run in production mode"),
|
|
319
|
+
plain: bool = typer.Option(
|
|
320
|
+
False, "--plain", help="Use plain output without colors or emojis"
|
|
321
|
+
),
|
|
322
|
+
):
|
|
323
|
+
"""Check if web project dependencies are in sync with Pulse app requirements."""
|
|
324
|
+
# Validate mode flags
|
|
325
|
+
mode_flags = [
|
|
326
|
+
name for flag, name in [(dev, "dev"), (ci, "ci"), (prod, "prod")] if flag
|
|
327
|
+
]
|
|
328
|
+
if len(mode_flags) > 1:
|
|
329
|
+
logger = CLILogger("dev", plain=plain)
|
|
330
|
+
logger.error("Please specify only one of --dev, --ci, or --prod.")
|
|
331
|
+
raise typer.Exit(1)
|
|
332
|
+
|
|
333
|
+
# Set mode: use specified mode, otherwise dev (default)
|
|
334
|
+
mode: PulseEnv = cast(PulseEnv, mode_flags[0]) if mode_flags else "dev"
|
|
335
|
+
env.pulse_env = mode
|
|
336
|
+
logger = CLILogger(mode, plain=plain)
|
|
337
|
+
|
|
338
|
+
logger.print(f"Checking dependencies for {app_file}")
|
|
339
|
+
app_ctx = load_app_from_target(app_file, logger)
|
|
340
|
+
_apply_app_context_to_env(app_ctx)
|
|
341
|
+
app_instance = app_ctx.app
|
|
342
|
+
|
|
343
|
+
web_root = app_instance.codegen.cfg.web_root
|
|
344
|
+
if not web_root.exists():
|
|
345
|
+
logger.error(f"Directory not found: {web_root.absolute()}")
|
|
346
|
+
raise typer.Exit(1)
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
to_add = check_web_dependencies(
|
|
350
|
+
web_root,
|
|
351
|
+
pulse_version=PULSE_PY_VERSION,
|
|
352
|
+
)
|
|
353
|
+
except DependencyResolutionError as exc:
|
|
354
|
+
logger.error(str(exc))
|
|
355
|
+
raise typer.Exit(1) from None
|
|
356
|
+
except DependencyError as exc:
|
|
357
|
+
logger.error(str(exc))
|
|
358
|
+
raise typer.Exit(1) from None
|
|
359
|
+
|
|
360
|
+
if not to_add:
|
|
361
|
+
logger.success("Dependencies in sync")
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
logger.print("Missing dependencies:")
|
|
365
|
+
for pkg in to_add:
|
|
366
|
+
logger.print(f" {pkg}")
|
|
367
|
+
|
|
368
|
+
if not fix:
|
|
369
|
+
logger.print("Run 'pulse check --fix' to install")
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
# Apply fix
|
|
373
|
+
try:
|
|
374
|
+
dep_plan = prepare_web_dependencies(
|
|
375
|
+
web_root,
|
|
376
|
+
pulse_version=PULSE_PY_VERSION,
|
|
377
|
+
)
|
|
378
|
+
if dep_plan:
|
|
379
|
+
_run_dependency_plan(logger, web_root, dep_plan)
|
|
380
|
+
logger.success("Dependencies synced")
|
|
381
|
+
except subprocess.CalledProcessError:
|
|
382
|
+
logger.error("Failed to install web dependencies with Bun.")
|
|
383
|
+
raise typer.Exit(1) from None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def build_uvicorn_command(
|
|
387
|
+
*,
|
|
388
|
+
app_ctx: AppLoadResult,
|
|
389
|
+
address: str,
|
|
390
|
+
port: int,
|
|
391
|
+
reload_enabled: bool,
|
|
392
|
+
extra_args: Sequence[str],
|
|
393
|
+
dev_secret: str | None,
|
|
394
|
+
server_only: bool,
|
|
395
|
+
web_root: Path,
|
|
396
|
+
verbose: bool = False,
|
|
397
|
+
ready_pattern: str | None = None,
|
|
398
|
+
on_ready: Callable[[], None] | None = None,
|
|
399
|
+
plain: bool = False,
|
|
400
|
+
) -> CommandSpec:
|
|
401
|
+
cwd = app_ctx.server_cwd or app_ctx.app_dir or Path.cwd()
|
|
402
|
+
app_import = f"{app_ctx.module_name}:{app_ctx.app_var}.asgi_factory"
|
|
403
|
+
args: list[str] = [
|
|
404
|
+
sys.executable,
|
|
405
|
+
"-m",
|
|
406
|
+
"uvicorn",
|
|
407
|
+
app_import,
|
|
408
|
+
"--host",
|
|
409
|
+
address,
|
|
410
|
+
"--port",
|
|
411
|
+
str(port),
|
|
412
|
+
"--factory",
|
|
413
|
+
]
|
|
414
|
+
|
|
415
|
+
if reload_enabled:
|
|
416
|
+
args.append("--reload")
|
|
417
|
+
args.extend(["--reload-include", "*.css"])
|
|
418
|
+
app_dir = app_ctx.app_dir or Path.cwd()
|
|
419
|
+
args.extend(["--reload-dir", str(app_dir)])
|
|
420
|
+
if web_root.exists():
|
|
421
|
+
args.extend(["--reload-dir", str(web_root)])
|
|
422
|
+
pulse_dir = str(app_ctx.app.codegen.cfg.pulse_dir)
|
|
423
|
+
pulse_app_dir = web_root / "app" / pulse_dir
|
|
424
|
+
rel_path = Path(os.path.relpath(pulse_app_dir, cwd))
|
|
425
|
+
if not rel_path.is_absolute():
|
|
426
|
+
args.extend(["--reload-exclude", str(rel_path)])
|
|
427
|
+
args.extend(["--reload-exclude", str(rel_path / "**")])
|
|
428
|
+
|
|
429
|
+
if app_ctx.app.env == "prod":
|
|
430
|
+
args.extend(production_flags())
|
|
431
|
+
|
|
432
|
+
if extra_args:
|
|
433
|
+
args.extend(extra_args)
|
|
434
|
+
if plain:
|
|
435
|
+
args.append("--no-use-colors")
|
|
436
|
+
|
|
437
|
+
command_env = os.environ.copy()
|
|
438
|
+
command_env.update(
|
|
439
|
+
{
|
|
440
|
+
"PYTHONUNBUFFERED": "1",
|
|
441
|
+
ENV_PULSE_HOST: address,
|
|
442
|
+
ENV_PULSE_PORT: str(port),
|
|
443
|
+
}
|
|
444
|
+
)
|
|
445
|
+
if plain:
|
|
446
|
+
command_env["NO_COLOR"] = "1"
|
|
447
|
+
command_env["FORCE_COLOR"] = "0"
|
|
448
|
+
else:
|
|
449
|
+
command_env["FORCE_COLOR"] = "1"
|
|
450
|
+
# Pass React server address to uvicorn process if set
|
|
451
|
+
if ENV_PULSE_REACT_SERVER_ADDRESS in os.environ:
|
|
452
|
+
command_env[ENV_PULSE_REACT_SERVER_ADDRESS] = os.environ[
|
|
453
|
+
ENV_PULSE_REACT_SERVER_ADDRESS
|
|
454
|
+
]
|
|
455
|
+
if app_ctx.app.env == "prod" and server_only:
|
|
456
|
+
command_env[ENV_PULSE_DISABLE_CODEGEN] = "1"
|
|
457
|
+
if dev_secret:
|
|
458
|
+
command_env[ENV_PULSE_SECRET] = dev_secret
|
|
459
|
+
|
|
460
|
+
# Apply custom log config to filter noisy requests (dev/ci only)
|
|
461
|
+
if app_ctx.app.env != "prod" and not verbose:
|
|
462
|
+
import json
|
|
463
|
+
import tempfile
|
|
464
|
+
|
|
465
|
+
log_config = get_log_config()
|
|
466
|
+
log_config_file = Path(tempfile.gettempdir()) / "pulse_uvicorn_log_config.json"
|
|
467
|
+
log_config_file.write_text(json.dumps(log_config))
|
|
468
|
+
args.extend(["--log-config", str(log_config_file)])
|
|
469
|
+
|
|
470
|
+
return CommandSpec(
|
|
471
|
+
name="server",
|
|
472
|
+
args=args,
|
|
473
|
+
cwd=cwd,
|
|
474
|
+
env=command_env,
|
|
475
|
+
ready_pattern=ready_pattern,
|
|
476
|
+
on_ready=on_ready,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def build_web_command(
|
|
481
|
+
*,
|
|
482
|
+
web_root: Path,
|
|
483
|
+
extra_args: Sequence[str],
|
|
484
|
+
port: int | None = None,
|
|
485
|
+
mode: PulseEnv = "dev",
|
|
486
|
+
ready_pattern: str | None = None,
|
|
487
|
+
on_ready: Callable[[], None] | None = None,
|
|
488
|
+
plain: bool = False,
|
|
489
|
+
) -> CommandSpec:
|
|
490
|
+
command_env = os.environ.copy()
|
|
491
|
+
if mode == "prod":
|
|
492
|
+
# Production: use built server
|
|
493
|
+
args = ["bun", "run", "start"]
|
|
494
|
+
else:
|
|
495
|
+
# Development: use dev server
|
|
496
|
+
args = ["bun", "run", "dev"]
|
|
497
|
+
|
|
498
|
+
if port is not None:
|
|
499
|
+
if mode == "prod":
|
|
500
|
+
# react-router-serve uses PORT environment variable
|
|
501
|
+
# Don't add --port flag for production
|
|
502
|
+
command_env["PORT"] = str(port)
|
|
503
|
+
else:
|
|
504
|
+
# react-router dev accepts --port flag
|
|
505
|
+
args.extend(["--port", str(port)])
|
|
506
|
+
if extra_args:
|
|
507
|
+
args.extend(extra_args)
|
|
508
|
+
|
|
509
|
+
command_env.update(
|
|
510
|
+
{
|
|
511
|
+
"PYTHONUNBUFFERED": "1",
|
|
512
|
+
}
|
|
513
|
+
)
|
|
514
|
+
if plain:
|
|
515
|
+
command_env["NO_COLOR"] = "1"
|
|
516
|
+
command_env["FORCE_COLOR"] = "0"
|
|
517
|
+
else:
|
|
518
|
+
command_env["FORCE_COLOR"] = "1"
|
|
519
|
+
|
|
520
|
+
return CommandSpec(
|
|
521
|
+
name="web",
|
|
522
|
+
args=args,
|
|
523
|
+
cwd=web_root,
|
|
524
|
+
env=command_env,
|
|
525
|
+
ready_pattern=ready_pattern,
|
|
526
|
+
on_ready=on_ready,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _apply_app_context_to_env(app_ctx: AppLoadResult) -> None:
|
|
531
|
+
if app_ctx.app_file:
|
|
532
|
+
env.pulse_app_file = str(app_ctx.app_file)
|
|
533
|
+
if app_ctx.app_dir:
|
|
534
|
+
env.pulse_app_dir = str(app_ctx.app_dir)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _run_dependency_plan(
|
|
538
|
+
logger: CLILogger, web_root: Path, plan: DependencyPlan
|
|
539
|
+
) -> None:
|
|
540
|
+
if plan.to_add:
|
|
541
|
+
logger.print(f"Installing dependencies in {web_root}")
|
|
542
|
+
subprocess.run(plan.command, cwd=web_root, check=True)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def main():
|
|
546
|
+
"""Main CLI entry point."""
|
|
547
|
+
try:
|
|
548
|
+
cli()
|
|
549
|
+
except SystemExit:
|
|
550
|
+
# Let typer.Exit and sys.exit propagate normally (no traceback)
|
|
551
|
+
raise
|
|
552
|
+
except Exception:
|
|
553
|
+
logger = CLILogger(env.pulse_env)
|
|
554
|
+
logger.print_exception()
|
|
555
|
+
sys.exit(1)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def production_flags():
|
|
559
|
+
# Prefer uvloop/http tools automatically if installed
|
|
560
|
+
flags: list[str] = []
|
|
561
|
+
try:
|
|
562
|
+
__import__("uvloop") # runtime check only
|
|
563
|
+
flags.extend(["--loop", "uvloop"])
|
|
564
|
+
except Exception:
|
|
565
|
+
pass
|
|
566
|
+
try:
|
|
567
|
+
__import__("httptools")
|
|
568
|
+
flags.extend(["--http", "httptools"])
|
|
569
|
+
except Exception:
|
|
570
|
+
pass
|
|
571
|
+
return flags
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
if __name__ == "__main__":
|
|
575
|
+
main()
|