pyview-web 0.0.7a0__py3-none-any.whl → 0.0.9a0__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.

@@ -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,43 @@
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
+ import sys
8
+
9
+ if sys.version_info >= (3, 10): # pragma: no cover
10
+ from typing import ParamSpec
11
+ else: # pragma: no cover
12
+ from typing_extensions import ParamSpec
13
+
14
+ _P = ParamSpec("_P")
15
+
16
+
17
+ @dataclass
18
+ class RequiredScopeAuthProvider(AuthProvider):
19
+ scopes: typing.Sequence[str]
20
+ status_code: int = 403
21
+ redirect: typing.Optional[str] = None
22
+
23
+ def wrap(
24
+ self, func: typing.Callable[_P, typing.Any]
25
+ ) -> typing.Callable[_P, typing.Any]:
26
+ return starlette_requires(self.scopes, self.status_code, self.redirect)(func)
27
+
28
+ async def has_required_auth(self, websocket: WebSocket) -> bool:
29
+ return has_required_scope(websocket, self.scopes)
30
+
31
+
32
+ def requires(
33
+ scopes: typing.Union[str, typing.Sequence[str]],
34
+ status_code: int = 403,
35
+ redirect: typing.Optional[str] = None,
36
+ ) -> typing.Callable[[type[LiveView]], type[LiveView]]:
37
+ def decorator(cls: type[LiveView]) -> type[LiveView]:
38
+ scopes_list = [scopes] if isinstance(scopes, str) else list(scopes)
39
+ return AuthProviderFactory.set(
40
+ cls, RequiredScopeAuthProvider(scopes_list, status_code, redirect)
41
+ )
42
+
43
+ return decorator
pyview/pyview.py CHANGED
@@ -2,7 +2,6 @@ from starlette.applications import Starlette
2
2
  from fastapi import WebSocket
3
3
  from fastapi.responses import HTMLResponse
4
4
  from starlette.middleware.gzip import GZipMiddleware
5
- from starlette.middleware.sessions import SessionMiddleware
6
5
  from starlette.routing import Route
7
6
  from starlette.requests import Request
8
7
  import uuid
@@ -11,23 +10,11 @@ from urllib.parse import parse_qs
11
10
  from pyview.live_socket import UnconnectedSocket
12
11
  from pyview.csrf import generate_csrf_token
13
12
  from pyview.session import serialize_session
14
- from pyview.secret import get_secret
13
+ from pyview.auth import AuthProviderFactory
15
14
  from .ws_handler import LiveSocketHandler
16
15
  from .live_view import LiveView
17
16
  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]
17
+ from .template import RootTemplate, RootTemplateContext, defaultRootTemplate
31
18
 
32
19
 
33
20
  class PyView(Starlette):
@@ -35,7 +22,7 @@ class PyView(Starlette):
35
22
 
36
23
  def __init__(self, *args, **kwargs):
37
24
  super().__init__(*args, **kwargs)
38
- self.rootTemplate = defaultRootTemplate("")
25
+ self.rootTemplate = defaultRootTemplate()
39
26
  self.view_lookup = LiveViewLookup()
40
27
  self.live_handler = LiveSocketHandler(self.view_lookup)
41
28
 
@@ -44,17 +31,21 @@ class PyView(Starlette):
44
31
 
45
32
  self.add_websocket_route("/live/websocket", live_websocket_endpoint)
46
33
  self.add_middleware(GZipMiddleware)
47
- # self.add_middleware(SessionMiddleware, secret_key=get_secret())
48
34
 
49
- def add_live_view(self, path: str, view: Callable[[], LiveView]):
35
+ def add_live_view(self, path: str, view: type[LiveView]):
50
36
  async def lv(request: Request):
51
- return await liveview_container(self.rootTemplate, self.view_lookup, request)
37
+ return await liveview_container(
38
+ self.rootTemplate, self.view_lookup, request
39
+ )
52
40
 
53
41
  self.view_lookup.add(path, view)
54
- self.routes.append(Route(path, lv, methods=["GET"]))
42
+ auth = AuthProviderFactory.get(view)
43
+ self.routes.append(Route(path, auth.wrap(lv), methods=["GET"]))
55
44
 
56
45
 
57
- async def liveview_container(template: RootTemplate, view_lookup: LiveViewLookup, request: Request):
46
+ async def liveview_container(
47
+ template: RootTemplate, view_lookup: LiveViewLookup, request: Request
48
+ ):
58
49
  url = request.url
59
50
  path = url.path
60
51
  lv: LiveView = view_lookup.get(path)
@@ -73,49 +64,7 @@ async def liveview_container(template: RootTemplate, view_lookup: LiveViewLookup
73
64
  "content": r.text(),
74
65
  "title": s.live_title,
75
66
  "csrf_token": generate_csrf_token("lv:phx-" + id),
76
- "css": None,
77
67
  "session": serialize_session(session),
78
68
  }
79
69
 
80
70
  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
- """
@@ -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
+ )
pyview/ws_handler.py CHANGED
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pyview-web
3
- Version: 0.0.7a0
3
+ Version: 0.0.9a0
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
@@ -36,7 +34,7 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
36
34
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
37
35
  Classifier: Typing :: Typed
38
36
  Requires-Dist: APScheduler (==3.9.1.post1)
39
- Requires-Dist: fastapi (==0.89.1)
37
+ Requires-Dist: fastapi (==0.111.0)
40
38
  Requires-Dist: itsdangerous (>=2.1.2,<3.0.0)
41
39
  Requires-Dist: markupsafe (>=2.1.2,<3.0.0)
42
40
  Requires-Dist: psutil (>=5.9.4,<6.0.0)
@@ -59,10 +57,22 @@ PyView enables dynamic, real-time web apps, using server-rendered HTML.
59
57
 
60
58
  `pip install pyview-web`
61
59
 
60
+ ## Quickstart
61
+
62
+ There's a [cookiecutter](https://github.com/cookiecutter/cookiecutter) template available
63
+
64
+ ```
65
+ cookiecutter gh:ogrodnek/pyview-cookiecutter
66
+ ```
67
+
62
68
  # Live Examples
63
69
 
64
70
  [https://examples.pyview.rocks/](https://examples.pyview.rocks/)
65
71
 
72
+ ## Other Examples
73
+
74
+ - [pyview AI chat](https://github.com/pyview/pyview-example-ai-chat)
75
+
66
76
  ## Simple Counter
67
77
 
68
78
  [See it live!](https://examples.pyview.rocks/count)
@@ -2,6 +2,9 @@ pyview/__init__.py,sha256=MN3RrkiFdM6bOZV6qJgvwifXV81Qx9k_PN2p8zJvq2I,223
2
2
  pyview/assets/js/app.js,sha256=n7FiM_VoxZ3ZUkLjtyxbOnSZU88zZkN1A5b8jjhiFoo,2508
3
3
  pyview/assets/package-lock.json,sha256=kFCrEUJc3G7VD7EsBQf6__EKQhaKAok-I5rrwiAoX0w,2425
4
4
  pyview/assets/package.json,sha256=E6xaX8KMUAektIIedLmI55jGnmlNMSeD2tgKYXWk1vg,151
5
+ pyview/auth/__init__.py,sha256=vMlirETRhD4va61NOzwg8VY8ep9wVOF96GznJGBmzD0,109
6
+ pyview/auth/provider.py,sha256=fwriy2JZcOStutVXD-8VlMPAFXjILCM0l08lhTgmuyE,935
7
+ pyview/auth/required.py,sha256=ZtNmLFth9nK39RxDiJkSzArXwS5Cvr55MUAzfJ1F2e0,1418
5
8
  pyview/changesets/__init__.py,sha256=55CLari2JHZtwy4hapHe7CqUyKjcP4dkM_t5d3CY2gU,46
6
9
  pyview/changesets/changesets.py,sha256=B1q1nXwI2iuZZQpE3P2T0PpwI21PHjqcsuIQmkKPCvI,1747
7
10
  pyview/csrf.py,sha256=VIURva9EJqXXYGC7engweh3SwDQCnHlhV2zWdcdnFqc,789
@@ -10,12 +13,13 @@ pyview/js.py,sha256=4OnPEfBfuvmekeQlm9444As4PLR22zLMIyyzQIIkmls,751
10
13
  pyview/live_routes.py,sha256=tsKFh2gmH2BWsjsZQZErzRp_-KiAZcn4lFKNLRIN5Nc,498
11
14
  pyview/live_socket.py,sha256=p3eTynzGtvLiooOZyTemmXTrusAJKaoueuZ6Q5gfeYk,3314
12
15
  pyview/live_view.py,sha256=RRhj89NMianv6dkYd9onOQEJgpRF-pqUb7nmbQr6R6E,1255
13
- pyview/pyview.py,sha256=fcpw75kPAZ8XTTLFg1l1VYhVa90tdjo3fLzQYRS0IeU,3801
16
+ pyview/pyview.py,sha256=LdW2irgsd4KRls5TLU22DZJIy5Pmo_2L-ajOwXi9cEs,2302
14
17
  pyview/secret.py,sha256=HbaNpGAkFs4uxMVAmk9HwE3FIehg7dmwEOlED7C9moM,363
15
18
  pyview/session.py,sha256=nC8ExyVwfCgQfx9T-aJGyFhr2C7jsrEY_QFkaXtP28U,432
16
19
  pyview/static/assets/app.js,sha256=vyD-RACuZxOBWGy7VD-BY5-qkgbFyJM43-M-WGgaAHo,199984
17
- pyview/template/__init__.py,sha256=qQDtCWlOZ_trRUj2LdmV5mE1b5ThGPlovrrX5jkNgjE,124
20
+ pyview/template/__init__.py,sha256=bDaxDV7QhUwBXfy672Ft0NBDNvhT4kEKDV5VUWhADe8,206
18
21
  pyview/template/live_template.py,sha256=wSKyBw7ejpUY5qXUZdE36Jeeix8Of0CUq8eZdQwxXyg,1864
22
+ pyview/template/root_template.py,sha256=0U50QIBhLVshLEog0SCbTDWgRjMpkfNDVcqowKFmGSQ,1876
19
23
  pyview/template/serializer.py,sha256=WDZfqJr2LMlf36fUW2CmWc2aREc63553_y_GRP2-qYc,826
20
24
  pyview/test_csrf.py,sha256=QWTOtfagDMkoYDK_ehYxua34F7-ltPsSeTwQGEOlqHU,684
21
25
  pyview/vendor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -31,8 +35,8 @@ pyview/vendor/ibis/nodes.py,sha256=jNRmlTCHXC4xCF8nfDRlLWINlaYiFa8NGrzebJFJgEM,2
31
35
  pyview/vendor/ibis/template.py,sha256=IX9z-Ig13yJyRnMqtB52eiRLe002qdIxnfa7fYEXLqM,2314
32
36
  pyview/vendor/ibis/tree.py,sha256=5LAjl3q9iPMZBb6QbKurWj9-QGKLVf11K2_bQotWlUc,2293
33
37
  pyview/vendor/ibis/utils.py,sha256=nLSaxPR9vMphzV9qinlz_Iurv9c49Ps6Knv8vyNlewU,2768
34
- pyview/ws_handler.py,sha256=iDPUv1ofpkDPSSWr6jWHl99vISAL_HIjERPsLjyc2Ic,4707
35
- pyview_web-0.0.7a0.dist-info/LICENSE,sha256=M_bADaBm9_MV9llX3lCicksLhwk3eZUjA2srE0uUWr0,1071
36
- pyview_web-0.0.7a0.dist-info/METADATA,sha256=LbhkNmx-nU2Yc2Q3k7Mu5pyjgyIjXhcRpOhvWwg0JWQ,5069
37
- pyview_web-0.0.7a0.dist-info/WHEEL,sha256=7Z8_27uaHI_UZAc4Uox4PpBhQ9Y5_modZXWMxtUi4NU,88
38
- pyview_web-0.0.7a0.dist-info/RECORD,,
38
+ pyview/ws_handler.py,sha256=tokN9gtC_Gn1tlPTxfhYGSFfquiSUvD3sHigzjh1gYM,4738
39
+ pyview_web-0.0.9a0.dist-info/LICENSE,sha256=M_bADaBm9_MV9llX3lCicksLhwk3eZUjA2srE0uUWr0,1071
40
+ pyview_web-0.0.9a0.dist-info/METADATA,sha256=A5xUGWuuj2y8FvzoqsSynuwkD3osTc2ZO1HWzYzUm2U,5220
41
+ pyview_web-0.0.9a0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
42
+ pyview_web-0.0.9a0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.5.2
2
+ Generator: poetry-core 1.9.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any