pyview-web 0.2.5__py3-none-any.whl → 0.3.0__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.
pyview/csrf.py CHANGED
@@ -1,8 +1,11 @@
1
1
  from itsdangerous import BadData, SignatureExpired, URLSafeTimedSerializer
2
2
  from typing import Optional
3
3
  import hmac
4
+ import logging
4
5
  from pyview.secret import get_secret
5
6
 
7
+ logger = logging.getLogger(__name__)
8
+
6
9
 
7
10
  def generate_csrf_token(value: str, salt: Optional[str] = None) -> str:
8
11
  """
@@ -20,6 +23,6 @@ def validate_csrf_token(data: str, expected: str, salt: Optional[str] = None) ->
20
23
  try:
21
24
  token = s.loads(data, max_age=3600)
22
25
  return hmac.compare_digest(token, expected)
23
- except (BadData, SignatureExpired) as e:
24
- print(e)
26
+ except (BadData, SignatureExpired):
27
+ logger.debug("CSRF token validation failed", exc_info=True)
25
28
  return False
@@ -55,10 +55,10 @@ class BaseEventHandler:
55
55
  else:
56
56
  logging.warning(f"Unhandled event: {event} {payload}")
57
57
 
58
- async def handle_info(self, info: "InfoEvent", socket):
59
- handler = self._info_handlers.get(info.name)
58
+ async def handle_info(self, event: "InfoEvent", socket):
59
+ handler = self._info_handlers.get(event.name)
60
60
 
61
61
  if handler:
62
- return await handler(self, info, socket)
62
+ return await handler(self, event, socket)
63
63
  else:
64
- logging.warning(f"Unhandled info: {info.name} {info}")
64
+ logging.warning(f"Unhandled info: {event.name} {event}")
@@ -0,0 +1,21 @@
1
+ """PyView instrumentation interfaces and implementations."""
2
+
3
+ from .interfaces import (
4
+ InstrumentationProvider,
5
+ Counter,
6
+ UpDownCounter,
7
+ Gauge,
8
+ Histogram,
9
+ Span,
10
+ )
11
+ from .noop import NoOpInstrumentation
12
+
13
+ __all__ = [
14
+ "InstrumentationProvider",
15
+ "Counter",
16
+ "UpDownCounter",
17
+ "Gauge",
18
+ "Histogram",
19
+ "Span",
20
+ "NoOpInstrumentation",
21
+ ]
@@ -0,0 +1,182 @@
1
+ """Abstract base classes for PyView instrumentation."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any, Optional, Callable, Union
5
+ from contextlib import contextmanager
6
+ import functools
7
+ import time
8
+ import asyncio
9
+
10
+
11
+ class Counter(ABC):
12
+ """A monotonically increasing counter metric."""
13
+
14
+ @abstractmethod
15
+ def add(self, value: float = 1, attributes: Optional[dict[str, Any]] = None) -> None:
16
+ """Add to the counter value."""
17
+ pass
18
+
19
+
20
+ class UpDownCounter(ABC):
21
+ """A counter that can increase or decrease."""
22
+
23
+ @abstractmethod
24
+ def add(self, value: float, attributes: Optional[dict[str, Any]] = None) -> None:
25
+ """Add to the counter value (can be negative)."""
26
+ pass
27
+
28
+
29
+ class Gauge(ABC):
30
+ """A point-in-time value metric."""
31
+
32
+ @abstractmethod
33
+ def set(self, value: float, attributes: Optional[dict[str, Any]] = None) -> None:
34
+ """Set the gauge to a specific value."""
35
+ pass
36
+
37
+
38
+ class Histogram(ABC):
39
+ """A metric that tracks the distribution of values."""
40
+
41
+ @abstractmethod
42
+ def record(self, value: float, attributes: Optional[dict[str, Any]] = None) -> None:
43
+ """Record a value in the histogram."""
44
+ pass
45
+
46
+
47
+ class Span(ABC):
48
+ """A span representing a unit of work."""
49
+
50
+ @abstractmethod
51
+ def set_attribute(self, key: str, value: Any) -> None:
52
+ """Set an attribute on the span."""
53
+ pass
54
+
55
+ @abstractmethod
56
+ def add_event(self, name: str, attributes: Optional[dict[str, Any]] = None) -> None:
57
+ """Add an event to the span."""
58
+ pass
59
+
60
+ @abstractmethod
61
+ def end(self, status: str = "ok") -> None:
62
+ """End the span with a status."""
63
+ pass
64
+
65
+
66
+ class InstrumentationProvider(ABC):
67
+ """Abstract base class for instrumentation providers."""
68
+
69
+ # Create instruments (efficient, reusable)
70
+ @abstractmethod
71
+ def create_counter(self, name: str, description: str = "", unit: str = "") -> Counter:
72
+ """Create a counter instrument."""
73
+ pass
74
+
75
+ @abstractmethod
76
+ def create_updown_counter(self, name: str, description: str = "", unit: str = "") -> UpDownCounter:
77
+ """Create an up/down counter instrument."""
78
+ pass
79
+
80
+ @abstractmethod
81
+ def create_gauge(self, name: str, description: str = "", unit: str = "") -> Gauge:
82
+ """Create a gauge instrument."""
83
+ pass
84
+
85
+ @abstractmethod
86
+ def create_histogram(self, name: str, description: str = "", unit: str = "") -> Histogram:
87
+ """Create a histogram instrument."""
88
+ pass
89
+
90
+ # Convenience methods (simple, pass name each time)
91
+ def increment_counter(self, name: str, value: float = 1, attributes: Optional[dict[str, Any]] = None) -> None:
92
+ """Convenience method to increment a counter."""
93
+ counter = self.create_counter(name)
94
+ counter.add(value, attributes)
95
+
96
+ def update_updown_counter(self, name: str, value: float, attributes: Optional[dict[str, Any]] = None) -> None:
97
+ """Convenience method to update an up/down counter."""
98
+ counter = self.create_updown_counter(name)
99
+ counter.add(value, attributes)
100
+
101
+ def record_gauge(self, name: str, value: float, attributes: Optional[dict[str, Any]] = None) -> None:
102
+ """Convenience method to record a gauge value."""
103
+ gauge = self.create_gauge(name)
104
+ gauge.set(value, attributes)
105
+
106
+ def record_histogram(self, name: str, value: float, attributes: Optional[dict[str, Any]] = None) -> None:
107
+ """Convenience method to record a histogram value."""
108
+ histogram = self.create_histogram(name)
109
+ histogram.record(value, attributes)
110
+
111
+ # Span methods
112
+ @abstractmethod
113
+ def start_span(self, name: str, attributes: Optional[dict[str, Any]] = None) -> Span:
114
+ """Start a new span."""
115
+ pass
116
+
117
+ # Context manager for spans
118
+ @contextmanager
119
+ def span(self, name: str, attributes: Optional[dict[str, Any]] = None):
120
+ """Context manager for creating and managing spans."""
121
+ span = self.start_span(name, attributes)
122
+ try:
123
+ yield span
124
+ except Exception as e:
125
+ span.set_attribute("error", True)
126
+ span.set_attribute("error.message", str(e))
127
+ span.set_attribute("error.type", type(e).__name__)
128
+ span.end("error")
129
+ raise
130
+ else:
131
+ span.end("ok")
132
+
133
+ # Decorator for tracing functions
134
+ def trace(self, name: Optional[str] = None, attributes: Optional[dict[str, Any]] = None):
135
+ """Decorator to trace function execution."""
136
+ def decorator(func: Callable):
137
+ span_name = name or f"{func.__module__}.{func.__name__}"
138
+
139
+ if asyncio.iscoroutinefunction(func):
140
+ @functools.wraps(func)
141
+ async def async_wrapper(*args, **kwargs):
142
+ with self.span(span_name, attributes):
143
+ return await func(*args, **kwargs)
144
+ return async_wrapper
145
+ else:
146
+ @functools.wraps(func)
147
+ def sync_wrapper(*args, **kwargs):
148
+ with self.span(span_name, attributes):
149
+ return func(*args, **kwargs)
150
+ return sync_wrapper
151
+ return decorator
152
+
153
+ # Timing helpers
154
+ @contextmanager
155
+ def time_histogram(self, name: str, attributes: Optional[dict[str, Any]] = None):
156
+ """Context manager to time operations and record to histogram."""
157
+ histogram = self.create_histogram(name, unit="s")
158
+ start = time.perf_counter()
159
+ try:
160
+ yield
161
+ finally:
162
+ duration = time.perf_counter() - start
163
+ histogram.record(duration, attributes)
164
+
165
+ def time_function(self, histogram_name: Optional[str] = None, attributes: Optional[dict[str, Any]] = None):
166
+ """Decorator to time function execution."""
167
+ def decorator(func: Callable):
168
+ name = histogram_name or f"{func.__module__}.{func.__name__}.duration"
169
+
170
+ if asyncio.iscoroutinefunction(func):
171
+ @functools.wraps(func)
172
+ async def async_wrapper(*args, **kwargs):
173
+ with self.time_histogram(name, attributes):
174
+ return await func(*args, **kwargs)
175
+ return async_wrapper
176
+ else:
177
+ @functools.wraps(func)
178
+ def sync_wrapper(*args, **kwargs):
179
+ with self.time_histogram(name, attributes):
180
+ return func(*args, **kwargs)
181
+ return sync_wrapper
182
+ return decorator
@@ -0,0 +1,97 @@
1
+ """No-operation implementation of instrumentation interfaces."""
2
+
3
+ from typing import Any, Optional
4
+ from .interfaces import (
5
+ InstrumentationProvider,
6
+ Counter,
7
+ UpDownCounter,
8
+ Gauge,
9
+ Histogram,
10
+ Span,
11
+ )
12
+
13
+
14
+ class NoOpCounter(Counter):
15
+ """No-op counter implementation."""
16
+
17
+ def add(self, value: float = 1, attributes: Optional[dict[str, Any]] = None) -> None:
18
+ """No-op: does nothing."""
19
+ pass
20
+
21
+
22
+ class NoOpUpDownCounter(UpDownCounter):
23
+ """No-op up/down counter implementation."""
24
+
25
+ def add(self, value: float, attributes: Optional[dict[str, Any]] = None) -> None:
26
+ """No-op: does nothing."""
27
+ pass
28
+
29
+
30
+ class NoOpGauge(Gauge):
31
+ """No-op gauge implementation."""
32
+
33
+ def set(self, value: float, attributes: Optional[dict[str, Any]] = None) -> None:
34
+ """No-op: does nothing."""
35
+ pass
36
+
37
+
38
+ class NoOpHistogram(Histogram):
39
+ """No-op histogram implementation."""
40
+
41
+ def record(self, value: float, attributes: Optional[dict[str, Any]] = None) -> None:
42
+ """No-op: does nothing."""
43
+ pass
44
+
45
+
46
+ class NoOpSpan(Span):
47
+ """No-op span implementation."""
48
+
49
+ def set_attribute(self, key: str, value: Any) -> None:
50
+ """No-op: does nothing."""
51
+ pass
52
+
53
+ def add_event(self, name: str, attributes: Optional[dict[str, Any]] = None) -> None:
54
+ """No-op: does nothing."""
55
+ pass
56
+
57
+ def end(self, status: str = "ok") -> None:
58
+ """No-op: does nothing."""
59
+ pass
60
+
61
+
62
+ class NoOpInstrumentation(InstrumentationProvider):
63
+ """
64
+ Default no-operation instrumentation provider.
65
+
66
+ This implementation does nothing and has zero performance overhead.
67
+ It's used as the default when no instrumentation is configured.
68
+ """
69
+
70
+ def __init__(self):
71
+ """Initialize the no-op provider."""
72
+ # Cache single instances to avoid object creation overhead
73
+ self._counter = NoOpCounter()
74
+ self._updown_counter = NoOpUpDownCounter()
75
+ self._gauge = NoOpGauge()
76
+ self._histogram = NoOpHistogram()
77
+ self._span = NoOpSpan()
78
+
79
+ def create_counter(self, name: str, description: str = "", unit: str = "") -> Counter:
80
+ """Return a no-op counter."""
81
+ return self._counter
82
+
83
+ def create_updown_counter(self, name: str, description: str = "", unit: str = "") -> UpDownCounter:
84
+ """Return a no-op up/down counter."""
85
+ return self._updown_counter
86
+
87
+ def create_gauge(self, name: str, description: str = "", unit: str = "") -> Gauge:
88
+ """Return a no-op gauge."""
89
+ return self._gauge
90
+
91
+ def create_histogram(self, name: str, description: str = "", unit: str = "") -> Histogram:
92
+ """Return a no-op histogram."""
93
+ return self._histogram
94
+
95
+ def start_span(self, name: str, attributes: Optional[dict[str, Any]] = None) -> Span:
96
+ """Return a no-op span."""
97
+ return self._span
pyview/live_socket.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
  from starlette.websockets import WebSocket
3
3
  import json
4
+ import logging
4
5
  from typing import (
5
6
  Any,
6
7
  TypeVar,
@@ -13,6 +14,7 @@ from typing import (
13
14
  )
14
15
  from urllib.parse import urlencode
15
16
  from apscheduler.schedulers.asyncio import AsyncIOScheduler
17
+ from apscheduler.jobstores.base import JobLookupError
16
18
  from pyview.vendor.flet.pubsub import PubSubHub, PubSub
17
19
  from pyview.events import InfoEvent
18
20
  from pyview.uploads import UploadConstraints, UploadConfig, UploadManager
@@ -21,12 +23,13 @@ from pyview.template.render_diff import calc_diff
21
23
  import datetime
22
24
  from pyview.async_stream_runner import AsyncStreamRunner
23
25
 
26
+ logger = logging.getLogger(__name__)
27
+
24
28
 
25
29
  if TYPE_CHECKING:
26
30
  from .live_view import LiveView
31
+ from .instrumentation import InstrumentationProvider
27
32
 
28
- scheduler = AsyncIOScheduler()
29
- scheduler.start()
30
33
 
31
34
  pub_sub_hub = PubSubHub()
32
35
 
@@ -55,16 +58,25 @@ class ConnectedLiveViewSocket(Generic[T]):
55
58
  upload_manager: UploadManager
56
59
  prev_rendered: Optional[dict[str, Any]] = None
57
60
 
58
- def __init__(self, websocket: WebSocket, topic: str, liveview: LiveView):
61
+ def __init__(
62
+ self,
63
+ websocket: WebSocket,
64
+ topic: str,
65
+ liveview: LiveView,
66
+ scheduler: AsyncIOScheduler,
67
+ instrumentation: "InstrumentationProvider",
68
+ ):
59
69
  self.websocket = websocket
60
70
  self.topic = topic
61
71
  self.liveview = liveview
72
+ self.instrumentation = instrumentation
62
73
  self.scheduled_jobs = []
63
74
  self.connected = True
64
75
  self.pub_sub = PubSub(pub_sub_hub, topic)
65
76
  self.pending_events = []
66
77
  self.upload_manager = UploadManager()
67
78
  self.stream_runner = AsyncStreamRunner(self)
79
+ self.scheduler = scheduler
68
80
 
69
81
  @property
70
82
  def meta(self) -> PyViewMeta:
@@ -81,13 +93,13 @@ class ConnectedLiveViewSocket(Generic[T]):
81
93
 
82
94
  def schedule_info(self, event, seconds):
83
95
  id = f"{self.topic}:{event}"
84
- scheduler.add_job(
96
+ self.scheduler.add_job(
85
97
  self.send_info, args=[event], id=id, trigger="interval", seconds=seconds
86
98
  )
87
99
  self.scheduled_jobs.append(id)
88
100
 
89
101
  def schedule_info_once(self, event, seconds=None):
90
- scheduler.add_job(
102
+ self.scheduler.add_job(
91
103
  self.send_info,
92
104
  args=[event],
93
105
  trigger="date",
@@ -113,8 +125,13 @@ class ConnectedLiveViewSocket(Generic[T]):
113
125
  await self.websocket.send_text(json.dumps(resp))
114
126
  except Exception:
115
127
  for id in self.scheduled_jobs:
116
- print("Removing job", id)
117
- scheduler.remove_job(id)
128
+ logger.debug("Removing scheduled job %s", id)
129
+ try:
130
+ self.scheduler.remove_job(id)
131
+ except Exception:
132
+ logger.warning(
133
+ "Failed to remove scheduled job %s", id, exc_info=True
134
+ )
118
135
 
119
136
  async def push_patch(self, path: str, params: dict[str, Any] = {}):
120
137
  # or "replace"
@@ -142,8 +159,38 @@ class ConnectedLiveViewSocket(Generic[T]):
142
159
  await self.liveview.handle_params(to, params, self)
143
160
  try:
144
161
  await self.websocket.send_text(json.dumps(message))
145
- except Exception as e:
146
- print("Error sending message", e)
162
+ except Exception:
163
+ logger.warning("Error sending patch message", exc_info=True)
164
+
165
+ async def push_navigate(self, path: str, params: dict[str, Any] = {}):
166
+ """Navigate to a different LiveView without full page reload"""
167
+ await self._navigate(path, params, kind="push")
168
+
169
+ async def replace_navigate(self, path: str, params: dict[str, Any] = {}):
170
+ """Navigate to a different LiveView, replacing current history entry"""
171
+ await self._navigate(path, params, kind="replace")
172
+
173
+ async def _navigate(self, path: str, params: dict[str, Any], kind: str):
174
+ """Internal navigation helper"""
175
+ to = path
176
+ if params:
177
+ to = to + "?" + urlencode(params)
178
+
179
+ message = [
180
+ None,
181
+ None,
182
+ self.topic,
183
+ "live_redirect",
184
+ {
185
+ "kind": kind,
186
+ "to": to,
187
+ },
188
+ ]
189
+
190
+ try:
191
+ await self.websocket.send_text(json.dumps(message))
192
+ except Exception:
193
+ logger.warning("Error sending navigation message", exc_info=True)
147
194
 
148
195
  async def push_event(self, event: str, value: dict[str, Any]):
149
196
  self.pending_events.append((event, value))
@@ -156,7 +203,10 @@ class ConnectedLiveViewSocket(Generic[T]):
156
203
  async def close(self):
157
204
  self.connected = False
158
205
  for id in self.scheduled_jobs:
159
- scheduler.remove_job(id)
206
+ try:
207
+ self.scheduler.remove_job(id)
208
+ except JobLookupError:
209
+ pass
160
210
  await self.pub_sub.unsubscribe_all_async()
161
211
 
162
212
  try:
pyview/pyview.py CHANGED
@@ -5,12 +5,14 @@ from starlette.routing import Route, WebSocketRoute
5
5
  from starlette.requests import Request
6
6
  import uuid
7
7
  from urllib.parse import parse_qs, urlparse
8
+ from typing import Optional
8
9
 
9
10
  from pyview.live_socket import UnconnectedSocket
10
11
  from pyview.csrf import generate_csrf_token
11
12
  from pyview.session import serialize_session
12
13
  from pyview.auth import AuthProviderFactory
13
14
  from pyview.meta import PyViewMeta
15
+ from pyview.instrumentation import InstrumentationProvider, NoOpInstrumentation
14
16
  from .ws_handler import LiveSocketHandler
15
17
  from .live_view import LiveView
16
18
  from .live_routes import LiveViewLookup
@@ -24,12 +26,14 @@ from .template import (
24
26
 
25
27
  class PyView(Starlette):
26
28
  rootTemplate: RootTemplate
29
+ instrumentation: InstrumentationProvider
27
30
 
28
- def __init__(self, *args, **kwargs):
31
+ def __init__(self, *args, instrumentation: Optional[InstrumentationProvider] = None, **kwargs):
29
32
  super().__init__(*args, **kwargs)
30
33
  self.rootTemplate = defaultRootTemplate()
34
+ self.instrumentation = instrumentation or NoOpInstrumentation()
31
35
  self.view_lookup = LiveViewLookup()
32
- self.live_handler = LiveSocketHandler(self.view_lookup)
36
+ self.live_handler = LiveSocketHandler(self.view_lookup, self.instrumentation)
33
37
 
34
38
  self.routes.append(WebSocketRoute("/live/websocket", self.live_handler.handle))
35
39
  self.add_middleware(GZipMiddleware)
pyview/uploads.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import datetime
2
2
  import uuid
3
+ import logging
3
4
  from pydantic import BaseModel, Field
4
5
  from typing import Optional, Any, Literal, Generator
5
6
  from dataclasses import dataclass, field
@@ -7,6 +8,8 @@ from contextlib import contextmanager
7
8
  import os
8
9
  import tempfile
9
10
 
11
+ logger = logging.getLogger(__name__)
12
+
10
13
 
11
14
  @dataclass
12
15
  class ConstraintViolation:
@@ -143,8 +146,8 @@ class UploadConfig(BaseModel):
143
146
  finally:
144
147
  try:
145
148
  self.uploads.close()
146
- except Exception as e:
147
- print("Error closing uploads", e)
149
+ except Exception:
150
+ logger.warning("Error closing uploads", exc_info=True)
148
151
 
149
152
  self.uploads = ActiveUploads()
150
153
  self.entries_by_ref = {}
@@ -185,14 +188,14 @@ class UploadManager:
185
188
  entries = uploads[config.ref]
186
189
  config.add_entries(entries)
187
190
  else:
188
- print("can't find ref", config.ref)
191
+ logger.warning("Upload config not found for ref: %s", config.ref)
189
192
 
190
193
  def process_allow_upload(self, payload: dict[str, Any]) -> dict[str, Any]:
191
194
  ref = payload["ref"]
192
195
  config = self.config_for_ref(ref)
193
196
 
194
197
  if not config:
195
- print("Can't find config for ref", ref)
198
+ logger.warning("Can't find upload config for ref: %s", ref)
196
199
  return {"error": [(ref, "not_found")]}
197
200
 
198
201
  proposed_entries = payload["entries"]
pyview/ws_handler.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from typing import Optional
2
2
  import json
3
+ import logging
3
4
  from starlette.websockets import WebSocket, WebSocketDisconnect
4
5
  from urllib.parse import urlparse, parse_qs
5
6
  from pyview.live_socket import ConnectedLiveViewSocket, LiveViewSocket
@@ -8,17 +9,58 @@ from pyview.csrf import validate_csrf_token
8
9
  from pyview.session import deserialize_session
9
10
  from pyview.auth import AuthProviderFactory
10
11
  from pyview.phx_message import parse_message
12
+ from pyview.instrumentation import InstrumentationProvider
13
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
14
+
15
+ logger = logging.getLogger(__name__)
11
16
 
12
17
 
13
18
  class AuthException(Exception):
14
19
  pass
15
20
 
16
21
 
22
+ class LiveSocketMetrics:
23
+ """Container for LiveSocket instrumentation metrics."""
24
+
25
+ def __init__(self, instrumentation: InstrumentationProvider):
26
+ self.active_connections = instrumentation.create_updown_counter(
27
+ "pyview.websocket.active_connections",
28
+ "Number of active WebSocket connections"
29
+ )
30
+ self.mounts = instrumentation.create_counter(
31
+ "pyview.liveview.mounts",
32
+ "Total number of LiveView mounts"
33
+ )
34
+ self.events_processed = instrumentation.create_counter(
35
+ "pyview.events.processed",
36
+ "Total number of events processed"
37
+ )
38
+ self.event_duration = instrumentation.create_histogram(
39
+ "pyview.events.duration",
40
+ "Event processing duration",
41
+ unit="s"
42
+ )
43
+ self.message_size = instrumentation.create_histogram(
44
+ "pyview.websocket.message_size",
45
+ "WebSocket message size in bytes",
46
+ unit="bytes"
47
+ )
48
+ self.render_duration = instrumentation.create_histogram(
49
+ "pyview.render.duration",
50
+ "Template render duration",
51
+ unit="s"
52
+ )
53
+
54
+
17
55
  class LiveSocketHandler:
18
- def __init__(self, routes: LiveViewLookup):
56
+ def __init__(self, routes: LiveViewLookup, instrumentation: InstrumentationProvider):
19
57
  self.routes = routes
58
+ self.instrumentation = instrumentation
59
+ self.metrics = LiveSocketMetrics(instrumentation)
20
60
  self.manager = ConnectionManager()
21
61
  self.sessions = 0
62
+ self.scheduler = AsyncIOScheduler()
63
+ self.scheduler.start()
22
64
 
23
65
  async def check_auth(self, websocket: WebSocket, lv):
24
66
  if not await AuthProviderFactory.get(lv).has_required_auth(websocket):
@@ -26,7 +68,9 @@ class LiveSocketHandler:
26
68
 
27
69
  async def handle(self, websocket: WebSocket):
28
70
  await self.manager.connect(websocket)
29
-
71
+
72
+ # Track active connections
73
+ self.metrics.active_connections.add(1)
30
74
  self.sessions += 1
31
75
  topic = None
32
76
  socket: Optional[LiveViewSocket] = None
@@ -43,12 +87,16 @@ class LiveSocketHandler:
43
87
  url = urlparse(payload["url"])
44
88
  lv, path_params = self.routes.get(url.path)
45
89
  await self.check_auth(websocket, lv)
46
- socket = ConnectedLiveViewSocket(websocket, topic, lv)
90
+ socket = ConnectedLiveViewSocket(websocket, topic, lv, self.scheduler, self.instrumentation)
47
91
 
48
92
  session = {}
49
93
  if "session" in payload:
50
94
  session = deserialize_session(payload["session"])
51
95
 
96
+ # Track mount
97
+ view_name = lv.__class__.__name__
98
+ self.metrics.mounts.add(1, {"view": view_name})
99
+
52
100
  await lv.mount(socket, session)
53
101
 
54
102
  # Parse query parameters and merge with path parameters
@@ -76,9 +124,16 @@ class LiveSocketHandler:
76
124
  if socket:
77
125
  await socket.close()
78
126
  self.sessions -= 1
127
+ self.metrics.active_connections.add(-1)
79
128
  except AuthException:
80
129
  await websocket.close()
81
130
  self.sessions -= 1
131
+ self.metrics.active_connections.add(-1)
132
+ except Exception:
133
+ logger.exception("Unexpected error in WebSocket handler")
134
+ self.sessions -= 1
135
+ self.metrics.active_connections.add(-1)
136
+ raise
82
137
 
83
138
  async def handle_connected(self, myJoinId, socket: ConnectedLiveViewSocket):
84
139
  while True:
@@ -105,8 +160,19 @@ class LiveSocketHandler:
105
160
  value = parse_qs(value)
106
161
  socket.upload_manager.maybe_process_uploads(value, payload)
107
162
 
108
- await socket.liveview.handle_event(payload["event"], value, socket)
109
- rendered = await _render(socket)
163
+ # Track event metrics
164
+ event_name = payload["event"]
165
+ view_name = socket.liveview.__class__.__name__
166
+ self.metrics.events_processed.add(1, {"event": event_name, "view": view_name})
167
+
168
+ # Time event processing
169
+ with self.instrumentation.time_histogram("pyview.events.duration",
170
+ {"event": event_name, "view": view_name}):
171
+ await socket.liveview.handle_event(event_name, value, socket)
172
+
173
+ # Time rendering
174
+ with self.instrumentation.time_histogram("pyview.render.duration", {"view": view_name}):
175
+ rendered = await _render(socket)
110
176
 
111
177
  hook_events = (
112
178
  {} if not socket.pending_events else {"e": socket.pending_events}
@@ -123,9 +189,9 @@ class LiveSocketHandler:
123
189
  "phx_reply",
124
190
  {"response": {"diff": diff | hook_events}, "status": "ok"},
125
191
  ]
126
- await self.manager.send_personal_message(
127
- json.dumps(resp), socket.websocket
128
- )
192
+ resp_json = json.dumps(resp)
193
+ self.metrics.message_size.record(len(resp_json))
194
+ await self.manager.send_personal_message(resp_json, socket.websocket)
129
195
  continue
130
196
 
131
197
  if event == "live_patch":
@@ -185,21 +251,64 @@ class LiveSocketHandler:
185
251
  )
186
252
  continue
187
253
 
188
- # file upload
254
+ # file upload or navigation
189
255
  if event == "phx_join":
190
- socket.upload_manager.add_upload(joinRef, payload)
256
+ # Check if this is a file upload join (topic starts with "lvu:")
257
+ if topic.startswith("lvu:"):
258
+ # This is a file upload join
259
+ socket.upload_manager.add_upload(joinRef, payload)
260
+
261
+ resp = [
262
+ joinRef,
263
+ mesageRef,
264
+ topic,
265
+ "phx_reply",
266
+ {"response": {}, "status": "ok"},
267
+ ]
191
268
 
192
- resp = [
193
- joinRef,
194
- mesageRef,
195
- topic,
196
- "phx_reply",
197
- {"response": {}, "status": "ok"},
198
- ]
269
+ await self.manager.send_personal_message(
270
+ json.dumps(resp), socket.websocket
271
+ )
272
+ else:
273
+ # This is a navigation join (topic starts with "lv:")
274
+ # Navigation payload has 'redirect' field instead of 'url'
275
+ url_str_raw = payload.get("redirect") or payload.get("url")
276
+ url_str: str = url_str_raw.decode("utf-8") if isinstance(url_str_raw, bytes) else str(url_str_raw)
277
+ url = urlparse(url_str)
278
+ lv, path_params = self.routes.get(url.path)
279
+ await self.check_auth(socket.websocket, lv)
280
+
281
+ # Create new socket for new LiveView
282
+ socket = ConnectedLiveViewSocket(
283
+ socket.websocket, topic, lv, self.scheduler, self.instrumentation
284
+ )
199
285
 
200
- await self.manager.send_personal_message(
201
- json.dumps(resp), socket.websocket
202
- )
286
+ session = {}
287
+ if "session" in payload:
288
+ session = deserialize_session(payload["session"])
289
+
290
+ await lv.mount(socket, session)
291
+
292
+ # Parse query parameters and merge with path parameters
293
+ query_params = parse_qs(url.query)
294
+ merged_params = {**query_params, **path_params}
295
+
296
+ await lv.handle_params(url, merged_params, socket)
297
+
298
+ rendered = await _render(socket)
299
+ socket.prev_rendered = rendered
300
+
301
+ resp = [
302
+ joinRef,
303
+ mesageRef,
304
+ topic,
305
+ "phx_reply",
306
+ {"response": {"rendered": rendered}, "status": "ok"},
307
+ ]
308
+
309
+ await self.manager.send_personal_message(
310
+ json.dumps(resp), socket.websocket
311
+ )
203
312
 
204
313
  if event == "chunk":
205
314
  socket.upload_manager.add_chunk(joinRef, payload) # type: ignore
@@ -247,6 +356,23 @@ class LiveSocketHandler:
247
356
  json.dumps(resp), socket.websocket
248
357
  )
249
358
 
359
+ if event == "phx_leave":
360
+ # Handle LiveView navigation - clean up current LiveView
361
+ await socket.close()
362
+
363
+ resp = [
364
+ joinRef,
365
+ mesageRef,
366
+ topic,
367
+ "phx_reply",
368
+ {"response": {}, "status": "ok"},
369
+ ]
370
+ await self.manager.send_personal_message(
371
+ json.dumps(resp), socket.websocket
372
+ )
373
+ # Continue to wait for next phx_join
374
+ continue
375
+
250
376
 
251
377
  async def _render(socket: ConnectedLiveViewSocket):
252
378
  rendered = (await socket.liveview.render(socket.context, socket.meta)).tree()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pyview-web
3
- Version: 0.2.5
3
+ Version: 0.3.0
4
4
  Summary: LiveView in Python
5
5
  License: MIT
6
6
  Keywords: web,api,LiveView
@@ -29,14 +29,13 @@ Classifier: Topic :: Software Development :: Libraries
29
29
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
30
30
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
31
31
  Classifier: Typing :: Typed
32
- Requires-Dist: APScheduler (==3.9.1.post1)
32
+ Requires-Dist: APScheduler (==3.11.0)
33
33
  Requires-Dist: click (>=8.1.7,<9.0.0)
34
- Requires-Dist: itsdangerous (>=2.1.2,<3.0.0)
35
- Requires-Dist: markupsafe (>=2.1.2,<3.0.0)
36
- Requires-Dist: psutil (>=5.9.4,<6.0.0)
34
+ Requires-Dist: itsdangerous (>=2.2.0,<3.0.0)
35
+ Requires-Dist: markupsafe (>=3.0.2,<4.0.0)
37
36
  Requires-Dist: pydantic (>=2.9.2,<3.0.0)
38
- Requires-Dist: starlette (==0.40.0)
39
- Requires-Dist: uvicorn (==0.30.6)
37
+ Requires-Dist: starlette (==0.47.1)
38
+ Requires-Dist: uvicorn (==0.34.3)
40
39
  Requires-Dist: wsproto (==1.2.0)
41
40
  Project-URL: Homepage, https://pyview.rocks
42
41
  Project-URL: Repository, https://github.com/ogrodnek/pyview
@@ -12,17 +12,20 @@ pyview/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
12
  pyview/cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
13
  pyview/cli/commands/create_view.py,sha256=9hrjc1J1sJHyp3Ihi5ls4Mj19OMNeaj6fpIwDFWrdZ8,5694
14
14
  pyview/cli/main.py,sha256=OEHqNwl9fqIPTI6qy5sLYndPHMEJ3fA9qsjSUuKzYSE,296
15
- pyview/csrf.py,sha256=VIURva9EJqXXYGC7engweh3SwDQCnHlhV2zWdcdnFqc,789
16
- pyview/events/BaseEventHandler.py,sha256=0xcjFFMLMN8Aj6toI31vzeYhRQqaX9rm-G7XGXMvqsE,1923
15
+ pyview/csrf.py,sha256=2AZaTyUPCbLXvsLoxXixbqOtaKqy0UilUWiHNTlEF20,888
16
+ pyview/events/BaseEventHandler.py,sha256=rBE0z_nN6vIZ8TQ9eRfnCnTEcMOaMX7Na7YgtQmSW3s,1928
17
17
  pyview/events/__init__.py,sha256=oP0SG4Af4uf0GEa0Y_zHYhR7TcBOcXQlTAsgOSaIcC4,156
18
18
  pyview/events/info_event.py,sha256=JOwf3KDodHkmH1MzqTD8sPxs0zbI4t8Ff0rLjwRSe2Y,358
19
+ pyview/instrumentation/__init__.py,sha256=A5MQbofz9yM177bhY3o4HN4migwd0604e_SuP91R76w,375
20
+ pyview/instrumentation/interfaces.py,sha256=AhVDM_vzETWtM-wfOXaM13K2OgdL0H8lu5wh2DQOxas,6664
21
+ pyview/instrumentation/noop.py,sha256=VP8UjiI--A7KWqnSFh7PMG7MqY0Z9ddQjBYVW7iHZa0,2941
19
22
  pyview/js.py,sha256=E6HMsUfXQjrcLqYq26ieeYuzTjBeZqfJwwOm3uSR4ME,3498
20
23
  pyview/live_routes.py,sha256=IN2Jmy8b1umcfx1R7ZgFXHZNbYDJp_kLIbADtDJknPM,1749
21
- pyview/live_socket.py,sha256=p1KtX9Exwhgsf0yOp3Eb32zdUOo5hSnYDJrpJuTu3QI,5084
24
+ pyview/live_socket.py,sha256=exzWVlDhmYEgeNQzz7ROhdFrqPrMU1JtFMImL3Uh5ZQ,6755
22
25
  pyview/live_view.py,sha256=mwAp7jiABSZCBgYF-GLQCB7zcJ7Wpz9cuC84zjzsp2U,1455
23
26
  pyview/meta.py,sha256=01Z-qldB9jrewmIJHQpUqyIhuHodQGgCvpuY9YM5R6c,74
24
27
  pyview/phx_message.py,sha256=DUdPfl6tlw9K0FNXJ35ehq03JGgynvwA_JItHQ_dxMQ,2007
25
- pyview/pyview.py,sha256=UuAeHdmrcmu3q681NR8IVQ1-LcMBnWyT1vIuQdrLlhY,2666
28
+ pyview/pyview.py,sha256=2rj7NMuc6-tml2Wg4PBV7tydFJVa6XUw0pM0voWYg5g,2972
26
29
  pyview/secret.py,sha256=HbaNpGAkFs4uxMVAmk9HwE3FIehg7dmwEOlED7C9moM,363
27
30
  pyview/session.py,sha256=nC8ExyVwfCgQfx9T-aJGyFhr2C7jsrEY_QFkaXtP28U,432
28
31
  pyview/static/assets/app.js,sha256=QoXfdcOCYwVYJftvjsIIVwFye7onaOJMxRpalyYqoMU,200029
@@ -33,8 +36,7 @@ pyview/template/render_diff.py,sha256=1P-OgtcGb0Y-zJ9uUH3bKWX-qQTHBa4jgg73qJD7eg
33
36
  pyview/template/root_template.py,sha256=zCUs1bt8R7qynhBE0tTSEYfdkGtbeKNmPhwzRiFNdsI,2031
34
37
  pyview/template/serializer.py,sha256=WDZfqJr2LMlf36fUW2CmWc2aREc63553_y_GRP2-qYc,826
35
38
  pyview/template/utils.py,sha256=S8593UjUJztUrtC3h1EL9MxQp5uH7rFDTNkv9C6A_xU,642
36
- pyview/test_csrf.py,sha256=QWTOtfagDMkoYDK_ehYxua34F7-ltPsSeTwQGEOlqHU,684
37
- pyview/uploads.py,sha256=cFNOlJD5dkA2VccZT_W1bJn_5vYAaphhRJX-RCfEXm8,9598
39
+ pyview/uploads.py,sha256=AMdlp50uMMI5tS5DaQoaSrKVstu-7cGxIxaFWzg943A,9717
38
40
  pyview/vendor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
39
41
  pyview/vendor/flet/pubsub/__init__.py,sha256=JSPCeKB26b5E-IVHNRvVHrlf_CBGDLCulE9ADrostGs,39
40
42
  pyview/vendor/flet/pubsub/pub_sub.py,sha256=gpdruSxKQBqL7_Dtxo4vETm1kM0YH7S299msw2oyUoE,10184
@@ -48,9 +50,9 @@ pyview/vendor/ibis/nodes.py,sha256=TgFt4q5MrVW3gC3PVitrs2LyXKllRveooM7XKydNATk,2
48
50
  pyview/vendor/ibis/template.py,sha256=6XJXnztw87CrOaKeW3e18LL0fNM8AI6AaK_QgMdb7ew,2353
49
51
  pyview/vendor/ibis/tree.py,sha256=hg8f-fKHeo6DE8R-QgAhdvEaZ8rKyz7p0nGwPy0CBTs,2509
50
52
  pyview/vendor/ibis/utils.py,sha256=nLSaxPR9vMphzV9qinlz_Iurv9c49Ps6Knv8vyNlewU,2768
51
- pyview/ws_handler.py,sha256=CY1iDx5GETjIkqhgFbo2fkE3FhrqucSdg4AjuJ2P0Qg,9041
52
- pyview_web-0.2.5.dist-info/LICENSE,sha256=M_bADaBm9_MV9llX3lCicksLhwk3eZUjA2srE0uUWr0,1071
53
- pyview_web-0.2.5.dist-info/METADATA,sha256=3g39-VhWx0gWkq1B46NAkTTTDSqnOr692TkFZtgOrNo,5243
54
- pyview_web-0.2.5.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
55
- pyview_web-0.2.5.dist-info/entry_points.txt,sha256=GAT-ic-VYmmSMUSUVKdV1bp4w-vgEeVP-XzElvarQ9U,42
56
- pyview_web-0.2.5.dist-info/RECORD,,
53
+ pyview/ws_handler.py,sha256=QAmD_ANP6UCgghMSFjPcyaQP9R1eKjoebq4CxnUDrbU,14492
54
+ pyview_web-0.3.0.dist-info/LICENSE,sha256=M_bADaBm9_MV9llX3lCicksLhwk3eZUjA2srE0uUWr0,1071
55
+ pyview_web-0.3.0.dist-info/METADATA,sha256=8VCRjuMu-E2wWussavO1C4Sk1bSTZKKKQIlZhXPy_Qo,5199
56
+ pyview_web-0.3.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
57
+ pyview_web-0.3.0.dist-info/entry_points.txt,sha256=GAT-ic-VYmmSMUSUVKdV1bp4w-vgEeVP-XzElvarQ9U,42
58
+ pyview_web-0.3.0.dist-info/RECORD,,
pyview/test_csrf.py DELETED
@@ -1,21 +0,0 @@
1
- from pyview.csrf import generate_csrf_token, validate_csrf_token
2
-
3
-
4
- def test_can_validate_tokens():
5
- t = generate_csrf_token("test")
6
- assert validate_csrf_token(t, "test")
7
-
8
-
9
- def test_can_validate_tokens_with_salt():
10
- t = generate_csrf_token("test", salt="test-salt")
11
- assert validate_csrf_token(t, "test", salt="test-salt")
12
-
13
-
14
- def test_can_validate_tokens_with_different_salt():
15
- t = generate_csrf_token("test", salt="test-salt")
16
- assert not validate_csrf_token(t, "test", salt="test-salt-2")
17
-
18
-
19
- def test_can_validate_tokens_with_different_value():
20
- t = generate_csrf_token("test", salt="test-salt")
21
- assert not validate_csrf_token(t, "test-2", salt="test-salt")