pyview-web 0.2.6__py3-none-any.whl → 0.4.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.
Potentially problematic release.
This version of pyview-web might be problematic. Click here for more details.
- pyview/csrf.py +5 -2
- pyview/instrumentation/__init__.py +21 -0
- pyview/instrumentation/interfaces.py +182 -0
- pyview/instrumentation/noop.py +97 -0
- pyview/live_socket.py +50 -5
- pyview/pyview.py +6 -2
- pyview/uploads.py +7 -4
- pyview/ws_handler.py +143 -20
- {pyview_web-0.2.6.dist-info → pyview_web-0.4.0.dist-info}/METADATA +4 -3
- {pyview_web-0.2.6.dist-info → pyview_web-0.4.0.dist-info}/RECORD +13 -11
- pyview/test_csrf.py +0 -21
- {pyview_web-0.2.6.dist-info → pyview_web-0.4.0.dist-info}/LICENSE +0 -0
- {pyview_web-0.2.6.dist-info → pyview_web-0.4.0.dist-info}/WHEEL +0 -0
- {pyview_web-0.2.6.dist-info → pyview_web-0.4.0.dist-info}/entry_points.txt +0 -0
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)
|
|
24
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
"
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: pyview-web
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: LiveView in Python
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: web,api,LiveView
|
|
7
7
|
Author: Larry Ogrodnek
|
|
8
8
|
Author-email: ogrodnek@gmail.com
|
|
9
|
-
Requires-Python: >=3.11,<3.
|
|
9
|
+
Requires-Python: >=3.11,<3.14
|
|
10
10
|
Classifier: Development Status :: 4 - Beta
|
|
11
11
|
Classifier: Environment :: Web Environment
|
|
12
12
|
Classifier: Framework :: AsyncIO
|
|
@@ -20,6 +20,7 @@ Classifier: Programming Language :: Python
|
|
|
20
20
|
Classifier: Programming Language :: Python :: 3
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.11
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
24
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
24
25
|
Classifier: Topic :: Internet
|
|
25
26
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
@@ -34,7 +35,7 @@ Requires-Dist: click (>=8.1.7,<9.0.0)
|
|
|
34
35
|
Requires-Dist: itsdangerous (>=2.2.0,<3.0.0)
|
|
35
36
|
Requires-Dist: markupsafe (>=3.0.2,<4.0.0)
|
|
36
37
|
Requires-Dist: pydantic (>=2.9.2,<3.0.0)
|
|
37
|
-
Requires-Dist: starlette (
|
|
38
|
+
Requires-Dist: starlette (>=0.47.2,<0.48.0)
|
|
38
39
|
Requires-Dist: uvicorn (==0.34.3)
|
|
39
40
|
Requires-Dist: wsproto (==1.2.0)
|
|
40
41
|
Project-URL: Homepage, https://pyview.rocks
|
|
@@ -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=
|
|
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=
|
|
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=
|
|
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/
|
|
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=
|
|
52
|
-
pyview_web-0.
|
|
53
|
-
pyview_web-0.
|
|
54
|
-
pyview_web-0.
|
|
55
|
-
pyview_web-0.
|
|
56
|
-
pyview_web-0.
|
|
53
|
+
pyview/ws_handler.py,sha256=QAmD_ANP6UCgghMSFjPcyaQP9R1eKjoebq4CxnUDrbU,14492
|
|
54
|
+
pyview_web-0.4.0.dist-info/LICENSE,sha256=M_bADaBm9_MV9llX3lCicksLhwk3eZUjA2srE0uUWr0,1071
|
|
55
|
+
pyview_web-0.4.0.dist-info/METADATA,sha256=RvJHJvSC5Q81WbIMCxex4flLbp5wXdYtPWSQGvOQ3JI,5258
|
|
56
|
+
pyview_web-0.4.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
57
|
+
pyview_web-0.4.0.dist-info/entry_points.txt,sha256=GAT-ic-VYmmSMUSUVKdV1bp4w-vgEeVP-XzElvarQ9U,42
|
|
58
|
+
pyview_web-0.4.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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|