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/helpers.py CHANGED
@@ -1,21 +1,9 @@
1
1
  import asyncio
2
2
  import inspect
3
- import json
4
3
  import os
5
- import platform
6
4
  import socket
7
- import time
8
5
  from collections.abc import Awaitable, Callable, Coroutine
9
- from pathlib import Path
10
- from typing import (
11
- Any,
12
- ParamSpec,
13
- Protocol,
14
- TypedDict,
15
- TypeVar,
16
- overload,
17
- override,
18
- )
6
+ from typing import Any, ParamSpec, Protocol, TypedDict, TypeVar, overload, override
19
7
  from urllib.parse import urlsplit
20
8
 
21
9
  from anyio import from_thread
@@ -380,115 +368,8 @@ def get_client_address_socketio(environ: dict[str, Any]) -> str | None:
380
368
  return None
381
369
 
382
370
 
383
- # --- Runtime lock helpers (prevent multiple dev instances per web root) ---
384
-
385
-
386
- def _is_process_alive(pid: int) -> bool:
387
- try:
388
- # On POSIX, signal 0 checks for existence without killing
389
- os.kill(pid, 0)
390
- except ProcessLookupError:
391
- return False
392
- except PermissionError:
393
- # Process exists but we may not have permission
394
- return True
395
- except Exception:
396
- # Best-effort: assume alive if uncertain
397
- return True
398
- return True
399
-
400
-
401
- def lock_path_for_web_root(web_root: Path, filename: str = ".pulse.lock") -> Path:
402
- return Path(web_root) / filename
403
-
404
-
405
- def write_gitignore_for_lock(lock_path: Path) -> None:
406
- try:
407
- gitignore_path = lock_path.parent / ".gitignore"
408
- pattern = f"\n{lock_path.name}\n"
409
- if gitignore_path.exists():
410
- try:
411
- content = gitignore_path.read_text()
412
- except Exception:
413
- content = ""
414
- if lock_path.name not in content.split():
415
- gitignore_path.write_text(content + pattern)
416
- else:
417
- gitignore_path.write_text(pattern.lstrip("\n"))
418
- except Exception:
419
- # Non-fatal
420
- pass
421
-
422
-
423
- def _read_lock(lock_path: Path) -> dict[str, Any] | None:
424
- try:
425
- data = json.loads(lock_path.read_text())
426
- if isinstance(data, dict):
427
- return data
428
- except Exception:
429
- return None
430
- return None
431
-
432
-
433
- def ensure_web_lock(lock_path: Path, *, owner: str = "server") -> tuple[Path, bool]:
434
- """Create a lock file or raise if an active one exists.
435
-
436
- Returns (lock_path, created_now)
437
- """
438
- lock_path = Path(lock_path)
439
- write_gitignore_for_lock(lock_path)
440
-
441
- if lock_path.exists():
442
- info = _read_lock(lock_path) or {}
443
- pid = int(info.get("pid", 0) or 0)
444
- if pid and _is_process_alive(pid):
445
- raise RuntimeError(
446
- f"Another Pulse dev instance appears to be running (pid={pid}) for {lock_path.parent}."
447
- )
448
- # Stale lock; continue to overwrite
449
-
450
- payload = {
451
- "pid": os.getpid(),
452
- "owner": owner,
453
- "created_at": int(time.time()),
454
- "hostname": socket.gethostname(),
455
- "platform": platform.platform(),
456
- "python": platform.python_version(),
457
- "cwd": os.getcwd(),
458
- }
459
- try:
460
- lock_path.parent.mkdir(parents=True, exist_ok=True)
461
- lock_path.write_text(json.dumps(payload))
462
- except Exception as exc:
463
- raise RuntimeError(f"Failed to create lock file at {lock_path}: {exc}") from exc
464
- return lock_path, True
465
-
466
-
467
- def validate_existing_lock(lock_path: Path) -> bool:
468
- """Validate an existing lock. Returns True if an active other instance exists.
469
-
470
- If the file is missing or stale, returns False. If an active other instance is
471
- detected, raises RuntimeError.
472
- """
473
- lock_path = Path(lock_path)
474
- if not lock_path.exists():
475
- return False
476
- info = _read_lock(lock_path) or {}
477
- pid = int(info.get("pid", 0) or 0)
478
- if pid and _is_process_alive(pid):
479
- # Active lock
480
- raise RuntimeError(
481
- f"Another Pulse dev instance appears to be running (pid={pid}) for {lock_path.parent}."
482
- )
483
- return False
484
-
485
-
486
- def remove_web_lock(lock_path: Path) -> None:
487
- try:
488
- Path(lock_path).unlink(missing_ok=True)
489
- except Exception:
490
- # Best-effort cleanup
491
- pass
371
+ # --- Runtime lock helpers moved to pulse.cli.web_lock ---
372
+ # Use WebLock context manager for idempotent lock management
492
373
 
493
374
 
494
375
  @overload
@@ -532,3 +413,19 @@ async def maybe_await(value: T | Awaitable[T]) -> T:
532
413
  if inspect.isawaitable(value):
533
414
  return await value
534
415
  return value
416
+
417
+
418
+ def find_available_port(start_port: int = 8000, max_attempts: int = 100) -> int:
419
+ """Find an available port starting from start_port."""
420
+ for port in range(start_port, start_port + max_attempts):
421
+ try:
422
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
423
+ # Allow reuse of addresses in TIME_WAIT state (matches uvicorn behavior)
424
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
425
+ s.bind(("localhost", port))
426
+ return port
427
+ except OSError:
428
+ continue
429
+ raise RuntimeError(
430
+ f"Could not find an available port after {max_attempts} attempts starting from {start_port}"
431
+ )
pulse/proxy.py ADDED
@@ -0,0 +1,192 @@
1
+ """
2
+ Proxy ASGI app for forwarding requests to React Router server in single-server mode.
3
+ """
4
+
5
+ import logging
6
+ from collections.abc import Iterable
7
+ from typing import Callable, cast
8
+
9
+ import httpx
10
+ from starlette.datastructures import Headers
11
+ from starlette.types import ASGIApp, Receive, Scope, Send
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class PulseProxy:
17
+ """
18
+ ASGI app that proxies non-API requests to React Router server.
19
+
20
+ In single-server mode, Python FastAPI handles /_pulse/* routes and
21
+ proxies everything else to the React Router server running on an internal port.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ app: ASGIApp,
27
+ get_web_port: Callable[[], int | None],
28
+ api_prefix: str = "/_pulse",
29
+ ):
30
+ """
31
+ Initialize proxy ASGI app.
32
+
33
+ Args:
34
+ app: The ASGI application to wrap (socketio.ASGIApp)
35
+ get_web_port: Callable that returns the React Router port (or None if not started)
36
+ api_prefix: Prefix for API routes that should NOT be proxied (default: "/_pulse")
37
+ """
38
+ self.app: ASGIApp = app
39
+ self.get_web_port: Callable[[], int | None] = get_web_port
40
+ self.api_prefix: str = api_prefix
41
+ self._client: httpx.AsyncClient | None = None
42
+
43
+ @property
44
+ def client(self) -> httpx.AsyncClient:
45
+ """Lazy initialization of HTTP client."""
46
+ if self._client is None:
47
+ self._client = httpx.AsyncClient(
48
+ timeout=httpx.Timeout(30.0),
49
+ follow_redirects=False,
50
+ )
51
+ return self._client
52
+
53
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
54
+ """
55
+ ASGI application handler.
56
+
57
+ Routes starting with api_prefix or WebSocket connections go to FastAPI.
58
+ Everything else is proxied to React Router.
59
+ """
60
+ if scope["type"] != "http":
61
+ # Pass through non-HTTP requests (WebSocket, lifespan, etc.)
62
+ await self.app(scope, receive, send)
63
+ return
64
+
65
+ path = scope["path"]
66
+
67
+ # Check if path starts with API prefix or is a WebSocket upgrade
68
+ if path.startswith(self.api_prefix):
69
+ # This is an API route, pass through to FastAPI
70
+ await self.app(scope, receive, send)
71
+ return
72
+
73
+ # Check if this is a WebSocket upgrade request (even if not prefixed)
74
+ headers = Headers(scope=scope)
75
+ if headers.get("upgrade", "").lower() == "websocket":
76
+ # WebSocket request, pass through to FastAPI
77
+ await self.app(scope, receive, send)
78
+ return
79
+
80
+ # Proxy to React Router server
81
+ await self._proxy_request(scope, receive, send)
82
+
83
+ async def _proxy_request(self, scope: Scope, receive: Receive, send: Send) -> None:
84
+ """
85
+ Forward HTTP request to React Router server and stream response back.
86
+ """
87
+ # Get the web server port
88
+ port = self.get_web_port()
89
+ if port is None:
90
+ # Web server not started yet, return error
91
+ await send(
92
+ {
93
+ "type": "http.response.start",
94
+ "status": 503,
95
+ "headers": [(b"content-type", b"text/plain")],
96
+ }
97
+ )
98
+ await send(
99
+ {
100
+ "type": "http.response.body",
101
+ "body": b"Service Unavailable: Web server not ready",
102
+ }
103
+ )
104
+ return
105
+
106
+ # Build target URL
107
+ path = scope["path"]
108
+ query_string = scope.get("query_string", b"").decode("utf-8")
109
+ target_url = f"http://localhost:{port}"
110
+ target_path = f"{target_url}{path}"
111
+ if query_string:
112
+ target_path += f"?{query_string}"
113
+
114
+ # Extract headers
115
+ headers: dict[str, str] = {}
116
+ for name, value in cast(Iterable[tuple[bytes, bytes]], scope["headers"]):
117
+ name = name.decode("latin1")
118
+ value = value.decode("latin1")
119
+
120
+ # Skip host header (will be set by httpx)
121
+ if name.lower() == "host":
122
+ continue
123
+
124
+ # Collect headers (handle multiple values)
125
+ existing = headers.get(name)
126
+ if existing:
127
+ headers[name] = f"{existing},{value}"
128
+ else:
129
+ headers[name] = value
130
+
131
+ # Read request body
132
+ body_parts: list[bytes] = []
133
+ while True:
134
+ message = await receive()
135
+ if message["type"] == "http.request":
136
+ body_parts.append(message.get("body", b""))
137
+ if not message.get("more_body", False):
138
+ break
139
+ body = b"".join(body_parts)
140
+
141
+ try:
142
+ # Forward request to React Router
143
+ method = scope["method"]
144
+ response = await self.client.request(
145
+ method=method,
146
+ url=target_path,
147
+ headers=headers,
148
+ content=body,
149
+ )
150
+
151
+ # Send response status
152
+ await send(
153
+ {
154
+ "type": "http.response.start",
155
+ "status": response.status_code,
156
+ "headers": [
157
+ (name.encode("latin1"), value.encode("latin1"))
158
+ for name, value in response.headers.items()
159
+ ],
160
+ }
161
+ )
162
+
163
+ # Stream response body
164
+ await send(
165
+ {
166
+ "type": "http.response.body",
167
+ "body": response.content,
168
+ }
169
+ )
170
+
171
+ except httpx.RequestError as e:
172
+ logger.error(f"Proxy request failed: {e}")
173
+
174
+ # Send error response
175
+ await send(
176
+ {
177
+ "type": "http.response.start",
178
+ "status": 502,
179
+ "headers": [(b"content-type", b"text/plain")],
180
+ }
181
+ )
182
+ await send(
183
+ {
184
+ "type": "http.response.body",
185
+ "body": b"Bad Gateway: Could not reach React Router server",
186
+ }
187
+ )
188
+
189
+ async def close(self):
190
+ """Close the HTTP client."""
191
+ if self._client is not None:
192
+ await self._client.aclose()
pulse/user_session.py CHANGED
@@ -189,8 +189,8 @@ class CookieSessionStore:
189
189
  if not secret:
190
190
  secret = env.pulse_secret or ""
191
191
  if not secret:
192
- mode = env.pulse_mode
193
- if mode == "prod":
192
+ pulse_env = env.pulse_env
193
+ if pulse_env == "prod":
194
194
  # In CI/production, require an explicit secret
195
195
  raise RuntimeError(
196
196
  "PULSE_SECRET must be set when using CookieSessionStore in production.\nCookieSessionStore is the default way of storing sessions in Pulse. Providing a secret is necessary to not invalidate all sessions on reload."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pulse-framework
3
- Version: 0.1.37
3
+ Version: 0.1.38a2
4
4
  Summary: Pulse - Full-stack framework for building real-time React applications in Python
5
5
  Requires-Dist: websockets>=12.0
6
6
  Requires-Dist: fastapi>=0.104.0
@@ -12,6 +12,7 @@ Requires-Dist: rich>=13.7.1
12
12
  Requires-Dist: python-multipart>=0.0.20
13
13
  Requires-Dist: python-dateutil>=2.9.0.post0
14
14
  Requires-Dist: watchfiles>=1.1.0
15
+ Requires-Dist: httpx>=0.28.1
15
16
  Requires-Python: >=3.11
16
17
  Description-Content-Type: text/markdown
17
18
 
@@ -1,16 +1,22 @@
1
- pulse/__init__.py,sha256=XJHCxLcA_mo3lRtP3tIp72Kqze6fFD8eRLRXZ5uiUuI,31475
2
- pulse/app.py,sha256=2LPXLOVXgFkOXAtbXdGsGjprs5rvEPo0Q5xky1scbXY,25249
1
+ pulse/__init__.py,sha256=aJg4LvIeLYalXvEe1u47_A9ktS3HGIf4ZeAo6w1tbMQ,31463
2
+ pulse/app.py,sha256=rCCbRUK7lNzsASbnTmPVG-JRjG0v8LTKPLTvCnPS978,29675
3
3
  pulse/channel.py,sha256=DuD1mg_xWvkpAWSKZ-EtBYdUzJ8IuKH0fxdgGOvFXpg,13041
4
4
  pulse/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
- pulse/cli/cmd.py,sha256=lqZii8euefMr3uRtQT0eon1TnkeFnZdmOzl9LiV1O48,15234
6
- pulse/cli/helpers.py,sha256=Hjda274MAIvqq_sED8XqPJTXKdAHsDoscP-05Pf0fm8,8062
5
+ pulse/cli/cmd.py,sha256=_5vZL-7qSHA20CPocS5MH5KhG1giSn1t11T4kdYmzCY,11951
6
+ pulse/cli/dependencies.py,sha256=ZBqBAfMvMBQUvh4THdPDztTMQ_dyR52S1IuotP_eEZs,5623
7
+ pulse/cli/folder_lock.py,sha256=kvUmZBg869lwCTIZFoge9dhorv8qPXHTWwVv_jQg1k8,3477
8
+ pulse/cli/helpers.py,sha256=8bRlV3d7w3w-jHaFvFYt9Pzue6_CbKOq_Z3jBsBOeUk,8820
9
+ pulse/cli/models.py,sha256=hRmIWmhXmGf2otzVm1do4Dm19rkWkmTwAA3Am3kw2tE,692
7
10
  pulse/cli/packages.py,sha256=e7ycwwJfdmB4pzrai4DHos6-JzyUgmE4DCZp0BqjdeI,6792
11
+ pulse/cli/processes.py,sha256=rv1FZ0aynNsYwAbyirqOOeTp-6gqfks8CETOAVPGwS0,5599
12
+ pulse/cli/secrets.py,sha256=dNfQe6AzSYhZuWveesjCRHIbvaPd3-F9lEJ-kZA7ROw,921
13
+ pulse/cli/uvicorn_log_config.py,sha256=Ip0iCeMUoY1ruv3Amf2SF84lW2DDpJFqdsLflZNxmeY,2407
8
14
  pulse/codegen/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
- pulse/codegen/codegen.py,sha256=IVP699lY7dd8oh4olaK45QBVTRGo4xNG2TsdVPnA5k8,10407
15
+ pulse/codegen/codegen.py,sha256=RMc2NkldX0dmxRG59gdulCMEzywvHMo2akeiHQMTu4I,10681
10
16
  pulse/codegen/imports.py,sha256=13f0uzJsotw069aP_COUUPMuTXXhRKRwUzfxsSCq_6A,6070
11
17
  pulse/codegen/js.py,sha256=7MuiECSJ-DulSqKuMZ8z1q_d7e3AbK6MYiNTYALZCLA,881
12
18
  pulse/codegen/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- pulse/codegen/templates/layout.py,sha256=VeJo5qwikQdmyXl3f1KAz3OeLqtQb28iGvED9IXodyM,3587
19
+ pulse/codegen/templates/layout.py,sha256=zvNAOo0rt0p6pduxZJ-nSZlzOYNySTV2KHkfUyU84RM,3676
14
20
  pulse/codegen/templates/route.py,sha256=cwmNHYkuecZ5M986hmm6SxisIoVSc656dtpqAvPjMjM,7824
15
21
  pulse/codegen/templates/routes_ts.py,sha256=nPgKCvU0gzue2k6KlOL1TJgrBqqRLmyy7K_qKAI8zAE,1129
16
22
  pulse/codegen/utils.py,sha256=QoXcV-h-DLLmq_t03hDNUePS0fNnofUQLoR-TXzDFCY,539
@@ -19,12 +25,12 @@ pulse/components/for_.py,sha256=LUyJEUlDM6b9oPjvUFgSsddxu6b6usF4BQdXe8FIiGI,1302
19
25
  pulse/components/if_.py,sha256=rQywsmdirNpkb-61ZEdF-tgzUh-37JWd4YFGblkzIdQ,1624
20
26
  pulse/components/react_router.py,sha256=TbRec-NVliUqrvAMeFXCrnDWV1rh6TGTPfRhqLuLubk,1129
21
27
  pulse/context.py,sha256=x_nCbCEUGygAdCZiTfko5uuYxVSAeCNhYa59zBq015M,1692
22
- pulse/cookies.py,sha256=elweCRj4mowgFEQG5sY1mNfunDUkWbfindvwMWRRibU,4787
28
+ pulse/cookies.py,sha256=c7ua1Lv6mNe1nYnA4SFVvewvRQAbYy9fN5G3Hr_Dr5c,5000
23
29
  pulse/css.py,sha256=-FyQQQ0EZI1Ins30qiF3l4z9yDb1V9qWuJKWxHcKGkw,3910
24
30
  pulse/decorators.py,sha256=8At1HQTFs9KG7nd83miGMe3KkhTBVGDviaqZaY62bHI,6651
25
- pulse/env.py,sha256=ltJQL5ZNvVyrLP7_IGzeK3RA55UhzJTzoV4FOrJtV2c,2937
31
+ pulse/env.py,sha256=BMEsIzR1_4c5bZzou7kYtSMk50gLnZ0Yf6l8U6Ve4IE,2575
26
32
  pulse/form.py,sha256=M87QwG4KFOrI8Nba7BTDoJ_wZ1-jzJW7QN4JweYCpuM,9004
27
- pulse/helpers.py,sha256=fhxoOvXQxnqgFnY_sZXOfwZxo8emhPiSSKnx1TWVj3M,13915
33
+ pulse/helpers.py,sha256=q54JGen1lBIEGyLnNKvmW_7nnTFqFZBDMYXsoXvth7o,11628
28
34
  pulse/hooks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
29
35
  pulse/hooks/core.py,sha256=Ksb-vSYbigL2GnUooxdLVUTfnT7cHOlYEKgKtrihvJo,7344
30
36
  pulse/hooks/effects.py,sha256=CQvt5viAweGLSxaGGlWm155GlEQiwQnGussw7OfiCGc,2393
@@ -42,6 +48,7 @@ pulse/html/tags.pyi,sha256=I8dFoft9w4RvneZ3li1weAdijY1krj9jfO_p2SU6e04,13953
42
48
  pulse/messages.py,sha256=SKfNHYCDkoRa5X0CPlBQbsFfqIVMZQ7z34vWxQY-bUs,3206
43
49
  pulse/middleware.py,sha256=26ETGBLrIj8m0DOOUkafhLueQDzUurgjcqtI1L-6DNs,6518
44
50
  pulse/plugin.py,sha256=rYk1tR4SSoQQM8ZD2rWC-AnMfcN7xOscW92RbyK6LKA,594
51
+ pulse/proxy.py,sha256=jQ9XRiRYHSeHA6GOVuYhXgLWU7HoKNtobQWn_bt07uQ,4984
45
52
  pulse/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
46
53
  pulse/query.py,sha256=u0KVFNt0d36sfmoxpQXvJ8DjctIHZaM_szlEonRiyRg,12849
47
54
  pulse/react_component.py,sha256=Rw1J6cHOX8-K3BnkswVOu2COgneVvRz1OYmyXkX17RM,25993
@@ -55,10 +62,10 @@ pulse/serializer.py,sha256=8RAITNoSNm5-U38elHpWmkBpcM_rxZFMCluJSfldfk4,5420
55
62
  pulse/state.py,sha256=nbnN9wGrHTP0AOFBfb4zKNzYaSkmLR5u0t4TVHg-UDA,9972
56
63
  pulse/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
57
64
  pulse/types/event_handler.py,sha256=OF7sOgYBb6iUs59RH1vQIH7aOrGPfs3nAaF7how-4PQ,1658
58
- pulse/user_session.py,sha256=UOOjUs18Ock1kNGMVnql02UqK3VJEfMF3phjFuogQWc,7569
65
+ pulse/user_session.py,sha256=kCZtQpYZe2keDXzusd6jsjjw075am0dXrb25jKLg5JU,7578
59
66
  pulse/vdom.py,sha256=KTNBh2dVvDy9eXRzhneBJgk7F35MyWec8R_puQ4tSRY,12420
60
67
  pulse/version.py,sha256=711vaM1jVIQPgkisGgKZqwmw019qZIsc_QTae75K2pg,1895
61
- pulse_framework-0.1.37.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
62
- pulse_framework-0.1.37.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
63
- pulse_framework-0.1.37.dist-info/METADATA,sha256=WYlLOfcpgE0FMRyM9qKXgpbAAk6Gu5Q0tfPcd8iWrcY,551
64
- pulse_framework-0.1.37.dist-info/RECORD,,
68
+ pulse_framework-0.1.38a2.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
69
+ pulse_framework-0.1.38a2.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
70
+ pulse_framework-0.1.38a2.dist-info/METADATA,sha256=Il4RlKJSqYB_RSQ5JyRJb0Qu897nUm-MACZA1VgrV3Y,582
71
+ pulse_framework-0.1.38a2.dist-info/RECORD,,