pyview-web 0.2.6__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
@@ -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,9 +23,12 @@ 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
33
 
29
34
  pub_sub_hub = PubSubHub()
@@ -59,10 +64,12 @@ class ConnectedLiveViewSocket(Generic[T]):
59
64
  topic: str,
60
65
  liveview: LiveView,
61
66
  scheduler: AsyncIOScheduler,
67
+ instrumentation: "InstrumentationProvider",
62
68
  ):
63
69
  self.websocket = websocket
64
70
  self.topic = topic
65
71
  self.liveview = liveview
72
+ self.instrumentation = instrumentation
66
73
  self.scheduled_jobs = []
67
74
  self.connected = True
68
75
  self.pub_sub = PubSub(pub_sub_hub, topic)
@@ -118,8 +125,13 @@ class ConnectedLiveViewSocket(Generic[T]):
118
125
  await self.websocket.send_text(json.dumps(resp))
119
126
  except Exception:
120
127
  for id in self.scheduled_jobs:
121
- print("Removing job", id)
122
- self.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
+ )
123
135
 
124
136
  async def push_patch(self, path: str, params: dict[str, Any] = {}):
125
137
  # or "replace"
@@ -147,8 +159,38 @@ class ConnectedLiveViewSocket(Generic[T]):
147
159
  await self.liveview.handle_params(to, params, self)
148
160
  try:
149
161
  await self.websocket.send_text(json.dumps(message))
150
- except Exception as e:
151
- 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)
152
194
 
153
195
  async def push_event(self, event: str, value: dict[str, Any]):
154
196
  self.pending_events.append((event, value))
@@ -161,7 +203,10 @@ class ConnectedLiveViewSocket(Generic[T]):
161
203
  async def close(self):
162
204
  self.connected = False
163
205
  for id in self.scheduled_jobs:
164
- self.scheduler.remove_job(id)
206
+ try:
207
+ self.scheduler.remove_job(id)
208
+ except JobLookupError:
209
+ pass
165
210
  await self.pub_sub.unsubscribe_all_async()
166
211
 
167
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,16 +9,54 @@ 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
11
13
  from apscheduler.schedulers.asyncio import AsyncIOScheduler
12
14
 
15
+ logger = logging.getLogger(__name__)
16
+
13
17
 
14
18
  class AuthException(Exception):
15
19
  pass
16
20
 
17
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
+
18
55
  class LiveSocketHandler:
19
- def __init__(self, routes: LiveViewLookup):
56
+ def __init__(self, routes: LiveViewLookup, instrumentation: InstrumentationProvider):
20
57
  self.routes = routes
58
+ self.instrumentation = instrumentation
59
+ self.metrics = LiveSocketMetrics(instrumentation)
21
60
  self.manager = ConnectionManager()
22
61
  self.sessions = 0
23
62
  self.scheduler = AsyncIOScheduler()
@@ -29,7 +68,9 @@ class LiveSocketHandler:
29
68
 
30
69
  async def handle(self, websocket: WebSocket):
31
70
  await self.manager.connect(websocket)
32
-
71
+
72
+ # Track active connections
73
+ self.metrics.active_connections.add(1)
33
74
  self.sessions += 1
34
75
  topic = None
35
76
  socket: Optional[LiveViewSocket] = None
@@ -46,12 +87,16 @@ class LiveSocketHandler:
46
87
  url = urlparse(payload["url"])
47
88
  lv, path_params = self.routes.get(url.path)
48
89
  await self.check_auth(websocket, lv)
49
- socket = ConnectedLiveViewSocket(websocket, topic, lv, self.scheduler)
90
+ socket = ConnectedLiveViewSocket(websocket, topic, lv, self.scheduler, self.instrumentation)
50
91
 
51
92
  session = {}
52
93
  if "session" in payload:
53
94
  session = deserialize_session(payload["session"])
54
95
 
96
+ # Track mount
97
+ view_name = lv.__class__.__name__
98
+ self.metrics.mounts.add(1, {"view": view_name})
99
+
55
100
  await lv.mount(socket, session)
56
101
 
57
102
  # Parse query parameters and merge with path parameters
@@ -79,9 +124,16 @@ class LiveSocketHandler:
79
124
  if socket:
80
125
  await socket.close()
81
126
  self.sessions -= 1
127
+ self.metrics.active_connections.add(-1)
82
128
  except AuthException:
83
129
  await websocket.close()
84
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
85
137
 
86
138
  async def handle_connected(self, myJoinId, socket: ConnectedLiveViewSocket):
87
139
  while True:
@@ -108,8 +160,19 @@ class LiveSocketHandler:
108
160
  value = parse_qs(value)
109
161
  socket.upload_manager.maybe_process_uploads(value, payload)
110
162
 
111
- await socket.liveview.handle_event(payload["event"], value, socket)
112
- 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)
113
176
 
114
177
  hook_events = (
115
178
  {} if not socket.pending_events else {"e": socket.pending_events}
@@ -126,9 +189,9 @@ class LiveSocketHandler:
126
189
  "phx_reply",
127
190
  {"response": {"diff": diff | hook_events}, "status": "ok"},
128
191
  ]
129
- await self.manager.send_personal_message(
130
- json.dumps(resp), socket.websocket
131
- )
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)
132
195
  continue
133
196
 
134
197
  if event == "live_patch":
@@ -188,21 +251,64 @@ class LiveSocketHandler:
188
251
  )
189
252
  continue
190
253
 
191
- # file upload
254
+ # file upload or navigation
192
255
  if event == "phx_join":
193
- 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
+ ]
194
268
 
195
- resp = [
196
- joinRef,
197
- mesageRef,
198
- topic,
199
- "phx_reply",
200
- {"response": {}, "status": "ok"},
201
- ]
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
+ )
202
285
 
203
- await self.manager.send_personal_message(
204
- json.dumps(resp), socket.websocket
205
- )
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
+ )
206
312
 
207
313
  if event == "chunk":
208
314
  socket.upload_manager.add_chunk(joinRef, payload) # type: ignore
@@ -250,6 +356,23 @@ class LiveSocketHandler:
250
356
  json.dumps(resp), socket.websocket
251
357
  )
252
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
+
253
376
 
254
377
  async def _render(socket: ConnectedLiveViewSocket):
255
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.6
3
+ Version: 0.3.0
4
4
  Summary: LiveView in Python
5
5
  License: MIT
6
6
  Keywords: web,api,LiveView
@@ -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
15
+ pyview/csrf.py,sha256=2AZaTyUPCbLXvsLoxXixbqOtaKqy0UilUWiHNTlEF20,888
16
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=rAa11A7hfbx7DoGC1PQnahxxgGpwOFXwQjYkR9-QtXY,5166
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=qe_rQV5z4GTZOgMDSTC29zv8VrUJ0B5AApGdQ9GzrV0,9192
52
- pyview_web-0.2.6.dist-info/LICENSE,sha256=M_bADaBm9_MV9llX3lCicksLhwk3eZUjA2srE0uUWr0,1071
53
- pyview_web-0.2.6.dist-info/METADATA,sha256=Iwe5cP1CvsCCvf-2qi077zYcxFj5HhTJwtD10yZqhcM,5199
54
- pyview_web-0.2.6.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
55
- pyview_web-0.2.6.dist-info/entry_points.txt,sha256=GAT-ic-VYmmSMUSUVKdV1bp4w-vgEeVP-XzElvarQ9U,42
56
- pyview_web-0.2.6.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")