pyview-web 0.0.6a0__tar.gz → 0.0.8a0__tar.gz

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.

Files changed (43) hide show
  1. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/PKG-INFO +3 -5
  2. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyproject.toml +1 -1
  3. pyview_web-0.0.8a0/pyview/auth/__init__.py +2 -0
  4. pyview_web-0.0.8a0/pyview/auth/provider.py +32 -0
  5. pyview_web-0.0.8a0/pyview/auth/required.py +33 -0
  6. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/csrf.py +3 -21
  7. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/live_view.py +3 -2
  8. pyview_web-0.0.8a0/pyview/pyview.py +70 -0
  9. pyview_web-0.0.8a0/pyview/secret.py +18 -0
  10. pyview_web-0.0.8a0/pyview/session.py +13 -0
  11. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/template/__init__.py +1 -0
  12. pyview_web-0.0.8a0/pyview/template/root_template.py +71 -0
  13. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/ws_handler.py +25 -22
  14. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/readme.md +1 -1
  15. pyview_web-0.0.6a0/pyview/pyview.py +0 -119
  16. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/LICENSE +0 -0
  17. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/__init__.py +0 -0
  18. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/assets/js/app.js +0 -0
  19. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/assets/package-lock.json +0 -0
  20. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/assets/package.json +0 -0
  21. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/changesets/__init__.py +0 -0
  22. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/changesets/changesets.py +0 -0
  23. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/events.py +0 -0
  24. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/js.py +0 -0
  25. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/live_routes.py +0 -0
  26. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/live_socket.py +0 -0
  27. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/static/assets/app.js +0 -0
  28. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/template/live_template.py +0 -0
  29. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/template/serializer.py +0 -0
  30. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/test_csrf.py +0 -0
  31. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/vendor/__init__.py +0 -0
  32. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/vendor/flet/pubsub/__init__.py +0 -0
  33. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/vendor/flet/pubsub/pub_sub.py +0 -0
  34. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/__init__.py +0 -0
  35. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/compiler.py +0 -0
  36. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/context.py +0 -0
  37. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/errors.py +0 -0
  38. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/filters.py +0 -0
  39. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/loaders.py +0 -0
  40. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/nodes.py +0 -0
  41. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/template.py +0 -0
  42. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/tree.py +0 -0
  43. {pyview_web-0.0.6a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pyview-web
3
- Version: 0.0.6a0
3
+ Version: 0.0.8a0
4
4
  Summary: LiveView in Python
5
5
  Home-page: https://pyview.rocks
6
6
  License: MIT
@@ -22,10 +22,8 @@ Classifier: Programming Language :: Python
22
22
  Classifier: Programming Language :: Python :: 3
23
23
  Classifier: Programming Language :: Python :: 3.10
24
24
  Classifier: Programming Language :: Python :: 3.11
25
- Classifier: Programming Language :: Python :: 3
25
+ Classifier: Programming Language :: Python :: 3.12
26
26
  Classifier: Programming Language :: Python :: 3 :: Only
27
- Classifier: Programming Language :: Python :: 3.10
28
- Classifier: Programming Language :: Python :: 3.11
29
27
  Classifier: Programming Language :: Python :: 3.9
30
28
  Classifier: Topic :: Internet
31
29
  Classifier: Topic :: Internet :: WWW/HTTP
@@ -79,7 +77,7 @@ class CountContext(TypedDict):
79
77
 
80
78
 
81
79
  class CountLiveView(LiveView[CountContext]):
82
- async def mount(self, socket: LiveViewSocket[CountContext]):
80
+ async def mount(self, socket: LiveViewSocket[CountContext], _session):
83
81
  socket.context = {"count": 0}
84
82
 
85
83
  async def handle_event(self, event, payload, socket: LiveViewSocket[CountContext]):
@@ -5,7 +5,7 @@ packages = [
5
5
  { include = "pyview" },
6
6
  ]
7
7
 
8
- version = "0.0.6a"
8
+ version = "0.0.8a"
9
9
  description = "LiveView in Python"
10
10
  authors = ["Larry Ogrodnek <ogrodnek@gmail.com>"]
11
11
  license = "MIT"
@@ -0,0 +1,2 @@
1
+ from .required import requires
2
+ from .provider import AuthProvider, AllowAllAuthProvider, AuthProviderFactory
@@ -0,0 +1,32 @@
1
+ from typing import Protocol, TypeVar, Callable
2
+ from starlette.websockets import WebSocket
3
+ from pyview import LiveView
4
+
5
+ _CallableType = TypeVar("_CallableType", bound=Callable)
6
+
7
+
8
+ class AuthProvider(Protocol):
9
+ async def has_required_auth(self, websocket: WebSocket) -> bool:
10
+ ...
11
+
12
+ def wrap(self, func: _CallableType) -> _CallableType:
13
+ ...
14
+
15
+
16
+ class AllowAllAuthProvider(AuthProvider):
17
+ async def has_required_auth(self, websocket: WebSocket) -> bool:
18
+ return True
19
+
20
+ def wrap(self, func: _CallableType) -> _CallableType:
21
+ return func
22
+
23
+
24
+ class AuthProviderFactory:
25
+ @classmethod
26
+ def get(cls, lv: type[LiveView]) -> AuthProvider:
27
+ return getattr(lv, "__pyview_auth_provider__", AllowAllAuthProvider())
28
+
29
+ @classmethod
30
+ def set(cls, lv: type[LiveView], auth_provider: AuthProvider) -> type[LiveView]:
31
+ setattr(lv, "__pyview_auth_provider__", auth_provider)
32
+ return lv
@@ -0,0 +1,33 @@
1
+ import typing
2
+ from dataclasses import dataclass
3
+ from starlette.websockets import WebSocket
4
+ from starlette.authentication import requires as starlette_requires, has_required_scope
5
+ from .provider import AuthProvider, AuthProviderFactory
6
+ from pyview import LiveView
7
+
8
+ _CallableType = typing.TypeVar("_CallableType", bound=typing.Callable)
9
+
10
+
11
+ @dataclass
12
+ class RequiredScopeAuthProvider(AuthProvider):
13
+ scopes: typing.Sequence[str]
14
+ status_code: int = 403
15
+ redirect: typing.Optional[str] = None
16
+
17
+ def wrap(self, func: _CallableType) -> _CallableType:
18
+ return starlette_requires(self.scopes, self.status_code, self.redirect)(func)
19
+
20
+ def has_required_auth(self, websocket: WebSocket) -> bool:
21
+ return has_required_scope(websocket, self.scopes)
22
+
23
+
24
+ def requires(
25
+ scopes: typing.Union[str, typing.Sequence[str]],
26
+ status_code: int = 403,
27
+ redirect: typing.Optional[str] = None,
28
+ ) -> typing.Callable[[type[LiveView]], type[LiveView]]:
29
+ def decorator(cls: type[LiveView]) -> type[LiveView]:
30
+ scopes_list = [scopes] if isinstance(scopes, str) else list(scopes)
31
+ return AuthProviderFactory.set(cls, RequiredScopeAuthProvider(scopes_list, status_code, redirect))
32
+
33
+ return decorator
@@ -1,15 +1,14 @@
1
1
  from itsdangerous import BadData, SignatureExpired, URLSafeTimedSerializer
2
- import secrets
3
2
  from typing import Optional
4
3
  import hmac
5
- import os
4
+ from pyview.secret import get_secret
6
5
 
7
6
 
8
7
  def generate_csrf_token(value: str, salt: Optional[str] = None) -> str:
9
8
  """
10
9
  Generate a CSRF token.
11
10
  """
12
- s = URLSafeTimedSerializer(_get_secret(), salt=salt or "pyview-csrf-token")
11
+ s = URLSafeTimedSerializer(get_secret(), salt=salt or "pyview-csrf-token")
13
12
  return s.dumps(value) # type: ignore
14
13
 
15
14
 
@@ -17,27 +16,10 @@ def validate_csrf_token(data: str, expected: str, salt: Optional[str] = None) ->
17
16
  """
18
17
  Validate a CSRF token.
19
18
  """
20
- s = URLSafeTimedSerializer(_get_secret(), salt=salt or "pyview-csrf-token")
19
+ s = URLSafeTimedSerializer(get_secret(), salt=salt or "pyview-csrf-token")
21
20
  try:
22
21
  token = s.loads(data, max_age=3600)
23
22
  return hmac.compare_digest(token, expected)
24
23
  except (BadData, SignatureExpired) as e:
25
24
  print(e)
26
25
  return False
27
-
28
-
29
- _SECRET = None
30
-
31
-
32
- def _get_secret() -> str:
33
- """
34
- Get the secret key from the environment, or generate a new one.
35
- """
36
- global _SECRET
37
- if _SECRET is None:
38
- secret = os.environ.get("PYVIEW_SECRET")
39
- if secret is None:
40
- secret = secrets.token_urlsafe(16)
41
- _SECRET = secret
42
-
43
- return _SECRET
@@ -1,4 +1,4 @@
1
- from typing import TypeVar, Generic, Optional, Union
1
+ from typing import TypeVar, Generic, Optional, Union, Any
2
2
  from .live_socket import LiveViewSocket, UnconnectedSocket
3
3
  from pyview.template import LiveTemplate, template_file, RenderedContent, LiveRender
4
4
  import inspect
@@ -7,13 +7,14 @@ from pyview.events import InfoEvent
7
7
  T = TypeVar("T")
8
8
 
9
9
  AnySocket = Union[LiveViewSocket[T], UnconnectedSocket[T]]
10
+ Session = dict[str, Any]
10
11
 
11
12
 
12
13
  class LiveView(Generic[T]):
13
14
  def __init__(self):
14
15
  pass
15
16
 
16
- async def mount(self, socket: AnySocket):
17
+ async def mount(self, socket: AnySocket, session: Session):
17
18
  pass
18
19
 
19
20
  async def handle_event(self, event, payload, socket: LiveViewSocket[T]):
@@ -0,0 +1,70 @@
1
+ from starlette.applications import Starlette
2
+ from fastapi import WebSocket
3
+ from fastapi.responses import HTMLResponse
4
+ from starlette.middleware.gzip import GZipMiddleware
5
+ from starlette.routing import Route
6
+ from starlette.requests import Request
7
+ import uuid
8
+ from urllib.parse import parse_qs
9
+
10
+ from pyview.live_socket import UnconnectedSocket
11
+ from pyview.csrf import generate_csrf_token
12
+ from pyview.session import serialize_session
13
+ from pyview.auth import AuthProviderFactory
14
+ from .ws_handler import LiveSocketHandler
15
+ from .live_view import LiveView
16
+ from .live_routes import LiveViewLookup
17
+ from .template import RootTemplate, RootTemplateContext, defaultRootTemplate
18
+
19
+
20
+ class PyView(Starlette):
21
+ rootTemplate: RootTemplate
22
+
23
+ def __init__(self, *args, **kwargs):
24
+ super().__init__(*args, **kwargs)
25
+ self.rootTemplate = defaultRootTemplate()
26
+ self.view_lookup = LiveViewLookup()
27
+ self.live_handler = LiveSocketHandler(self.view_lookup)
28
+
29
+ async def live_websocket_endpoint(websocket: WebSocket):
30
+ await self.live_handler.handle(websocket)
31
+
32
+ self.add_websocket_route("/live/websocket", live_websocket_endpoint)
33
+ self.add_middleware(GZipMiddleware)
34
+
35
+ def add_live_view(self, path: str, view: type[LiveView]):
36
+ async def lv(request: Request):
37
+ return await liveview_container(
38
+ self.rootTemplate, self.view_lookup, request
39
+ )
40
+
41
+ self.view_lookup.add(path, view)
42
+ auth = AuthProviderFactory.get(view)
43
+ self.routes.append(Route(path, auth.wrap(lv), methods=["GET"]))
44
+
45
+
46
+ async def liveview_container(
47
+ template: RootTemplate, view_lookup: LiveViewLookup, request: Request
48
+ ):
49
+ url = request.url
50
+ path = url.path
51
+ lv: LiveView = view_lookup.get(path)
52
+ s = UnconnectedSocket()
53
+
54
+ session = request.session if "session" in request.scope else {}
55
+
56
+ await lv.mount(s, session)
57
+ await lv.handle_params(url, parse_qs(url.query), s)
58
+ r = await lv.render(s.context)
59
+
60
+ id = str(uuid.uuid4())
61
+
62
+ context: RootTemplateContext = {
63
+ "id": id,
64
+ "content": r.text(),
65
+ "title": s.live_title,
66
+ "csrf_token": generate_csrf_token("lv:phx-" + id),
67
+ "session": serialize_session(session),
68
+ }
69
+
70
+ return HTMLResponse(template(context))
@@ -0,0 +1,18 @@
1
+ import os
2
+ import secrets
3
+
4
+ _SECRET = None
5
+
6
+
7
+ def get_secret() -> str:
8
+ """
9
+ Get the secret key from the environment, or generate a new one.
10
+ """
11
+ global _SECRET
12
+ if _SECRET is None:
13
+ secret = os.environ.get("PYVIEW_SECRET")
14
+ if secret is None:
15
+ secret = secrets.token_urlsafe(16)
16
+ _SECRET = secret
17
+
18
+ return _SECRET
@@ -0,0 +1,13 @@
1
+ from typing import Any, cast
2
+ from itsdangerous import URLSafeSerializer
3
+ from pyview.secret import get_secret
4
+
5
+
6
+ def serialize_session(session: dict[str, Any]) -> str:
7
+ s = URLSafeSerializer(get_secret(), salt="pyview-session")
8
+ return cast(str, s.dumps(session))
9
+
10
+
11
+ def deserialize_session(ser: str) -> dict[str, Any]:
12
+ s = URLSafeSerializer(get_secret(), salt="pyview-session")
13
+ return cast(dict[str, Any], s.loads(ser))
@@ -1,2 +1,3 @@
1
1
  from pyview.vendor.ibis import Template
2
2
  from .live_template import LiveTemplate, template_file, RenderedContent, LiveRender
3
+ from .root_template import RootTemplate, RootTemplateContext, defaultRootTemplate
@@ -0,0 +1,71 @@
1
+ from typing import Callable, Optional, TypedDict
2
+ from markupsafe import Markup
3
+
4
+
5
+ class RootTemplateContext(TypedDict):
6
+ id: str
7
+ content: str
8
+ title: Optional[str]
9
+ csrf_token: str
10
+ session: Optional[str]
11
+
12
+
13
+ RootTemplate = Callable[[RootTemplateContext], str]
14
+ ContentWrapper = Callable[[RootTemplateContext, Markup], Markup]
15
+
16
+
17
+ def defaultRootTemplate(
18
+ css: Optional[Markup] = None, content_wrapper: Optional[ContentWrapper] = None
19
+ ) -> RootTemplate:
20
+ content_wrapper = content_wrapper or (lambda c, m: m)
21
+
22
+ def template(context: RootTemplateContext) -> str:
23
+ return _defaultRootTemplate(context, css or Markup(""), content_wrapper)
24
+
25
+ return template
26
+
27
+
28
+ def _defaultRootTemplate(
29
+ context: RootTemplateContext, css: Markup, contentWrapper: ContentWrapper
30
+ ) -> str:
31
+ suffix = " | LiveView"
32
+ render_title = (context["title"] + suffix) if context.get("title", None) is not None else "LiveView" # type: ignore
33
+ main_content = contentWrapper(
34
+ context,
35
+ Markup(
36
+ f"""
37
+ <div
38
+ data-phx-main="true"
39
+ data-phx-session="{context['session']}"
40
+ data-phx-static=""
41
+ id="phx-{context['id']}"
42
+ >
43
+ {context['content']}
44
+ </div>"""
45
+ ),
46
+ )
47
+
48
+ return (
49
+ Markup(
50
+ f"""
51
+ <!DOCTYPE html>
52
+ <html lang="en">
53
+ <head>
54
+ <title data-suffix="{suffix}">{render_title}</title>
55
+ <meta name="csrf-token" content="{context['csrf_token']}" />
56
+ <meta charset="utf-8">
57
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
58
+ <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
59
+ <script defer type="text/javascript" src="/static/assets/app.js"></script>
60
+ {css}
61
+ </head>
62
+ <body>"""
63
+ )
64
+ + main_content
65
+ + Markup(
66
+ """
67
+ </body>
68
+ </html>
69
+ """
70
+ )
71
+ )
@@ -5,6 +5,12 @@ from urllib.parse import urlparse, parse_qs
5
5
  from pyview.live_socket import LiveViewSocket
6
6
  from pyview.live_routes import LiveViewLookup
7
7
  from pyview.csrf import validate_csrf_token
8
+ from pyview.session import deserialize_session
9
+ from pyview.auth import AuthProviderFactory
10
+
11
+
12
+ class AuthException(Exception):
13
+ pass
8
14
 
9
15
 
10
16
  class LiveSocketHandler:
@@ -13,6 +19,10 @@ class LiveSocketHandler:
13
19
  self.manager = ConnectionManager()
14
20
  self.sessions = 0
15
21
 
22
+ async def check_auth(self, websocket: WebSocket, lv):
23
+ if not await AuthProviderFactory.get(lv).has_required_auth(websocket):
24
+ raise AuthException()
25
+
16
26
  async def handle(self, websocket: WebSocket):
17
27
  await self.manager.connect(websocket)
18
28
 
@@ -24,14 +34,19 @@ class LiveSocketHandler:
24
34
  data = await websocket.receive_text()
25
35
  [joinRef, mesageRef, topic, event, payload] = json.loads(data)
26
36
  if event == "phx_join":
37
+ if not validate_csrf_token(payload["params"]["_csrf_token"], topic):
38
+ raise AuthException("Invalid CSRF token")
39
+
27
40
  url = urlparse(payload["url"])
28
41
  lv = self.routes.get(url.path)
42
+ await self.check_auth(websocket, lv)
29
43
  socket = LiveViewSocket(websocket, topic, lv)
30
44
 
31
- if not validate_csrf_token(payload["params"]["_csrf_token"], topic):
32
- raise Exception("Invalid CSRF token")
45
+ session = {}
46
+ if "session" in payload:
47
+ session = deserialize_session(payload["session"])
33
48
 
34
- await lv.mount(socket)
49
+ await lv.mount(socket, session)
35
50
  await lv.handle_params(url, parse_qs(url.query), socket)
36
51
 
37
52
  rendered = await _render(socket)
@@ -48,10 +63,12 @@ class LiveSocketHandler:
48
63
  await self.handle_connected(socket)
49
64
 
50
65
  except WebSocketDisconnect:
51
- self.manager.disconnect(websocket)
52
66
  if socket:
53
67
  await socket.close()
54
68
  self.sessions -= 1
69
+ except AuthException:
70
+ await websocket.close()
71
+ self.sessions -= 1
55
72
 
56
73
  async def handle_connected(self, socket: LiveViewSocket):
57
74
  while True:
@@ -66,9 +83,7 @@ class LiveSocketHandler:
66
83
  "phx_reply",
67
84
  {"response": {}, "status": "ok"},
68
85
  ]
69
- await self.manager.send_personal_message(
70
- json.dumps(resp), socket.websocket
71
- )
86
+ await self.manager.send_personal_message(json.dumps(resp), socket.websocket)
72
87
  continue
73
88
 
74
89
  if event == "event":
@@ -86,9 +101,7 @@ class LiveSocketHandler:
86
101
  "phx_reply",
87
102
  {"response": {"diff": rendered}, "status": "ok"},
88
103
  ]
89
- await self.manager.send_personal_message(
90
- json.dumps(resp), socket.websocket
91
- )
104
+ await self.manager.send_personal_message(json.dumps(resp), socket.websocket)
92
105
  continue
93
106
 
94
107
  if event == "live_patch":
@@ -105,9 +118,7 @@ class LiveSocketHandler:
105
118
  "phx_reply",
106
119
  {"response": {"diff": rendered}, "status": "ok"},
107
120
  ]
108
- await self.manager.send_personal_message(
109
- json.dumps(resp), socket.websocket
110
- )
121
+ await self.manager.send_personal_message(json.dumps(resp), socket.websocket)
111
122
  continue
112
123
 
113
124
 
@@ -123,18 +134,10 @@ async def _render(socket: LiveViewSocket):
123
134
 
124
135
  class ConnectionManager:
125
136
  def __init__(self):
126
- self.active_connections: list[WebSocket] = []
137
+ pass
127
138
 
128
139
  async def connect(self, websocket: WebSocket):
129
140
  await websocket.accept()
130
- self.active_connections.append(websocket)
131
-
132
- def disconnect(self, websocket: WebSocket):
133
- self.active_connections.remove(websocket)
134
141
 
135
142
  async def send_personal_message(self, message: str, websocket: WebSocket):
136
143
  await websocket.send_text(message)
137
-
138
- async def broadcast(self, message: str):
139
- for connection in self.active_connections:
140
- await connection.send_text(message)
@@ -32,7 +32,7 @@ class CountContext(TypedDict):
32
32
 
33
33
 
34
34
  class CountLiveView(LiveView[CountContext]):
35
- async def mount(self, socket: LiveViewSocket[CountContext]):
35
+ async def mount(self, socket: LiveViewSocket[CountContext], _session):
36
36
  socket.context = {"count": 0}
37
37
 
38
38
  async def handle_event(self, event, payload, socket: LiveViewSocket[CountContext]):
@@ -1,119 +0,0 @@
1
- from starlette.applications import Starlette
2
- from fastapi import WebSocket
3
- from fastapi.responses import HTMLResponse
4
- from starlette.middleware.gzip import GZipMiddleware
5
- from starlette.routing import Route
6
- import uuid
7
- from urllib.parse import parse_qs
8
-
9
- from pyview.live_socket import UnconnectedSocket
10
- from pyview.csrf import generate_csrf_token
11
- from .ws_handler import LiveSocketHandler
12
- from .live_view import LiveView
13
- from .live_routes import LiveViewLookup
14
- from typing import Callable, Optional, TypedDict
15
-
16
-
17
- class RootTemplateContext(TypedDict):
18
- id: str
19
- content: str
20
- title: Optional[str]
21
- css: Optional[str]
22
- csrf_token: str
23
-
24
-
25
- RootTemplate = Callable[[RootTemplateContext], str]
26
-
27
-
28
- class PyView(Starlette):
29
- rootTemplate: RootTemplate
30
-
31
- def __init__(self, *args, **kwargs):
32
- super().__init__(*args, **kwargs)
33
- self.rootTemplate = defaultRootTemplate("")
34
- self.view_lookup = LiveViewLookup()
35
- self.live_handler = LiveSocketHandler(self.view_lookup)
36
-
37
- async def live_websocket_endpoint(websocket: WebSocket):
38
- await self.live_handler.handle(websocket)
39
-
40
- self.add_websocket_route("/live/websocket", live_websocket_endpoint)
41
- self.add_middleware(GZipMiddleware)
42
-
43
- def add_live_view(self, path: str, view: Callable[[], LiveView]):
44
- async def lv(request):
45
- return await liveview_container(
46
- self.rootTemplate, self.view_lookup, request
47
- )
48
-
49
- self.view_lookup.add(path, view)
50
- self.routes.append(Route(path, lv, methods=["GET"]))
51
-
52
-
53
- async def liveview_container(
54
- template: RootTemplate, view_lookup: LiveViewLookup, request
55
- ):
56
- url = request.url
57
- path = url.path
58
- lv: LiveView = view_lookup.get(path)
59
- s = UnconnectedSocket()
60
- await lv.mount(s)
61
- await lv.handle_params(url, parse_qs(url.query), s)
62
- r = await lv.render(s.context)
63
-
64
- id = str(uuid.uuid4())
65
-
66
- context: RootTemplateContext = {
67
- "id": id,
68
- "content": r.text(),
69
- "title": s.live_title,
70
- "csrf_token": generate_csrf_token("lv:phx-" + id),
71
- "css": None,
72
- }
73
-
74
- return HTMLResponse(template(context))
75
-
76
-
77
- def defaultRootTemplate(css: str) -> RootTemplate:
78
- def template(context: RootTemplateContext) -> str:
79
- context["css"] = css
80
- return _defaultRootTemplate(context)
81
-
82
- return template
83
-
84
-
85
- def _defaultRootTemplate(context: RootTemplateContext) -> str:
86
- suffix = " | LiveView"
87
- render_title = (
88
- (context["title"] + suffix) # type: ignore
89
- if context.get("title", None) is not None
90
- else "LiveView"
91
- )
92
- css = context["css"] if context.get("css", None) is not None else ""
93
- return f"""
94
- <!DOCTYPE html>
95
- <html lang="en">
96
- <head>
97
- <title data-suffix="{suffix}">{render_title}</title>
98
- <meta name="csrf-token" content="{context['csrf_token']}" />
99
- <meta charset="utf-8">
100
- <meta http-equiv="X-UA-Compatible" content="IE=edge">
101
- <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
102
- <script defer type="text/javascript" src="/static/assets/app.js"></script>
103
- {css}
104
- </head>
105
- <body>
106
- <div>
107
- <a href="/">Home</a>
108
- <div
109
- data-phx-main="true"
110
- data-phx-session=""
111
- data-phx-static=""
112
- id="phx-{context['id']}"
113
- >
114
- {context['content']}
115
- </div>
116
- </div>
117
- </body>
118
- </html>
119
- """
File without changes
File without changes