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.
Files changed (126) hide show
  1. pulse/__init__.py +1493 -0
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +1086 -0
  4. pulse/channel.py +607 -0
  5. pulse/cli/__init__.py +0 -0
  6. pulse/cli/cmd.py +575 -0
  7. pulse/cli/dependencies.py +181 -0
  8. pulse/cli/folder_lock.py +134 -0
  9. pulse/cli/helpers.py +271 -0
  10. pulse/cli/logging.py +102 -0
  11. pulse/cli/models.py +35 -0
  12. pulse/cli/packages.py +262 -0
  13. pulse/cli/processes.py +292 -0
  14. pulse/cli/secrets.py +39 -0
  15. pulse/cli/uvicorn_log_config.py +87 -0
  16. pulse/code_analysis.py +38 -0
  17. pulse/codegen/__init__.py +0 -0
  18. pulse/codegen/codegen.py +359 -0
  19. pulse/codegen/templates/__init__.py +0 -0
  20. pulse/codegen/templates/layout.py +106 -0
  21. pulse/codegen/templates/route.py +345 -0
  22. pulse/codegen/templates/routes_ts.py +42 -0
  23. pulse/codegen/utils.py +20 -0
  24. pulse/component.py +237 -0
  25. pulse/components/__init__.py +0 -0
  26. pulse/components/for_.py +83 -0
  27. pulse/components/if_.py +86 -0
  28. pulse/components/react_router.py +94 -0
  29. pulse/context.py +108 -0
  30. pulse/cookies.py +322 -0
  31. pulse/decorators.py +344 -0
  32. pulse/dom/__init__.py +0 -0
  33. pulse/dom/elements.py +1024 -0
  34. pulse/dom/events.py +445 -0
  35. pulse/dom/props.py +1250 -0
  36. pulse/dom/svg.py +0 -0
  37. pulse/dom/tags.py +328 -0
  38. pulse/dom/tags.pyi +480 -0
  39. pulse/env.py +178 -0
  40. pulse/form.py +538 -0
  41. pulse/helpers.py +541 -0
  42. pulse/hooks/__init__.py +0 -0
  43. pulse/hooks/core.py +452 -0
  44. pulse/hooks/effects.py +88 -0
  45. pulse/hooks/init.py +668 -0
  46. pulse/hooks/runtime.py +464 -0
  47. pulse/hooks/setup.py +254 -0
  48. pulse/hooks/stable.py +138 -0
  49. pulse/hooks/state.py +192 -0
  50. pulse/js/__init__.py +125 -0
  51. pulse/js/__init__.pyi +115 -0
  52. pulse/js/_types.py +299 -0
  53. pulse/js/array.py +339 -0
  54. pulse/js/console.py +50 -0
  55. pulse/js/date.py +119 -0
  56. pulse/js/document.py +145 -0
  57. pulse/js/error.py +140 -0
  58. pulse/js/json.py +66 -0
  59. pulse/js/map.py +97 -0
  60. pulse/js/math.py +69 -0
  61. pulse/js/navigator.py +79 -0
  62. pulse/js/number.py +57 -0
  63. pulse/js/obj.py +81 -0
  64. pulse/js/object.py +172 -0
  65. pulse/js/promise.py +172 -0
  66. pulse/js/pulse.py +115 -0
  67. pulse/js/react.py +495 -0
  68. pulse/js/regexp.py +57 -0
  69. pulse/js/set.py +124 -0
  70. pulse/js/string.py +38 -0
  71. pulse/js/weakmap.py +53 -0
  72. pulse/js/weakset.py +48 -0
  73. pulse/js/window.py +205 -0
  74. pulse/messages.py +202 -0
  75. pulse/middleware.py +471 -0
  76. pulse/plugin.py +96 -0
  77. pulse/proxy.py +242 -0
  78. pulse/py.typed +0 -0
  79. pulse/queries/__init__.py +0 -0
  80. pulse/queries/client.py +609 -0
  81. pulse/queries/common.py +101 -0
  82. pulse/queries/effect.py +55 -0
  83. pulse/queries/infinite_query.py +1418 -0
  84. pulse/queries/mutation.py +295 -0
  85. pulse/queries/protocol.py +136 -0
  86. pulse/queries/query.py +1314 -0
  87. pulse/queries/store.py +120 -0
  88. pulse/react_component.py +88 -0
  89. pulse/reactive.py +1208 -0
  90. pulse/reactive_extensions.py +1172 -0
  91. pulse/render_session.py +768 -0
  92. pulse/renderer.py +584 -0
  93. pulse/request.py +205 -0
  94. pulse/routing.py +598 -0
  95. pulse/serializer.py +279 -0
  96. pulse/state.py +556 -0
  97. pulse/test_helpers.py +15 -0
  98. pulse/transpiler/__init__.py +111 -0
  99. pulse/transpiler/assets.py +81 -0
  100. pulse/transpiler/builtins.py +1029 -0
  101. pulse/transpiler/dynamic_import.py +130 -0
  102. pulse/transpiler/emit_context.py +49 -0
  103. pulse/transpiler/errors.py +96 -0
  104. pulse/transpiler/function.py +611 -0
  105. pulse/transpiler/id.py +18 -0
  106. pulse/transpiler/imports.py +341 -0
  107. pulse/transpiler/js_module.py +336 -0
  108. pulse/transpiler/modules/__init__.py +33 -0
  109. pulse/transpiler/modules/asyncio.py +57 -0
  110. pulse/transpiler/modules/json.py +24 -0
  111. pulse/transpiler/modules/math.py +265 -0
  112. pulse/transpiler/modules/pulse/__init__.py +5 -0
  113. pulse/transpiler/modules/pulse/tags.py +250 -0
  114. pulse/transpiler/modules/typing.py +63 -0
  115. pulse/transpiler/nodes.py +1987 -0
  116. pulse/transpiler/py_module.py +135 -0
  117. pulse/transpiler/transpiler.py +1100 -0
  118. pulse/transpiler/vdom.py +256 -0
  119. pulse/types/__init__.py +0 -0
  120. pulse/types/event_handler.py +50 -0
  121. pulse/user_session.py +386 -0
  122. pulse/version.py +69 -0
  123. pulse_framework-0.1.62.dist-info/METADATA +198 -0
  124. pulse_framework-0.1.62.dist-info/RECORD +126 -0
  125. pulse_framework-0.1.62.dist-info/WHEEL +4 -0
  126. pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Sequence
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from pulse.cli.packages import (
9
+ VersionConflict,
10
+ get_pkg_spec,
11
+ is_workspace_spec,
12
+ load_package_json,
13
+ parse_dependency_spec,
14
+ parse_install_spec,
15
+ resolve_versions,
16
+ spec_satisfies,
17
+ )
18
+ from pulse.transpiler.imports import get_registered_imports
19
+
20
+
21
+ def convert_pep440_to_semver(python_version: str) -> str:
22
+ """Convert PEP 440 version format to NPM semver format.
23
+
24
+ PEP 440 formats:
25
+ - 0.1.37a1 -> 0.1.37-alpha.1
26
+ - 0.1.37b1 -> 0.1.37-beta.1
27
+ - 0.1.37rc1 -> 0.1.37-rc.1
28
+ - 0.1.37.dev1 -> 0.1.37-dev.1
29
+
30
+ Non-pre-release versions are returned unchanged.
31
+ """
32
+ # Match pre-release patterns: version followed by a/b/rc/dev + number
33
+ # PEP 440: a1, b1, rc1, dev1, alpha1, beta1, etc.
34
+ pattern = r"^(\d+\.\d+\.\d+)([a-z]+)(\d+)$"
35
+ match = re.match(pattern, python_version)
36
+
37
+ if match:
38
+ base_version = match.group(1)
39
+ prerelease_type = match.group(2)
40
+ prerelease_num = match.group(3)
41
+
42
+ # Map PEP 440 prerelease types to NPM semver
43
+ type_map = {
44
+ "a": "alpha",
45
+ "alpha": "alpha",
46
+ "b": "beta",
47
+ "beta": "beta",
48
+ "rc": "rc",
49
+ "c": "rc", # PEP 440 also allows 'c' for release candidate
50
+ "dev": "dev",
51
+ }
52
+
53
+ npm_type = type_map.get(prerelease_type.lower(), prerelease_type)
54
+ return f"{base_version}-{npm_type}.{prerelease_num}"
55
+
56
+ # Also handle .dev format (e.g., 0.1.37.dev1)
57
+ pattern2 = r"^(\d+\.\d+\.\d+)\.dev(\d+)$"
58
+ match2 = re.match(pattern2, python_version)
59
+ if match2:
60
+ base_version = match2.group(1)
61
+ dev_num = match2.group(2)
62
+ return f"{base_version}-dev.{dev_num}"
63
+
64
+ # No pre-release, return as-is
65
+ return python_version
66
+
67
+
68
+ class DependencyError(RuntimeError):
69
+ """Base error for dependency preparation failures."""
70
+
71
+
72
+ class DependencyResolutionError(DependencyError):
73
+ """Raised when component constraints cannot be resolved."""
74
+
75
+
76
+ class DependencyCommandError(DependencyError):
77
+ """Raised when Bun commands fail to run."""
78
+
79
+
80
+ @dataclass
81
+ class DependencyPlan:
82
+ """Return value describing the command required to sync dependencies."""
83
+
84
+ command: list[str]
85
+ to_add: Sequence[str]
86
+
87
+
88
+ def get_required_dependencies(
89
+ web_root: Path,
90
+ *,
91
+ pulse_version: str,
92
+ ) -> dict[str, str | None]:
93
+ """Get the required dependencies for a Pulse app."""
94
+ if not web_root.exists():
95
+ raise DependencyError(f"Directory not found: {web_root}")
96
+
97
+ constraints: dict[str, list[str | None]] = {
98
+ "pulse-ui-client": [pulse_version],
99
+ }
100
+
101
+ # New transpiler v2 imports
102
+ for imp in get_registered_imports():
103
+ if imp.src:
104
+ try:
105
+ spec = parse_install_spec(imp.src)
106
+ except ValueError as exc:
107
+ # We might want to be more lenient here or at least log it,
108
+ # but following existing pattern of raising DependencyError
109
+ raise DependencyError(str(exc)) from None
110
+ if spec:
111
+ name_only, ver = parse_dependency_spec(spec)
112
+ constraints.setdefault(name_only, []).append(ver)
113
+ if imp.version:
114
+ constraints.setdefault(name_only, []).append(imp.version)
115
+
116
+ try:
117
+ resolved = resolve_versions(constraints)
118
+ except VersionConflict as exc:
119
+ raise DependencyResolutionError(str(exc)) from None
120
+
121
+ desired: dict[str, str | None] = dict(resolved)
122
+ for pkg in [
123
+ "react-router",
124
+ "@react-router/node",
125
+ "@react-router/serve",
126
+ "@react-router/dev",
127
+ ]:
128
+ desired.setdefault(pkg, "^7")
129
+
130
+ return desired
131
+
132
+
133
+ def check_web_dependencies(
134
+ web_root: Path,
135
+ *,
136
+ pulse_version: str,
137
+ ) -> list[str]:
138
+ """Check if web dependencies are in sync and return list of packages that need to be added/updated."""
139
+ desired = get_required_dependencies(
140
+ web_root=web_root,
141
+ pulse_version=pulse_version,
142
+ )
143
+ pkg_json = load_package_json(web_root)
144
+
145
+ to_add: list[str] = []
146
+ for name, req_ver in sorted(desired.items()):
147
+ effective = req_ver
148
+ if name == "pulse-ui-client":
149
+ effective = convert_pep440_to_semver(pulse_version)
150
+
151
+ existing = get_pkg_spec(pkg_json, name)
152
+ if existing is None:
153
+ to_add.append(f"{name}@{effective}" if effective else name)
154
+ continue
155
+
156
+ if is_workspace_spec(existing):
157
+ continue
158
+
159
+ if spec_satisfies(effective, existing):
160
+ continue
161
+
162
+ to_add.append(f"{name}@{effective}" if effective else name)
163
+
164
+ return to_add
165
+
166
+
167
+ def prepare_web_dependencies(
168
+ web_root: Path,
169
+ *,
170
+ pulse_version: str,
171
+ ) -> DependencyPlan | None:
172
+ """Inspect registered components and return the Bun command needed to sync dependencies."""
173
+ to_add = check_web_dependencies(
174
+ web_root=web_root,
175
+ pulse_version=pulse_version,
176
+ )
177
+
178
+ if to_add:
179
+ return DependencyPlan(command=["bun", "add", *to_add], to_add=to_add)
180
+
181
+ 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 ADDED
@@ -0,0 +1,271 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import importlib.util
5
+ import platform
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Literal, TypedDict
9
+
10
+ import typer
11
+
12
+ from pulse.cli.models import AppLoadResult
13
+
14
+ if TYPE_CHECKING:
15
+ from pulse.cli.logging import CLILogger
16
+
17
+
18
+ def os_family() -> Literal["windows", "mac", "linux"]:
19
+ s = platform.system().lower()
20
+ if "windows" in s:
21
+ return "windows"
22
+ if "darwin" in s or "mac" in s:
23
+ return "mac"
24
+ return "linux"
25
+
26
+
27
+ class ParsedAppTarget(TypedDict):
28
+ mode: Literal["path", "module"]
29
+ module_name: str
30
+ app_var: str
31
+ file_path: Path | None
32
+ server_cwd: Path | None
33
+
34
+
35
+ def _module_path_from_file(file_path: Path) -> tuple[str, Path]:
36
+ """Compute the module import path for a file within a package hierarchy.
37
+ Returns (module_path, server_cwd). server_cwd is the directory that should be
38
+ used as the working directory so that importing module_path works.
39
+ """
40
+ file_path = file_path.resolve()
41
+ is_init = file_path.name == "__init__.py"
42
+ parts: list[str] = [] if is_init else [file_path.stem]
43
+ current = file_path.parent
44
+ # Default to the file's parent when not inside a package
45
+ top_package_parent = current
46
+ while (current / "__init__.py").exists():
47
+ parts.insert(0, current.name)
48
+ top_package_parent = current.parent
49
+ current = current.parent
50
+ module_path = ".".join(parts)
51
+ server_cwd = top_package_parent
52
+ return module_path, server_cwd
53
+
54
+
55
+ def parse_app_target(target: str) -> ParsedAppTarget:
56
+ """Parse an app target which can be either:
57
+ - a filesystem path to a Python file with optional ":var" (default var is "app"), e.g. "examples/main.py:app"
58
+ - a module path in uvicorn style with a required ":var", e.g. "examples.main:app"
59
+
60
+ Returns a dict describing how to import/run it.
61
+ """
62
+ # Split optional ":var" specifier once from the right
63
+ # Handle Windows drive letters by checking if the colon is followed by a path separator
64
+ if ":" in target:
65
+ # Check if this looks like a Windows drive letter (e.g., "C:\path" or "C:/path")
66
+ colon_pos = target.rfind(":")
67
+ if colon_pos > 0 and colon_pos < len(target) - 1:
68
+ char_after_colon = target[colon_pos + 1]
69
+ if char_after_colon in ["\\", "/"]:
70
+ # This is a Windows drive letter, not a variable specifier
71
+ path_or_module, app_var = target, "app"
72
+ else:
73
+ # This is a variable specifier
74
+ path_or_module, app_var = target.rsplit(":", 1)
75
+ app_var = app_var or "app"
76
+ else:
77
+ # Single colon at end, treat as variable specifier
78
+ path_or_module, app_var = target.rsplit(":", 1)
79
+ app_var = app_var or "app"
80
+ else:
81
+ path_or_module, app_var = target, "app"
82
+
83
+ p = Path(path_or_module)
84
+ if p.exists():
85
+ if p.is_dir():
86
+ # If a package directory is passed, try __init__.py semantics by using the directory name as module
87
+ init_file = p / "__init__.py"
88
+ if init_file.exists():
89
+ module_name, server_cwd = _module_path_from_file(init_file)
90
+ file_path = init_file
91
+ else:
92
+ module_name = p.name
93
+ file_path = None
94
+ server_cwd = p.parent.resolve()
95
+ else:
96
+ file_path = p.resolve()
97
+ module_name, server_cwd = _module_path_from_file(file_path)
98
+ return {
99
+ "mode": "path",
100
+ "module_name": module_name,
101
+ "app_var": app_var,
102
+ "file_path": file_path,
103
+ "server_cwd": server_cwd,
104
+ }
105
+
106
+ # Treat as module import path
107
+ module_name = path_or_module
108
+ return {
109
+ "mode": "module",
110
+ "module_name": module_name,
111
+ "app_var": app_var,
112
+ "file_path": None,
113
+ "server_cwd": None,
114
+ }
115
+
116
+
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
+ """
124
+ # Avoid circular import
125
+ from pulse.app import App
126
+
127
+ def _log_error(message: str) -> None:
128
+ if logger:
129
+ logger.error(message)
130
+ else:
131
+ print(f"Error: {message}")
132
+
133
+ def _log_warning(message: str) -> None:
134
+ if logger:
135
+ logger.warning(message)
136
+ else:
137
+ print(f"Warning: {message}")
138
+
139
+ def _print_exception() -> None:
140
+ if logger:
141
+ logger.print_exception()
142
+ else:
143
+ import traceback
144
+
145
+ traceback.print_exc()
146
+
147
+ parsed = parse_app_target(target)
148
+
149
+ module_name = parsed["module_name"]
150
+ app_var = parsed["app_var"]
151
+ app_file: Path | None = None
152
+ app_dir: Path | None = None
153
+
154
+ if parsed["mode"] == "path":
155
+ file_path = parsed["file_path"]
156
+ if file_path is None:
157
+ _log_error(f"Could not determine a Python file from: {target}")
158
+ raise typer.Exit(1)
159
+
160
+ sys.path.insert(0, str(file_path.parent.absolute()))
161
+ try:
162
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
163
+ if spec is None or spec.loader is None:
164
+ _log_error(f"Could not load module from: {file_path}")
165
+ raise typer.Exit(1)
166
+ module = importlib.util.module_from_spec(spec)
167
+ sys.modules[spec.name] = module
168
+ spec.loader.exec_module(module)
169
+ except typer.Exit:
170
+ raise
171
+ except Exception:
172
+ _log_error(f"Error loading {file_path}")
173
+ _print_exception()
174
+ raise typer.Exit(1) from None
175
+ finally:
176
+ if str(file_path.parent.absolute()) in sys.path:
177
+ sys.path.remove(str(file_path.parent.absolute()))
178
+
179
+ app_file = file_path.resolve()
180
+ app_dir = file_path.parent.resolve()
181
+ loaded_module = module
182
+ else:
183
+ # module mode
184
+ try:
185
+ module = importlib.import_module(module_name) # type: ignore[name-defined]
186
+ except Exception:
187
+ _log_error(f"Error importing module: {module_name}")
188
+ _print_exception()
189
+ raise typer.Exit(1) from None
190
+
191
+ # Try to set env paths from the resolved module file
192
+ file_attr = getattr(module, "__file__", None)
193
+ if file_attr:
194
+ fp = Path(file_attr)
195
+ app_file = fp.resolve()
196
+ app_dir = fp.parent.resolve()
197
+ loaded_module = module
198
+
199
+ # Fetch the app attribute
200
+ if not hasattr(loaded_module, app_var):
201
+ _log_error(f"App variable '{app_var}' not found in {module_name}")
202
+ raise typer.Exit(1)
203
+ app_candidate = getattr(loaded_module, app_var)
204
+ if not isinstance(app_candidate, App):
205
+ _log_error(f"'{app_var}' in {module_name} is not a pulse.App instance")
206
+ raise typer.Exit(1)
207
+ if not app_candidate.routes:
208
+ _log_warning("No routes found")
209
+ return AppLoadResult(
210
+ target=target,
211
+ mode=parsed["mode"],
212
+ app=app_candidate,
213
+ module_name=module_name,
214
+ app_var=app_var,
215
+ app_file=app_file,
216
+ app_dir=app_dir,
217
+ server_cwd=parsed["server_cwd"],
218
+ )
219
+
220
+
221
+ def ensure_gitignore_has(root: Path, *patterns: str) -> None:
222
+ """
223
+ Ensure .gitignore in root contains the specified patterns.
224
+ Non-fatal: silently ignores errors.
225
+
226
+ Args:
227
+ root: Directory containing (or to contain) .gitignore
228
+ *patterns: Patterns to ensure are in .gitignore
229
+ """
230
+ if not patterns:
231
+ return
232
+
233
+ try:
234
+ gitignore_path = root / ".gitignore"
235
+
236
+ if gitignore_path.exists():
237
+ content = gitignore_path.read_text()
238
+ # Parse existing entries (split on whitespace to handle various formats)
239
+ existing = set(content.split())
240
+ missing = [p for p in patterns if p not in existing]
241
+
242
+ if missing:
243
+ # Add missing patterns
244
+ additions = "\n".join(missing)
245
+ gitignore_path.write_text(f"{content.rstrip()}\n{additions}\n")
246
+ else:
247
+ # Create new .gitignore with all patterns
248
+ gitignore_path.write_text("\n".join(patterns) + "\n")
249
+ except Exception:
250
+ # Non-fatal; ignore gitignore failures
251
+ pass
252
+
253
+
254
+ def install_hints_for_mkcert() -> list[str]:
255
+ fam = os_family()
256
+ if fam == "mac":
257
+ return [
258
+ "brew install mkcert nss",
259
+ "mkcert -install",
260
+ ]
261
+ if fam == "windows":
262
+ return [
263
+ "choco install mkcert # or: winget install FiloSottile.mkcert",
264
+ "mkcert -install",
265
+ ]
266
+ return [
267
+ "sudo apt install -y mkcert libnss3-tools # Debian/Ubuntu",
268
+ "# or: sudo dnf install -y mkcert nss-tools # Fedora",
269
+ "# or: sudo pacman -Syu mkcert nss # Arch",
270
+ "mkcert -install",
271
+ ]
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()