pulse-framework 0.1.37__py3-none-any.whl → 0.1.38a2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pulse/__init__.py +2 -2
- pulse/app.py +224 -75
- pulse/cli/cmd.py +294 -394
- pulse/cli/dependencies.py +212 -0
- pulse/cli/folder_lock.py +134 -0
- pulse/cli/helpers.py +82 -43
- pulse/cli/models.py +33 -0
- pulse/cli/processes.py +225 -0
- pulse/cli/secrets.py +39 -0
- pulse/cli/uvicorn_log_config.py +87 -0
- pulse/codegen/codegen.py +16 -3
- pulse/codegen/templates/layout.py +3 -2
- pulse/cookies.py +17 -16
- pulse/env.py +8 -18
- pulse/helpers.py +19 -122
- pulse/proxy.py +192 -0
- pulse/user_session.py +2 -2
- {pulse_framework-0.1.37.dist-info → pulse_framework-0.1.38a2.dist-info}/METADATA +2 -1
- {pulse_framework-0.1.37.dist-info → pulse_framework-0.1.38a2.dist-info}/RECORD +21 -14
- {pulse_framework-0.1.37.dist-info → pulse_framework-0.1.38a2.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.37.dist-info → pulse_framework-0.1.38a2.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pulse.cli.packages import (
|
|
10
|
+
VersionConflict,
|
|
11
|
+
get_pkg_spec,
|
|
12
|
+
is_workspace_spec,
|
|
13
|
+
load_package_json,
|
|
14
|
+
parse_dependency_spec,
|
|
15
|
+
parse_install_spec,
|
|
16
|
+
resolve_versions,
|
|
17
|
+
spec_satisfies,
|
|
18
|
+
)
|
|
19
|
+
from pulse.react_component import ReactComponent, registered_react_components
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def convert_pep440_to_semver(python_version: str) -> str:
|
|
23
|
+
"""Convert PEP 440 version format to NPM semver format.
|
|
24
|
+
|
|
25
|
+
PEP 440 formats:
|
|
26
|
+
- 0.1.37a1 -> 0.1.37-alpha.1
|
|
27
|
+
- 0.1.37b1 -> 0.1.37-beta.1
|
|
28
|
+
- 0.1.37rc1 -> 0.1.37-rc.1
|
|
29
|
+
- 0.1.37.dev1 -> 0.1.37-dev.1
|
|
30
|
+
|
|
31
|
+
Non-pre-release versions are returned unchanged.
|
|
32
|
+
"""
|
|
33
|
+
# Match pre-release patterns: version followed by a/b/rc/dev + number
|
|
34
|
+
# PEP 440: a1, b1, rc1, dev1, alpha1, beta1, etc.
|
|
35
|
+
pattern = r"^(\d+\.\d+\.\d+)([a-z]+)(\d+)$"
|
|
36
|
+
match = re.match(pattern, python_version)
|
|
37
|
+
|
|
38
|
+
if match:
|
|
39
|
+
base_version = match.group(1)
|
|
40
|
+
prerelease_type = match.group(2)
|
|
41
|
+
prerelease_num = match.group(3)
|
|
42
|
+
|
|
43
|
+
# Map PEP 440 prerelease types to NPM semver
|
|
44
|
+
type_map = {
|
|
45
|
+
"a": "alpha",
|
|
46
|
+
"alpha": "alpha",
|
|
47
|
+
"b": "beta",
|
|
48
|
+
"beta": "beta",
|
|
49
|
+
"rc": "rc",
|
|
50
|
+
"c": "rc", # PEP 440 also allows 'c' for release candidate
|
|
51
|
+
"dev": "dev",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
npm_type = type_map.get(prerelease_type.lower(), prerelease_type)
|
|
55
|
+
return f"{base_version}-{npm_type}.{prerelease_num}"
|
|
56
|
+
|
|
57
|
+
# Also handle .dev format (e.g., 0.1.37.dev1)
|
|
58
|
+
pattern2 = r"^(\d+\.\d+\.\d+)\.dev(\d+)$"
|
|
59
|
+
match2 = re.match(pattern2, python_version)
|
|
60
|
+
if match2:
|
|
61
|
+
base_version = match2.group(1)
|
|
62
|
+
dev_num = match2.group(2)
|
|
63
|
+
return f"{base_version}-dev.{dev_num}"
|
|
64
|
+
|
|
65
|
+
# No pre-release, return as-is
|
|
66
|
+
return python_version
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class DependencyError(RuntimeError):
|
|
70
|
+
"""Base error for dependency preparation failures."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class DependencyResolutionError(DependencyError):
|
|
74
|
+
"""Raised when component constraints cannot be resolved."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DependencyCommandError(DependencyError):
|
|
78
|
+
"""Raised when Bun commands fail to run."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class DependencyPlan:
|
|
83
|
+
"""Return value describing the command required to sync dependencies."""
|
|
84
|
+
|
|
85
|
+
command: list[str]
|
|
86
|
+
to_add: Sequence[str]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_required_dependencies(
|
|
90
|
+
web_root: Path,
|
|
91
|
+
*,
|
|
92
|
+
pulse_version: str,
|
|
93
|
+
component_provider: Callable[
|
|
94
|
+
[], Iterable[ReactComponent[Any]]
|
|
95
|
+
] = registered_react_components,
|
|
96
|
+
) -> dict[str, str | None]:
|
|
97
|
+
"""Get the required dependencies for a Pulse app."""
|
|
98
|
+
if not web_root.exists():
|
|
99
|
+
raise DependencyError(f"Directory not found: {web_root}")
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
components = list(component_provider())
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
raise DependencyError("Unable to inspect registered React components") from exc
|
|
105
|
+
|
|
106
|
+
constraints: dict[str, list[str | None]] = {
|
|
107
|
+
"pulse-ui-client": [pulse_version],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for comp in components:
|
|
111
|
+
src = getattr(comp, "src", None)
|
|
112
|
+
component_pkg_name: str | None = None
|
|
113
|
+
if src:
|
|
114
|
+
try:
|
|
115
|
+
spec = parse_install_spec(src)
|
|
116
|
+
except ValueError as exc:
|
|
117
|
+
raise DependencyError(str(exc)) from None
|
|
118
|
+
if spec:
|
|
119
|
+
name_only, ver = parse_dependency_spec(spec)
|
|
120
|
+
constraints.setdefault(name_only, []).append(ver)
|
|
121
|
+
component_pkg_name = name_only
|
|
122
|
+
|
|
123
|
+
comp_version = getattr(comp, "version", None)
|
|
124
|
+
if comp_version and component_pkg_name:
|
|
125
|
+
constraints.setdefault(component_pkg_name, []).append(comp_version)
|
|
126
|
+
|
|
127
|
+
for extra in getattr(comp, "extra_imports", []):
|
|
128
|
+
extra_src = getattr(extra, "src", None) if extra is not None else None
|
|
129
|
+
if not extra_src:
|
|
130
|
+
continue
|
|
131
|
+
try:
|
|
132
|
+
spec2 = parse_install_spec(extra_src)
|
|
133
|
+
except ValueError as exc:
|
|
134
|
+
raise DependencyError(str(exc)) from None
|
|
135
|
+
if spec2:
|
|
136
|
+
name_only2, ver2 = parse_dependency_spec(spec2)
|
|
137
|
+
constraints.setdefault(name_only2, []).append(ver2)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
resolved = resolve_versions(constraints)
|
|
141
|
+
except VersionConflict as exc:
|
|
142
|
+
raise DependencyResolutionError(str(exc)) from None
|
|
143
|
+
|
|
144
|
+
desired: dict[str, str | None] = dict(resolved)
|
|
145
|
+
for pkg in [
|
|
146
|
+
"react-router",
|
|
147
|
+
"@react-router/node",
|
|
148
|
+
"@react-router/serve",
|
|
149
|
+
"@react-router/dev",
|
|
150
|
+
]:
|
|
151
|
+
desired.setdefault(pkg, "^7")
|
|
152
|
+
|
|
153
|
+
return desired
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def check_web_dependencies(
|
|
157
|
+
web_root: Path,
|
|
158
|
+
*,
|
|
159
|
+
pulse_version: str,
|
|
160
|
+
component_provider: Callable[
|
|
161
|
+
[], Iterable[ReactComponent[Any]]
|
|
162
|
+
] = registered_react_components,
|
|
163
|
+
) -> list[str]:
|
|
164
|
+
"""Check if web dependencies are in sync and return list of packages that need to be added/updated."""
|
|
165
|
+
desired = get_required_dependencies(
|
|
166
|
+
web_root=web_root,
|
|
167
|
+
pulse_version=pulse_version,
|
|
168
|
+
component_provider=component_provider,
|
|
169
|
+
)
|
|
170
|
+
pkg_json = load_package_json(web_root)
|
|
171
|
+
|
|
172
|
+
to_add: list[str] = []
|
|
173
|
+
for name, req_ver in sorted(desired.items()):
|
|
174
|
+
effective = req_ver
|
|
175
|
+
if name == "pulse-ui-client":
|
|
176
|
+
effective = convert_pep440_to_semver(pulse_version)
|
|
177
|
+
|
|
178
|
+
existing = get_pkg_spec(pkg_json, name)
|
|
179
|
+
if existing is None:
|
|
180
|
+
to_add.append(f"{name}@{effective}" if effective else name)
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
if is_workspace_spec(existing):
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
if spec_satisfies(effective, existing):
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
to_add.append(f"{name}@{effective}" if effective else name)
|
|
190
|
+
|
|
191
|
+
return to_add
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def prepare_web_dependencies(
|
|
195
|
+
web_root: Path,
|
|
196
|
+
*,
|
|
197
|
+
pulse_version: str,
|
|
198
|
+
component_provider: Callable[
|
|
199
|
+
[], Iterable[ReactComponent[Any]]
|
|
200
|
+
] = registered_react_components,
|
|
201
|
+
) -> DependencyPlan | None:
|
|
202
|
+
"""Inspect registered components and return the Bun command needed to sync dependencies."""
|
|
203
|
+
to_add = check_web_dependencies(
|
|
204
|
+
web_root=web_root,
|
|
205
|
+
pulse_version=pulse_version,
|
|
206
|
+
component_provider=component_provider,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if to_add:
|
|
210
|
+
return DependencyPlan(command=["bun", "add", *to_add], to_add=to_add)
|
|
211
|
+
|
|
212
|
+
return DependencyPlan(command=["bun", "i"], to_add=())
|
pulse/cli/folder_lock.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Folder lock management.
|
|
3
|
+
|
|
4
|
+
Provides a FolderLock context manager that coordinates across processes and
|
|
5
|
+
Uvicorn reloads using environment variables.
|
|
6
|
+
|
|
7
|
+
Example: prevent multiple Pulse dev instances per web root.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import socket
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import TracebackType
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from pulse.cli.helpers import ensure_gitignore_has
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_process_alive(pid: int) -> bool:
|
|
23
|
+
"""Check if a process with the given PID is running."""
|
|
24
|
+
try:
|
|
25
|
+
# On POSIX, signal 0 checks for existence without killing
|
|
26
|
+
os.kill(pid, 0)
|
|
27
|
+
except ProcessLookupError:
|
|
28
|
+
return False
|
|
29
|
+
except PermissionError:
|
|
30
|
+
# Process exists but we may not have permission
|
|
31
|
+
return True
|
|
32
|
+
except Exception:
|
|
33
|
+
# Best-effort: assume alive if uncertain
|
|
34
|
+
return True
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_lock(lock_path: Path) -> dict[str, Any] | None:
|
|
39
|
+
"""Read and parse lock file contents."""
|
|
40
|
+
try:
|
|
41
|
+
data = json.loads(lock_path.read_text())
|
|
42
|
+
if isinstance(data, dict):
|
|
43
|
+
return data
|
|
44
|
+
except Exception:
|
|
45
|
+
return None
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _write_gitignore_for_lock(lock_path: Path) -> None:
|
|
50
|
+
"""Add lock file to .gitignore if not already present."""
|
|
51
|
+
|
|
52
|
+
ensure_gitignore_has(lock_path.parent, lock_path.name)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _create_lock_file(lock_path: Path) -> None:
|
|
56
|
+
"""Create a lock file with current process information."""
|
|
57
|
+
lock_path = Path(lock_path)
|
|
58
|
+
_write_gitignore_for_lock(lock_path)
|
|
59
|
+
|
|
60
|
+
if lock_path.exists():
|
|
61
|
+
info = _read_lock(lock_path) or {}
|
|
62
|
+
pid = int(info.get("pid", 0) or 0)
|
|
63
|
+
if pid and is_process_alive(pid):
|
|
64
|
+
raise RuntimeError(
|
|
65
|
+
f"Another Pulse dev instance appears to be running (pid={pid}) for {lock_path.parent}."
|
|
66
|
+
)
|
|
67
|
+
# Stale lock; continue to overwrite
|
|
68
|
+
|
|
69
|
+
payload = {
|
|
70
|
+
"pid": os.getpid(),
|
|
71
|
+
"created_at": int(time.time()),
|
|
72
|
+
"hostname": socket.gethostname(),
|
|
73
|
+
"platform": platform.platform(),
|
|
74
|
+
"python": platform.python_version(),
|
|
75
|
+
"cwd": os.getcwd(),
|
|
76
|
+
}
|
|
77
|
+
try:
|
|
78
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
lock_path.write_text(json.dumps(payload))
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
raise RuntimeError(f"Failed to create lock file at {lock_path}: {exc}") from exc
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _remove_lock_file(lock_path: Path) -> None:
|
|
85
|
+
"""Remove lock file (best-effort)."""
|
|
86
|
+
try:
|
|
87
|
+
Path(lock_path).unlink(missing_ok=True)
|
|
88
|
+
except Exception:
|
|
89
|
+
# Best-effort cleanup
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def lock_path_for_web_root(web_root: Path, filename: str = ".pulse.lock") -> Path:
|
|
94
|
+
"""Return the lock file path for a given web root."""
|
|
95
|
+
return Path(web_root) / filename
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class FolderLock:
|
|
99
|
+
"""
|
|
100
|
+
Context manager for folder lock management.
|
|
101
|
+
|
|
102
|
+
Coordinates across processes and Uvicorn reloads using environment variables.
|
|
103
|
+
The process that creates the lock (typically the CLI) sets PULSE_LOCK_OWNER.
|
|
104
|
+
Child processes (uvicorn workers, reloaded processes) inherit this env var
|
|
105
|
+
and know not to delete the lock on exit.
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
with FolderLock(web_root):
|
|
109
|
+
# Protected region
|
|
110
|
+
pass
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, web_root: Path, *, filename: str = ".pulse.lock"):
|
|
114
|
+
"""
|
|
115
|
+
Initialize FolderLock.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
web_root: Path to the web root directory
|
|
119
|
+
filename: Name of the lock file (default: ".pulse.lock")
|
|
120
|
+
"""
|
|
121
|
+
self.lock_path: Path = lock_path_for_web_root(web_root, filename)
|
|
122
|
+
|
|
123
|
+
def __enter__(self):
|
|
124
|
+
_create_lock_file(self.lock_path)
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
def __exit__(
|
|
128
|
+
self,
|
|
129
|
+
exc_type: type[BaseException] | None,
|
|
130
|
+
exc_val: BaseException | None,
|
|
131
|
+
exc_tb: TracebackType | None,
|
|
132
|
+
) -> bool:
|
|
133
|
+
_remove_lock_file(self.lock_path)
|
|
134
|
+
return False
|
pulse/cli/helpers.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import importlib
|
|
2
2
|
import importlib.util
|
|
3
3
|
import platform
|
|
4
|
-
import socket
|
|
5
4
|
import sys
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
from typing import Literal, TypedDict
|
|
@@ -9,8 +8,7 @@ from typing import Literal, TypedDict
|
|
|
9
8
|
import typer
|
|
10
9
|
from rich.console import Console
|
|
11
10
|
|
|
12
|
-
from pulse.
|
|
13
|
-
from pulse.env import env
|
|
11
|
+
from pulse.cli.models import AppLoadResult
|
|
14
12
|
|
|
15
13
|
|
|
16
14
|
def os_family() -> Literal["windows", "mac", "linux"]:
|
|
@@ -22,20 +20,6 @@ def os_family() -> Literal["windows", "mac", "linux"]:
|
|
|
22
20
|
return "linux"
|
|
23
21
|
|
|
24
22
|
|
|
25
|
-
def find_available_port(start_port: int = 8000, max_attempts: int = 100) -> int:
|
|
26
|
-
"""Find an available port starting from start_port."""
|
|
27
|
-
for port in range(start_port, start_port + max_attempts):
|
|
28
|
-
try:
|
|
29
|
-
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
30
|
-
s.bind(("localhost", port))
|
|
31
|
-
return port
|
|
32
|
-
except OSError:
|
|
33
|
-
continue
|
|
34
|
-
raise RuntimeError(
|
|
35
|
-
f"Could not find an available port after {max_attempts} attempts starting from {start_port}"
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
|
|
39
23
|
class ParsedAppTarget(TypedDict):
|
|
40
24
|
mode: Literal["path", "module"]
|
|
41
25
|
module_name: str
|
|
@@ -126,8 +110,11 @@ def parse_app_target(target: str) -> ParsedAppTarget:
|
|
|
126
110
|
}
|
|
127
111
|
|
|
128
112
|
|
|
129
|
-
def load_app_from_file(file_path: str | Path) ->
|
|
130
|
-
"""Load routes from a Python file
|
|
113
|
+
def load_app_from_file(file_path: str | Path) -> AppLoadResult:
|
|
114
|
+
"""Load routes from a Python file and return app context details."""
|
|
115
|
+
# Avoid circular import
|
|
116
|
+
from pulse.app import App
|
|
117
|
+
|
|
131
118
|
file_path = Path(file_path)
|
|
132
119
|
|
|
133
120
|
if not file_path.exists():
|
|
@@ -138,10 +125,6 @@ def load_app_from_file(file_path: str | Path) -> App:
|
|
|
138
125
|
typer.echo(f"❌ File must be a Python file (.py): {file_path}")
|
|
139
126
|
raise typer.Exit(1)
|
|
140
127
|
|
|
141
|
-
# Set env so downstream codegen can resolve paths relative to the app file
|
|
142
|
-
env.pulse_app_file = str(file_path.absolute())
|
|
143
|
-
env.pulse_app_dir = str(file_path.parent.absolute())
|
|
144
|
-
|
|
145
128
|
# clear_routes()
|
|
146
129
|
sys.path.insert(0, str(file_path.parent.absolute()))
|
|
147
130
|
|
|
@@ -158,7 +141,16 @@ def load_app_from_file(file_path: str | Path) -> App:
|
|
|
158
141
|
app_instance = module.app
|
|
159
142
|
if not app_instance.routes:
|
|
160
143
|
typer.echo(f"⚠️ No routes found in {file_path}")
|
|
161
|
-
return
|
|
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
|
+
)
|
|
162
154
|
|
|
163
155
|
typer.echo(f"⚠️ No app found in {file_path}")
|
|
164
156
|
raise typer.Exit(1)
|
|
@@ -173,24 +165,28 @@ def load_app_from_file(file_path: str | Path) -> App:
|
|
|
173
165
|
sys.path.remove(str(file_path.parent.absolute()))
|
|
174
166
|
|
|
175
167
|
|
|
176
|
-
def load_app_from_target(target: str) ->
|
|
168
|
+
def load_app_from_target(target: str) -> AppLoadResult:
|
|
177
169
|
"""Load an App instance from either a file path (with optional :var) or a module path (uvicorn style)."""
|
|
170
|
+
|
|
171
|
+
# Avoid circulart import
|
|
172
|
+
from pulse.app import App
|
|
173
|
+
|
|
178
174
|
parsed = parse_app_target(target)
|
|
179
175
|
|
|
180
|
-
|
|
176
|
+
module_name = parsed["module_name"]
|
|
177
|
+
app_var = parsed["app_var"]
|
|
178
|
+
app_file: Path | None = None
|
|
179
|
+
app_dir: Path | None = None
|
|
180
|
+
|
|
181
181
|
if parsed["mode"] == "path":
|
|
182
182
|
file_path = parsed["file_path"]
|
|
183
183
|
if file_path is None:
|
|
184
184
|
typer.echo(f"❌ Could not determine a Python file from: {target}")
|
|
185
185
|
raise typer.Exit(1)
|
|
186
|
-
env.pulse_app_file = str(file_path.absolute())
|
|
187
|
-
env.pulse_app_dir = str(file_path.parent.absolute())
|
|
188
186
|
|
|
189
187
|
sys.path.insert(0, str(file_path.parent.absolute()))
|
|
190
188
|
try:
|
|
191
|
-
spec = importlib.util.spec_from_file_location(
|
|
192
|
-
parsed["module_name"], file_path
|
|
193
|
-
)
|
|
189
|
+
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
194
190
|
if spec is None or spec.loader is None:
|
|
195
191
|
typer.echo(f"❌ Could not load module from: {file_path}")
|
|
196
192
|
raise typer.Exit(1)
|
|
@@ -206,13 +202,16 @@ def load_app_from_target(target: str) -> App:
|
|
|
206
202
|
if str(file_path.parent.absolute()) in sys.path:
|
|
207
203
|
sys.path.remove(str(file_path.parent.absolute()))
|
|
208
204
|
|
|
205
|
+
app_file = file_path.resolve()
|
|
206
|
+
app_dir = file_path.parent.resolve()
|
|
207
|
+
loaded_module = module
|
|
209
208
|
else:
|
|
210
209
|
# module mode
|
|
211
210
|
try:
|
|
212
|
-
module = importlib.import_module(
|
|
211
|
+
module = importlib.import_module(module_name) # type: ignore[name-defined]
|
|
213
212
|
except Exception:
|
|
214
213
|
console = Console()
|
|
215
|
-
console.log(f"❌ Error importing module: {
|
|
214
|
+
console.log(f"❌ Error importing module: {module_name}")
|
|
216
215
|
console.print_exception()
|
|
217
216
|
raise typer.Exit(1) from None
|
|
218
217
|
|
|
@@ -220,23 +219,63 @@ def load_app_from_target(target: str) -> App:
|
|
|
220
219
|
file_attr = getattr(module, "__file__", None)
|
|
221
220
|
if file_attr:
|
|
222
221
|
fp = Path(file_attr)
|
|
223
|
-
|
|
224
|
-
|
|
222
|
+
app_file = fp.resolve()
|
|
223
|
+
app_dir = fp.parent.resolve()
|
|
224
|
+
loaded_module = module
|
|
225
225
|
|
|
226
226
|
# Fetch the app attribute
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
typer.echo(f"❌ App variable '{var_name}' not found in {parsed['module_name']}")
|
|
227
|
+
if not hasattr(loaded_module, app_var):
|
|
228
|
+
typer.echo(f"❌ App variable '{app_var}' not found in {module_name}")
|
|
230
229
|
raise typer.Exit(1)
|
|
231
|
-
app_candidate = getattr(
|
|
230
|
+
app_candidate = getattr(loaded_module, app_var)
|
|
232
231
|
if not isinstance(app_candidate, App):
|
|
233
|
-
typer.echo(
|
|
234
|
-
f"❌ '{var_name}' in {parsed['module_name']} is not a pulse.App instance"
|
|
235
|
-
)
|
|
232
|
+
typer.echo(f"❌ '{app_var}' in {module_name} is not a pulse.App instance")
|
|
236
233
|
raise typer.Exit(1)
|
|
237
234
|
if not app_candidate.routes:
|
|
238
235
|
typer.echo("⚠️ No routes found")
|
|
239
|
-
return
|
|
236
|
+
return AppLoadResult(
|
|
237
|
+
target=target,
|
|
238
|
+
mode=parsed["mode"],
|
|
239
|
+
app=app_candidate,
|
|
240
|
+
module_name=module_name,
|
|
241
|
+
app_var=app_var,
|
|
242
|
+
app_file=app_file,
|
|
243
|
+
app_dir=app_dir,
|
|
244
|
+
server_cwd=parsed["server_cwd"],
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def ensure_gitignore_has(root: Path, *patterns: str) -> None:
|
|
249
|
+
"""
|
|
250
|
+
Ensure .gitignore in root contains the specified patterns.
|
|
251
|
+
Non-fatal: silently ignores errors.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
root: Directory containing (or to contain) .gitignore
|
|
255
|
+
*patterns: Patterns to ensure are in .gitignore
|
|
256
|
+
"""
|
|
257
|
+
if not patterns:
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
gitignore_path = root / ".gitignore"
|
|
262
|
+
|
|
263
|
+
if gitignore_path.exists():
|
|
264
|
+
content = gitignore_path.read_text()
|
|
265
|
+
# Parse existing entries (split on whitespace to handle various formats)
|
|
266
|
+
existing = set(content.split())
|
|
267
|
+
missing = [p for p in patterns if p not in existing]
|
|
268
|
+
|
|
269
|
+
if missing:
|
|
270
|
+
# Add missing patterns
|
|
271
|
+
additions = "\n".join(missing)
|
|
272
|
+
gitignore_path.write_text(f"{content.rstrip()}\n{additions}\n")
|
|
273
|
+
else:
|
|
274
|
+
# Create new .gitignore with all patterns
|
|
275
|
+
gitignore_path.write_text("\n".join(patterns) + "\n")
|
|
276
|
+
except Exception:
|
|
277
|
+
# Non-fatal; ignore gitignore failures
|
|
278
|
+
pass
|
|
240
279
|
|
|
241
280
|
|
|
242
281
|
def install_hints_for_mkcert() -> list[str]:
|
pulse/cli/models.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
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
|