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.
@@ -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=())
@@ -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.app import App
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) -> App:
130
- """Load routes from a Python file (supports both App instances and global @ps.route decorators)."""
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 app_instance
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) -> App:
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
- # Set env for downstream codegen resolution
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(parsed["module_name"]) # type: ignore[name-defined]
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: {parsed['module_name']}")
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
- env.pulse_app_file = str(fp.absolute())
224
- env.pulse_app_dir = str(fp.parent.absolute())
222
+ app_file = fp.resolve()
223
+ app_dir = fp.parent.resolve()
224
+ loaded_module = module
225
225
 
226
226
  # Fetch the app attribute
227
- var_name = parsed["app_var"]
228
- if not hasattr(module, var_name):
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(module, var_name)
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 app_candidate
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