pyview-web 0.3.0__py3-none-any.whl → 0.8.0a2__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.
Files changed (78) hide show
  1. pyview/__init__.py +16 -6
  2. pyview/assets/js/app.js +1 -0
  3. pyview/assets/js/uploaders.js +221 -0
  4. pyview/assets/package-lock.json +16 -14
  5. pyview/assets/package.json +2 -2
  6. pyview/async_stream_runner.py +2 -1
  7. pyview/auth/__init__.py +3 -1
  8. pyview/auth/provider.py +6 -6
  9. pyview/auth/required.py +7 -10
  10. pyview/binding/__init__.py +47 -0
  11. pyview/binding/binder.py +134 -0
  12. pyview/binding/context.py +33 -0
  13. pyview/binding/converters.py +191 -0
  14. pyview/binding/helpers.py +78 -0
  15. pyview/binding/injectables.py +119 -0
  16. pyview/binding/params.py +105 -0
  17. pyview/binding/result.py +32 -0
  18. pyview/changesets/__init__.py +2 -0
  19. pyview/changesets/changesets.py +8 -3
  20. pyview/cli/commands/create_view.py +4 -3
  21. pyview/cli/main.py +1 -1
  22. pyview/components/__init__.py +72 -0
  23. pyview/components/base.py +212 -0
  24. pyview/components/lifecycle.py +85 -0
  25. pyview/components/manager.py +366 -0
  26. pyview/components/renderer.py +14 -0
  27. pyview/components/slots.py +73 -0
  28. pyview/csrf.py +4 -2
  29. pyview/events/AutoEventDispatch.py +98 -0
  30. pyview/events/BaseEventHandler.py +51 -8
  31. pyview/events/__init__.py +2 -1
  32. pyview/instrumentation/__init__.py +3 -3
  33. pyview/instrumentation/interfaces.py +57 -33
  34. pyview/instrumentation/noop.py +21 -18
  35. pyview/js.py +20 -23
  36. pyview/live_routes.py +5 -3
  37. pyview/live_socket.py +167 -44
  38. pyview/live_view.py +24 -12
  39. pyview/meta.py +14 -2
  40. pyview/phx_message.py +7 -8
  41. pyview/playground/__init__.py +10 -0
  42. pyview/playground/builder.py +118 -0
  43. pyview/playground/favicon.py +39 -0
  44. pyview/pyview.py +54 -20
  45. pyview/session.py +2 -0
  46. pyview/static/assets/app.js +2088 -806
  47. pyview/static/assets/uploaders.js +221 -0
  48. pyview/stream.py +308 -0
  49. pyview/template/__init__.py +11 -1
  50. pyview/template/live_template.py +12 -8
  51. pyview/template/live_view_template.py +338 -0
  52. pyview/template/render_diff.py +33 -7
  53. pyview/template/root_template.py +21 -9
  54. pyview/template/serializer.py +2 -5
  55. pyview/template/template_view.py +170 -0
  56. pyview/template/utils.py +3 -2
  57. pyview/uploads.py +344 -55
  58. pyview/vendor/flet/pubsub/__init__.py +3 -1
  59. pyview/vendor/flet/pubsub/pub_sub.py +10 -18
  60. pyview/vendor/ibis/__init__.py +3 -7
  61. pyview/vendor/ibis/compiler.py +25 -32
  62. pyview/vendor/ibis/context.py +13 -15
  63. pyview/vendor/ibis/errors.py +0 -6
  64. pyview/vendor/ibis/filters.py +70 -76
  65. pyview/vendor/ibis/loaders.py +6 -7
  66. pyview/vendor/ibis/nodes.py +40 -42
  67. pyview/vendor/ibis/template.py +4 -5
  68. pyview/vendor/ibis/tree.py +62 -3
  69. pyview/vendor/ibis/utils.py +14 -15
  70. pyview/ws_handler.py +116 -86
  71. {pyview_web-0.3.0.dist-info → pyview_web-0.8.0a2.dist-info}/METADATA +39 -33
  72. pyview_web-0.8.0a2.dist-info/RECORD +80 -0
  73. pyview_web-0.8.0a2.dist-info/WHEEL +4 -0
  74. pyview_web-0.8.0a2.dist-info/entry_points.txt +3 -0
  75. pyview_web-0.3.0.dist-info/LICENSE +0 -21
  76. pyview_web-0.3.0.dist-info/RECORD +0 -58
  77. pyview_web-0.3.0.dist-info/WHEEL +0 -4
  78. pyview_web-0.3.0.dist-info/entry_points.txt +0 -3
pyview/__init__.py CHANGED
@@ -1,13 +1,15 @@
1
- from pyview.live_view import LiveView
1
+ from pyview.components import ComponentMeta, ComponentsManager, ComponentSocket, LiveComponent
2
+ from pyview.js import JsCommand
2
3
  from pyview.live_socket import (
3
- LiveViewSocket,
4
- is_connected,
5
4
  ConnectedLiveViewSocket,
5
+ LiveViewSocket,
6
6
  UnconnectedSocket,
7
+ is_connected,
7
8
  )
8
- from pyview.pyview import PyView, defaultRootTemplate
9
- from pyview.js import JsCommand
10
- from pyview.pyview import RootTemplateContext, RootTemplate
9
+ from pyview.live_view import LiveView
10
+ from pyview.playground import playground
11
+ from pyview.pyview import PyView, RootTemplate, RootTemplateContext, defaultRootTemplate
12
+ from pyview.stream import Stream
11
13
 
12
14
  __all__ = [
13
15
  "LiveView",
@@ -19,4 +21,12 @@ __all__ = [
19
21
  "RootTemplate",
20
22
  "is_connected",
21
23
  "ConnectedLiveViewSocket",
24
+ "UnconnectedSocket",
25
+ "playground",
26
+ "Stream",
27
+ # Components
28
+ "LiveComponent",
29
+ "ComponentMeta",
30
+ "ComponentSocket",
31
+ "ComponentsManager",
22
32
  ]
pyview/assets/js/app.js CHANGED
@@ -61,6 +61,7 @@ let csrfToken = document
61
61
  let liveSocket = new LiveSocket("/live", Socket, {
62
62
  hooks: Hooks,
63
63
  params: { _csrf_token: csrfToken },
64
+ uploaders: window.Uploaders || {},
64
65
  });
65
66
 
66
67
  // Show progress bar on live navigation and form submits
@@ -0,0 +1,221 @@
1
+ /**
2
+ * PyView External S3 Uploaders
3
+ *
4
+ * Client-side uploaders for external S3 uploads.
5
+ *
6
+ * Available uploaders:
7
+ * - S3: Simple POST upload to S3 using presigned POST URLs
8
+ * - S3Multipart: Multipart upload for large files (>5GB)
9
+ */
10
+
11
+ window.Uploaders = window.Uploaders || {};
12
+
13
+ // S3 Simple POST uploader
14
+ // Uses presigned POST URLs for direct upload to S3
15
+ // Works for files up to ~5GB
16
+ if (!window.Uploaders.S3) {
17
+ window.Uploaders.S3 = function (entries, onViewError) {
18
+ entries.forEach((entry) => {
19
+ let formData = new FormData();
20
+ let { url, fields } = entry.meta;
21
+
22
+ // Add all fields from presigned POST
23
+ Object.entries(fields).forEach(([key, val]) =>
24
+ formData.append(key, val)
25
+ );
26
+ formData.append("file", entry.file);
27
+
28
+ let xhr = new XMLHttpRequest();
29
+ onViewError(() => xhr.abort());
30
+
31
+ xhr.onload = () => {
32
+ if (xhr.status === 204 || xhr.status === 200) {
33
+ entry.progress(100);
34
+ } else {
35
+ entry.error(`S3 upload failed with status ${xhr.status}`);
36
+ }
37
+ };
38
+ xhr.onerror = () => entry.error("Network error during upload");
39
+
40
+ xhr.upload.addEventListener("progress", (event) => {
41
+ if (event.lengthComputable) {
42
+ let percent = Math.round((event.loaded / event.total) * 100);
43
+ if (percent < 100) {
44
+ entry.progress(percent);
45
+ }
46
+ }
47
+ });
48
+
49
+ xhr.open("POST", url, true);
50
+ xhr.send(formData);
51
+ });
52
+ };
53
+ }
54
+
55
+ // S3 Multipart uploader for large files
56
+ // Uploads file in chunks with retry logic and concurrency control
57
+ //
58
+ // - Exponential backoff retry (max 3 attempts per part)
59
+ // - Concurrency limit (max 6 parallel uploads)
60
+ // - Automatic cleanup on fatal errors
61
+ //
62
+ // Based on AWS best practices:
63
+ // https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
64
+ //
65
+ // Server must:
66
+ // 1. Return metadata with: uploader="S3Multipart", upload_id, part_urls, chunk_size
67
+ // 2. Provide entry_complete callback to finalize the upload
68
+ if (!window.Uploaders.S3Multipart) {
69
+ window.Uploaders.S3Multipart = function (entries, onViewError) {
70
+ entries.forEach((entry) => {
71
+ const { upload_id, part_urls, chunk_size, key } = entry.meta;
72
+ const file = entry.file;
73
+ const parts = []; // Store {PartNumber, ETag} for each uploaded part
74
+
75
+ const MAX_RETRIES = 3;
76
+ const MAX_CONCURRENT = 6;
77
+ let uploadedParts = 0;
78
+ let activeUploads = 0;
79
+ let partIndex = 0;
80
+ let hasError = false;
81
+ const totalParts = part_urls.length;
82
+
83
+ console.log(`[S3Multipart] Starting upload for ${entry.file.name}`);
84
+ console.log(`[S3Multipart] Total parts: ${totalParts}, chunk size: ${chunk_size}`);
85
+ console.log(`[S3Multipart] Max concurrent uploads: ${MAX_CONCURRENT}, max retries: ${MAX_RETRIES}`);
86
+
87
+ // Add a custom method to send completion data directly
88
+ // This bypasses entry.progress() which only handles numbers
89
+ entry.complete = function(completionData) {
90
+ console.log(`[S3Multipart] Calling entry.complete with:`, completionData);
91
+ // Call pushFileProgress directly with the completion data
92
+ entry.view.pushFileProgress(entry.fileEl, entry.ref, completionData);
93
+ };
94
+
95
+ // Upload a single part with retry logic
96
+ const uploadPart = (index, retryCount = 0) => {
97
+ if (hasError) return; // Stop if we've hit a fatal error
98
+
99
+ const partNumber = index + 1;
100
+ const url = part_urls[index];
101
+ const start = index * chunk_size;
102
+ const end = Math.min(start + chunk_size, file.size);
103
+ const chunk = file.slice(start, end);
104
+
105
+ console.log(`[S3Multipart] Starting part ${partNumber}/${totalParts}, size: ${chunk.size} bytes, attempt ${retryCount + 1}`);
106
+
107
+ const xhr = new XMLHttpRequest();
108
+ onViewError(() => xhr.abort());
109
+
110
+ // Track upload progress within this chunk
111
+ xhr.upload.addEventListener("progress", (event) => {
112
+ if (event.lengthComputable) {
113
+ // Calculate overall progress: completed parts + current part's progress
114
+ const completedBytes = uploadedParts * chunk_size;
115
+ const currentPartBytes = event.loaded;
116
+ const totalBytes = file.size;
117
+ const overallPercent = Math.round(((completedBytes + currentPartBytes) / totalBytes) * 100);
118
+
119
+ // Don't report 100% until all parts complete and we send completion data
120
+ if (overallPercent < 100) {
121
+ entry.progress(overallPercent);
122
+ }
123
+ }
124
+ });
125
+
126
+ xhr.onload = () => {
127
+ activeUploads--;
128
+
129
+ if (xhr.status === 200) {
130
+ const etag = xhr.getResponseHeader('ETag');
131
+ console.log(`[S3Multipart] Part ${partNumber} succeeded, ETag: ${etag}`);
132
+
133
+ if (!etag) {
134
+ console.error(`[S3Multipart] Part ${partNumber} missing ETag!`);
135
+ entry.error(`Part ${partNumber} upload succeeded but no ETag returned`);
136
+ hasError = true;
137
+ return;
138
+ }
139
+
140
+ // Store the part with its ETag
141
+ parts.push({
142
+ PartNumber: partNumber,
143
+ ETag: etag.replace(/"/g, '')
144
+ });
145
+ uploadedParts++;
146
+
147
+ // Update progress
148
+ const progressPercent = Math.round((uploadedParts / totalParts) * 100);
149
+ console.log(`[S3Multipart] Progress: ${uploadedParts}/${totalParts} parts (${progressPercent}%)`);
150
+
151
+ if (uploadedParts < totalParts) {
152
+ entry.progress(progressPercent < 100 ? progressPercent : 99);
153
+ uploadNextPart(); // Start next part
154
+ } else {
155
+ // All parts complete!
156
+ const completionData = {
157
+ complete: true,
158
+ upload_id: upload_id,
159
+ key: key,
160
+ parts: parts.sort((a, b) => a.PartNumber - b.PartNumber)
161
+ };
162
+ console.log(`[S3Multipart] All parts complete! Sending completion data`);
163
+ entry.complete(completionData);
164
+ }
165
+ } else {
166
+ // Upload failed - retry with exponential backoff
167
+ console.error(`[S3Multipart] Part ${partNumber} failed with status ${xhr.status}, attempt ${retryCount + 1}`);
168
+
169
+ if (retryCount < MAX_RETRIES) {
170
+ // Exponential backoff: 1s, 2s, 4s, max 10s
171
+ const delay = Math.min(1000 * (2 ** retryCount), 10000);
172
+ console.log(`[S3Multipart] Retrying part ${partNumber} in ${delay}ms...`);
173
+
174
+ setTimeout(() => {
175
+ uploadPart(index, retryCount + 1);
176
+ }, delay);
177
+ } else {
178
+ // Max retries exceeded - fatal error
179
+ console.error(`[S3Multipart] Part ${partNumber} failed after ${MAX_RETRIES} retries, aborting upload`);
180
+ entry.error(`Part ${partNumber} failed after ${MAX_RETRIES} attempts. Upload aborted.`);
181
+ hasError = true;
182
+ }
183
+ }
184
+ };
185
+
186
+ xhr.onerror = () => {
187
+ activeUploads--;
188
+ console.error(`[S3Multipart] Network error on part ${partNumber}, attempt ${retryCount + 1}`);
189
+
190
+ if (retryCount < MAX_RETRIES) {
191
+ const delay = Math.min(1000 * (2 ** retryCount), 10000);
192
+ console.log(`[S3Multipart] Retrying part ${partNumber} after network error in ${delay}ms...`);
193
+
194
+ setTimeout(() => {
195
+ uploadPart(index, retryCount + 1);
196
+ }, delay);
197
+ } else {
198
+ console.error(`[S3Multipart] Part ${partNumber} network error after ${MAX_RETRIES} retries, aborting upload`);
199
+ entry.error(`Part ${partNumber} network error after ${MAX_RETRIES} attempts. Upload aborted.`);
200
+ hasError = true;
201
+ }
202
+ };
203
+
204
+ xhr.open('PUT', url, true);
205
+ xhr.send(chunk);
206
+ activeUploads++;
207
+ };
208
+
209
+ // Upload next part if we haven't hit the concurrency limit
210
+ const uploadNextPart = () => {
211
+ while (partIndex < totalParts && activeUploads < MAX_CONCURRENT && !hasError) {
212
+ uploadPart(partIndex);
213
+ partIndex++;
214
+ }
215
+ };
216
+
217
+ // Start initial batch of uploads
218
+ uploadNextPart();
219
+ });
220
+ };
221
+ }
@@ -6,9 +6,9 @@
6
6
  "": {
7
7
  "dependencies": {
8
8
  "nprogress": "^0.2.0",
9
- "phoenix": "^1.7.0-rc.2",
9
+ "phoenix": "^1.7.0",
10
10
  "phoenix_html": "^3.2.0",
11
- "phoenix_live_view": "^0.18.11"
11
+ "phoenix_live_view": "^0.20.17"
12
12
  }
13
13
  },
14
14
  "node_modules/nprogress": {
@@ -17,9 +17,10 @@
17
17
  "integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA=="
18
18
  },
19
19
  "node_modules/phoenix": {
20
- "version": "1.7.0-rc.2",
21
- "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.7.0-rc.2.tgz",
22
- "integrity": "sha512-05DaSo/ws2VtieY3Z6CJVx36DyhTil6/KesK1a4JlQPxYgNHZn7swv7R/R7etoN8SGtEjMV/a9HBkPS5wF/Xdg=="
20
+ "version": "1.8.3",
21
+ "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.8.3.tgz",
22
+ "integrity": "sha512-5bMYQI30wl3erxbHnXMdt1xuQeRTeEOpQrakf3yqj/1HRHl7Gj4Cdk2NKXkUcCD5WpbxrilvZEMexM1VhWbnDg==",
23
+ "license": "MIT"
23
24
  },
24
25
  "node_modules/phoenix_html": {
25
26
  "version": "3.2.0",
@@ -27,9 +28,10 @@
27
28
  "integrity": "sha512-zv7PIZk0MPkF0ax8n465Q6w86+sGAy5cTem6KcbkUbdgxGc0y3WZmzkM2bSlYdSGbLEZfjXxos1G72xXsha6xA=="
28
29
  },
29
30
  "node_modules/phoenix_live_view": {
30
- "version": "0.18.11",
31
- "resolved": "https://registry.npmjs.org/phoenix_live_view/-/phoenix_live_view-0.18.11.tgz",
32
- "integrity": "sha512-p/mBu/O3iVLvAreUoDeSZ4/myQJJeR8BH7Yu9LVCMI2xe2IZ2mffxtDGJb0mxnJrUQa7p03HHNlKGXj7LSJDdg=="
31
+ "version": "0.20.17",
32
+ "resolved": "https://registry.npmjs.org/phoenix_live_view/-/phoenix_live_view-0.20.17.tgz",
33
+ "integrity": "sha512-qGT3Jtj2wUawOaMrE8NKXmkexfaUn6bx5PuPMxWMzYuyp6Qv9i4xRZ2T3U6avC5Kf+oJEiBVIiWrODooC0vpQw==",
34
+ "license": "MIT"
33
35
  }
34
36
  },
35
37
  "dependencies": {
@@ -39,9 +41,9 @@
39
41
  "integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA=="
40
42
  },
41
43
  "phoenix": {
42
- "version": "1.7.0-rc.2",
43
- "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.7.0-rc.2.tgz",
44
- "integrity": "sha512-05DaSo/ws2VtieY3Z6CJVx36DyhTil6/KesK1a4JlQPxYgNHZn7swv7R/R7etoN8SGtEjMV/a9HBkPS5wF/Xdg=="
44
+ "version": "1.8.3",
45
+ "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.8.3.tgz",
46
+ "integrity": "sha512-5bMYQI30wl3erxbHnXMdt1xuQeRTeEOpQrakf3yqj/1HRHl7Gj4Cdk2NKXkUcCD5WpbxrilvZEMexM1VhWbnDg=="
45
47
  },
46
48
  "phoenix_html": {
47
49
  "version": "3.2.0",
@@ -49,9 +51,9 @@
49
51
  "integrity": "sha512-zv7PIZk0MPkF0ax8n465Q6w86+sGAy5cTem6KcbkUbdgxGc0y3WZmzkM2bSlYdSGbLEZfjXxos1G72xXsha6xA=="
50
52
  },
51
53
  "phoenix_live_view": {
52
- "version": "0.18.11",
53
- "resolved": "https://registry.npmjs.org/phoenix_live_view/-/phoenix_live_view-0.18.11.tgz",
54
- "integrity": "sha512-p/mBu/O3iVLvAreUoDeSZ4/myQJJeR8BH7Yu9LVCMI2xe2IZ2mffxtDGJb0mxnJrUQa7p03HHNlKGXj7LSJDdg=="
54
+ "version": "0.20.17",
55
+ "resolved": "https://registry.npmjs.org/phoenix_live_view/-/phoenix_live_view-0.20.17.tgz",
56
+ "integrity": "sha512-qGT3Jtj2wUawOaMrE8NKXmkexfaUn6bx5PuPMxWMzYuyp6Qv9i4xRZ2T3U6avC5Kf+oJEiBVIiWrODooC0vpQw=="
55
57
  }
56
58
  }
57
59
  }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "dependencies": {
3
3
  "nprogress": "^0.2.0",
4
- "phoenix": "^1.7.0-rc.2",
4
+ "phoenix": "^1.7.0",
5
5
  "phoenix_html": "^3.2.0",
6
- "phoenix_live_view": "^0.18.11"
6
+ "phoenix_live_view": "^0.20.17"
7
7
  }
8
8
  }
@@ -1,7 +1,8 @@
1
1
  import asyncio
2
- import uuid
3
2
  import logging
3
+ import uuid
4
4
  from typing import Any, AsyncGenerator, Callable, Optional
5
+
5
6
  from pyview.events.info_event import InfoEvent, InfoEventScheduler
6
7
 
7
8
 
pyview/auth/__init__.py CHANGED
@@ -1,2 +1,4 @@
1
+ from .provider import AllowAllAuthProvider, AuthProvider, AuthProviderFactory
1
2
  from .required import requires
2
- from .provider import AuthProvider, AllowAllAuthProvider, AuthProviderFactory
3
+
4
+ __all__ = ["AllowAllAuthProvider", "AuthProvider", "AuthProviderFactory", "requires"]
pyview/auth/provider.py CHANGED
@@ -1,16 +1,16 @@
1
- from typing import Protocol, TypeVar, Callable
1
+ from typing import Callable, Protocol, TypeVar
2
+
2
3
  from starlette.websockets import WebSocket
4
+
3
5
  from pyview import LiveView
4
6
 
5
7
  _CallableType = TypeVar("_CallableType", bound=Callable)
6
8
 
7
9
 
8
10
  class AuthProvider(Protocol):
9
- async def has_required_auth(self, websocket: WebSocket) -> bool:
10
- ...
11
+ async def has_required_auth(self, websocket: WebSocket) -> bool: ...
11
12
 
12
- def wrap(self, func: _CallableType) -> _CallableType:
13
- ...
13
+ def wrap(self, func: _CallableType) -> _CallableType: ...
14
14
 
15
15
 
16
16
  class AllowAllAuthProvider(AuthProvider):
@@ -28,5 +28,5 @@ class AuthProviderFactory:
28
28
 
29
29
  @classmethod
30
30
  def set(cls, lv: type[LiveView], auth_provider: AuthProvider) -> type[LiveView]:
31
- setattr(lv, "__pyview_auth_provider__", auth_provider)
31
+ setattr(lv, "__pyview_auth_provider__", auth_provider) # noqa: B010
32
32
  return lv
pyview/auth/required.py CHANGED
@@ -1,15 +1,14 @@
1
1
  import typing
2
2
  from dataclasses import dataclass
3
+ from typing import ParamSpec
4
+
5
+ from starlette.authentication import has_required_scope
6
+ from starlette.authentication import requires as starlette_requires
3
7
  from starlette.websockets import WebSocket
4
- from starlette.authentication import requires as starlette_requires, has_required_scope
5
- from .provider import AuthProvider, AuthProviderFactory
8
+
6
9
  from pyview import LiveView
7
- import sys
8
10
 
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
11
+ from .provider import AuthProvider, AuthProviderFactory
13
12
 
14
13
  _P = ParamSpec("_P")
15
14
 
@@ -20,9 +19,7 @@ class RequiredScopeAuthProvider(AuthProvider):
20
19
  status_code: int = 403
21
20
  redirect: typing.Optional[str] = None
22
21
 
23
- def wrap(
24
- self, func: typing.Callable[_P, typing.Any]
25
- ) -> typing.Callable[_P, typing.Any]:
22
+ def wrap(self, func: typing.Callable[_P, typing.Any]) -> typing.Callable[_P, typing.Any]:
26
23
  return starlette_requires(self.scopes, self.status_code, self.redirect)(func)
27
24
 
28
25
  async def has_required_auth(self, websocket: WebSocket) -> bool:
@@ -0,0 +1,47 @@
1
+ """Parameter binding for pyview handlers.
2
+
3
+ This module provides signature-driven parameter binding, allowing handlers to
4
+ declare typed parameters that are automatically converted from request data.
5
+
6
+ Example:
7
+ # Old style (manual extraction):
8
+ async def handle_params(self, url, params, socket):
9
+ page = int(params["page"][0]) if "page" in params else 1
10
+
11
+ # New style (typed binding):
12
+ async def handle_params(self, socket: LiveViewSocket[MyContext], page: int = 1):
13
+ # page is already an int!
14
+ pass
15
+
16
+ Reserved parameter names (injected from context, not from URL params):
17
+ - socket: The LiveViewSocket instance
18
+ - url: The parsed URL
19
+ - event: The event name (for event handlers)
20
+ - payload: The event payload dict (for event handlers)
21
+
22
+ Type-based injection:
23
+ - params: Params -> injects Params container
24
+ - params: dict -> injects params as dict
25
+ - params: str -> treats "params" as a URL param name (not injected)
26
+ """
27
+
28
+ from .binder import Binder
29
+ from .context import BindContext
30
+ from .converters import ConversionError, ConverterRegistry
31
+ from .helpers import call_handle_event, call_handle_params
32
+ from .injectables import InjectableRegistry
33
+ from .params import Params
34
+ from .result import BindResult, ParamError
35
+
36
+ __all__ = [
37
+ "Params",
38
+ "BindContext",
39
+ "BindResult",
40
+ "ParamError",
41
+ "ConverterRegistry",
42
+ "ConversionError",
43
+ "InjectableRegistry",
44
+ "Binder",
45
+ "call_handle_event",
46
+ "call_handle_params",
47
+ ]
@@ -0,0 +1,134 @@
1
+ """Core binder for signature-driven parameter binding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import inspect
7
+ import logging
8
+ from typing import Any, Callable, Generic, TypeVar, get_type_hints
9
+
10
+ from .context import BindContext
11
+ from .converters import ConversionError, ConverterRegistry
12
+ from .injectables import _NOT_FOUND, InjectableRegistry
13
+ from .result import BindResult, ParamError
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ T = TypeVar("T")
18
+
19
+
20
+ class Binder(Generic[T]):
21
+ """Binds function parameters from BindContext based on type annotations.
22
+
23
+ The binder inspects a function's signature and type hints, then:
24
+ 1. Resolves injectable parameters (socket, event, payload, url, params)
25
+ 2. Pulls values from params or payload for other parameters
26
+ 3. Converts values to the expected types
27
+ 4. Returns a BindResult with bound_args and any errors
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ converter: ConverterRegistry | None = None,
33
+ injectables: InjectableRegistry[T] | None = None,
34
+ ):
35
+ self.converter = converter or ConverterRegistry()
36
+ self.injectables = injectables or InjectableRegistry()
37
+
38
+ def bind(self, func: Callable[..., Any], ctx: BindContext[T]) -> BindResult:
39
+ """Bind context values to function signature.
40
+
41
+ Args:
42
+ func: The function to bind parameters for
43
+ ctx: Binding context with params, payload, socket, etc.
44
+
45
+ Returns:
46
+ BindResult with bound_args dict and any errors
47
+ """
48
+ sig = inspect.signature(func)
49
+
50
+ # Get type hints, falling back to empty for missing annotations
51
+ # NameError: forward reference can't be resolved
52
+ # AttributeError: accessing annotations on some objects
53
+ # RecursionError: circular type references
54
+ try:
55
+ hints = get_type_hints(func)
56
+ except (NameError, AttributeError, RecursionError) as e:
57
+ logger.debug("Could not resolve type hints for %s: %s", func.__name__, e)
58
+ hints = {}
59
+
60
+ bound: dict[str, Any] = {}
61
+ errors: list[ParamError] = []
62
+
63
+ for name, param in sig.parameters.items():
64
+ # Skip 'self' for methods
65
+ if name == "self":
66
+ continue
67
+
68
+ # Skip *args and **kwargs (VAR_POSITIONAL and VAR_KEYWORD)
69
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
70
+ continue
71
+
72
+ expected = hints.get(name, Any)
73
+
74
+ # 1) Try injectables first
75
+ injected = self.injectables.resolve(name, expected, ctx)
76
+ if injected is not _NOT_FOUND:
77
+ bound[name] = injected
78
+ continue
79
+
80
+ # 2) Check for dataclass parameter - gather fields from params
81
+ if dataclasses.is_dataclass(expected) and isinstance(expected, type):
82
+ raw = self._resolve_dataclass_fields(expected, ctx)
83
+ try:
84
+ bound[name] = self.converter.convert(raw, expected)
85
+ except ConversionError as e:
86
+ errors.append(ParamError(name, repr(expected), raw, str(e)))
87
+ continue
88
+
89
+ # 3) Pull raw value from params or payload
90
+ raw = self._resolve_raw(name, ctx)
91
+
92
+ # 4) Handle missing values
93
+ if raw is None:
94
+ if param.default is not inspect.Parameter.empty:
95
+ bound[name] = param.default
96
+ continue
97
+ if self.converter.is_optional(expected):
98
+ bound[name] = None
99
+ continue
100
+ errors.append(ParamError(name, repr(expected), None, "missing required parameter"))
101
+ continue
102
+
103
+ # 5) Convert to expected type
104
+ try:
105
+ bound[name] = self.converter.convert(raw, expected)
106
+ except ConversionError as e:
107
+ errors.append(ParamError(name, repr(expected), raw, str(e)))
108
+
109
+ return BindResult(bound, errors)
110
+
111
+ def _resolve_dataclass_fields(self, expected: type, ctx: BindContext[T]) -> dict[str, Any]:
112
+ """Gather dataclass fields from params."""
113
+ fields = dataclasses.fields(expected)
114
+ result: dict[str, Any] = {}
115
+
116
+ for field in fields:
117
+ if ctx.params.has(field.name):
118
+ result[field.name] = ctx.params.getlist(field.name)
119
+ elif ctx.payload and field.name in ctx.payload:
120
+ result[field.name] = ctx.payload[field.name]
121
+
122
+ return result
123
+
124
+ def _resolve_raw(self, name: str, ctx: BindContext[T]) -> Any | None:
125
+ """Resolve raw value from params or payload."""
126
+ # Check params first
127
+ if ctx.params.has(name):
128
+ return ctx.params.getlist(name)
129
+
130
+ # Check payload
131
+ if ctx.payload and name in ctx.payload:
132
+ return ctx.payload[name]
133
+
134
+ return None
@@ -0,0 +1,33 @@
1
+ """Binding context for parameter resolution."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING, Any, Generic, Optional, TypeVar
5
+ from urllib.parse import ParseResult
6
+
7
+ if TYPE_CHECKING:
8
+ from pyview.live_socket import LiveViewSocket
9
+
10
+ from .params import Params
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ @dataclass
16
+ class BindContext(Generic[T]):
17
+ """Context provided to the binder for resolving parameter values.
18
+
19
+ Attributes:
20
+ params: Multi-value parameter container (query/path/form merged)
21
+ payload: Event payload dict (for handle_event)
22
+ url: Parsed URL (for handle_params)
23
+ socket: LiveView socket instance
24
+ event: Event name (for handle_event)
25
+ extra: Additional injectable values
26
+ """
27
+
28
+ params: "Params"
29
+ payload: Optional[dict[str, Any]]
30
+ url: Optional[ParseResult]
31
+ socket: Optional["LiveViewSocket[T]"]
32
+ event: Optional[str]
33
+ extra: dict[str, Any] = field(default_factory=dict)