pyview-web 0.0.7a0__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.7a0 → pyview_web-0.0.8a0}/PKG-INFO +2 -4
  2. {pyview_web-0.0.7a0 → 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.8a0/pyview/pyview.py +70 -0
  7. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/template/__init__.py +1 -0
  8. pyview_web-0.0.8a0/pyview/template/root_template.py +71 -0
  9. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/ws_handler.py +17 -13
  10. pyview_web-0.0.7a0/pyview/pyview.py +0 -121
  11. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/LICENSE +0 -0
  12. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/__init__.py +0 -0
  13. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/assets/js/app.js +0 -0
  14. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/assets/package-lock.json +0 -0
  15. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/assets/package.json +0 -0
  16. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/changesets/__init__.py +0 -0
  17. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/changesets/changesets.py +0 -0
  18. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/csrf.py +0 -0
  19. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/events.py +0 -0
  20. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/js.py +0 -0
  21. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/live_routes.py +0 -0
  22. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/live_socket.py +0 -0
  23. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/live_view.py +0 -0
  24. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/secret.py +0 -0
  25. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/session.py +0 -0
  26. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/static/assets/app.js +0 -0
  27. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/template/live_template.py +0 -0
  28. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/template/serializer.py +0 -0
  29. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/test_csrf.py +0 -0
  30. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/vendor/__init__.py +0 -0
  31. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/vendor/flet/pubsub/__init__.py +0 -0
  32. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/vendor/flet/pubsub/pub_sub.py +0 -0
  33. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/__init__.py +0 -0
  34. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/compiler.py +0 -0
  35. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/context.py +0 -0
  36. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/errors.py +0 -0
  37. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/filters.py +0 -0
  38. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/loaders.py +0 -0
  39. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/nodes.py +0 -0
  40. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/template.py +0 -0
  41. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/tree.py +0 -0
  42. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/pyview/vendor/ibis/utils.py +0 -0
  43. {pyview_web-0.0.7a0 → pyview_web-0.0.8a0}/readme.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pyview-web
3
- Version: 0.0.7a0
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
@@ -5,7 +5,7 @@ packages = [
5
5
  { include = "pyview" },
6
6
  ]
7
7
 
8
- version = "0.0.7a"
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
@@ -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))
@@ -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
+ )
@@ -6,6 +6,11 @@ from pyview.live_socket import LiveViewSocket
6
6
  from pyview.live_routes import LiveViewLookup
7
7
  from pyview.csrf import validate_csrf_token
8
8
  from pyview.session import deserialize_session
9
+ from pyview.auth import AuthProviderFactory
10
+
11
+
12
+ class AuthException(Exception):
13
+ pass
9
14
 
10
15
 
11
16
  class LiveSocketHandler:
@@ -14,6 +19,10 @@ class LiveSocketHandler:
14
19
  self.manager = ConnectionManager()
15
20
  self.sessions = 0
16
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
+
17
26
  async def handle(self, websocket: WebSocket):
18
27
  await self.manager.connect(websocket)
19
28
 
@@ -25,13 +34,14 @@ class LiveSocketHandler:
25
34
  data = await websocket.receive_text()
26
35
  [joinRef, mesageRef, topic, event, payload] = json.loads(data)
27
36
  if event == "phx_join":
37
+ if not validate_csrf_token(payload["params"]["_csrf_token"], topic):
38
+ raise AuthException("Invalid CSRF token")
39
+
28
40
  url = urlparse(payload["url"])
29
41
  lv = self.routes.get(url.path)
42
+ await self.check_auth(websocket, lv)
30
43
  socket = LiveViewSocket(websocket, topic, lv)
31
44
 
32
- if not validate_csrf_token(payload["params"]["_csrf_token"], topic):
33
- raise Exception("Invalid CSRF token")
34
-
35
45
  session = {}
36
46
  if "session" in payload:
37
47
  session = deserialize_session(payload["session"])
@@ -53,10 +63,12 @@ class LiveSocketHandler:
53
63
  await self.handle_connected(socket)
54
64
 
55
65
  except WebSocketDisconnect:
56
- self.manager.disconnect(websocket)
57
66
  if socket:
58
67
  await socket.close()
59
68
  self.sessions -= 1
69
+ except AuthException:
70
+ await websocket.close()
71
+ self.sessions -= 1
60
72
 
61
73
  async def handle_connected(self, socket: LiveViewSocket):
62
74
  while True:
@@ -122,18 +134,10 @@ async def _render(socket: LiveViewSocket):
122
134
 
123
135
  class ConnectionManager:
124
136
  def __init__(self):
125
- self.active_connections: list[WebSocket] = []
137
+ pass
126
138
 
127
139
  async def connect(self, websocket: WebSocket):
128
140
  await websocket.accept()
129
- self.active_connections.append(websocket)
130
-
131
- def disconnect(self, websocket: WebSocket):
132
- self.active_connections.remove(websocket)
133
141
 
134
142
  async def send_personal_message(self, message: str, websocket: WebSocket):
135
143
  await websocket.send_text(message)
136
-
137
- async def broadcast(self, message: str):
138
- for connection in self.active_connections:
139
- await connection.send_text(message)
@@ -1,121 +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.middleware.sessions import SessionMiddleware
6
- from starlette.routing import Route
7
- from starlette.requests import Request
8
- import uuid
9
- from urllib.parse import parse_qs
10
-
11
- from pyview.live_socket import UnconnectedSocket
12
- from pyview.csrf import generate_csrf_token
13
- from pyview.session import serialize_session
14
- from pyview.secret import get_secret
15
- from .ws_handler import LiveSocketHandler
16
- from .live_view import LiveView
17
- from .live_routes import LiveViewLookup
18
- from typing import Callable, Optional, TypedDict
19
-
20
-
21
- class RootTemplateContext(TypedDict):
22
- id: str
23
- content: str
24
- title: Optional[str]
25
- css: Optional[str]
26
- csrf_token: str
27
- session: Optional[str]
28
-
29
-
30
- RootTemplate = Callable[[RootTemplateContext], str]
31
-
32
-
33
- class PyView(Starlette):
34
- rootTemplate: RootTemplate
35
-
36
- def __init__(self, *args, **kwargs):
37
- super().__init__(*args, **kwargs)
38
- self.rootTemplate = defaultRootTemplate("")
39
- self.view_lookup = LiveViewLookup()
40
- self.live_handler = LiveSocketHandler(self.view_lookup)
41
-
42
- async def live_websocket_endpoint(websocket: WebSocket):
43
- await self.live_handler.handle(websocket)
44
-
45
- self.add_websocket_route("/live/websocket", live_websocket_endpoint)
46
- self.add_middleware(GZipMiddleware)
47
- # self.add_middleware(SessionMiddleware, secret_key=get_secret())
48
-
49
- def add_live_view(self, path: str, view: Callable[[], LiveView]):
50
- async def lv(request: Request):
51
- return await liveview_container(self.rootTemplate, self.view_lookup, request)
52
-
53
- self.view_lookup.add(path, view)
54
- self.routes.append(Route(path, lv, methods=["GET"]))
55
-
56
-
57
- async def liveview_container(template: RootTemplate, view_lookup: LiveViewLookup, request: Request):
58
- url = request.url
59
- path = url.path
60
- lv: LiveView = view_lookup.get(path)
61
- s = UnconnectedSocket()
62
-
63
- session = request.session if "session" in request.scope else {}
64
-
65
- await lv.mount(s, session)
66
- await lv.handle_params(url, parse_qs(url.query), s)
67
- r = await lv.render(s.context)
68
-
69
- id = str(uuid.uuid4())
70
-
71
- context: RootTemplateContext = {
72
- "id": id,
73
- "content": r.text(),
74
- "title": s.live_title,
75
- "csrf_token": generate_csrf_token("lv:phx-" + id),
76
- "css": None,
77
- "session": serialize_session(session),
78
- }
79
-
80
- return HTMLResponse(template(context))
81
-
82
-
83
- def defaultRootTemplate(css: str) -> RootTemplate:
84
- def template(context: RootTemplateContext) -> str:
85
- context["css"] = css
86
- return _defaultRootTemplate(context)
87
-
88
- return template
89
-
90
-
91
- def _defaultRootTemplate(context: RootTemplateContext) -> str:
92
- suffix = " | LiveView"
93
- render_title = (context["title"] + suffix) if context.get("title", None) is not None else "LiveView" # type: ignore
94
- css = context["css"] if context.get("css", None) is not None else ""
95
- return f"""
96
- <!DOCTYPE html>
97
- <html lang="en">
98
- <head>
99
- <title data-suffix="{suffix}">{render_title}</title>
100
- <meta name="csrf-token" content="{context['csrf_token']}" />
101
- <meta charset="utf-8">
102
- <meta http-equiv="X-UA-Compatible" content="IE=edge">
103
- <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
104
- <script defer type="text/javascript" src="/static/assets/app.js"></script>
105
- {css}
106
- </head>
107
- <body>
108
- <div>
109
- <a href="/">Home</a>
110
- <div
111
- data-phx-main="true"
112
- data-phx-session="{context['session']}"
113
- data-phx-static=""
114
- id="phx-{context['id']}"
115
- >
116
- {context['content']}
117
- </div>
118
- </div>
119
- </body>
120
- </html>
121
- """
File without changes
File without changes
File without changes