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.
- pyview/__init__.py +16 -6
- pyview/assets/js/app.js +1 -0
- pyview/assets/js/uploaders.js +221 -0
- pyview/assets/package-lock.json +16 -14
- pyview/assets/package.json +2 -2
- pyview/async_stream_runner.py +2 -1
- pyview/auth/__init__.py +3 -1
- pyview/auth/provider.py +6 -6
- pyview/auth/required.py +7 -10
- pyview/binding/__init__.py +47 -0
- pyview/binding/binder.py +134 -0
- pyview/binding/context.py +33 -0
- pyview/binding/converters.py +191 -0
- pyview/binding/helpers.py +78 -0
- pyview/binding/injectables.py +119 -0
- pyview/binding/params.py +105 -0
- pyview/binding/result.py +32 -0
- pyview/changesets/__init__.py +2 -0
- pyview/changesets/changesets.py +8 -3
- pyview/cli/commands/create_view.py +4 -3
- pyview/cli/main.py +1 -1
- pyview/components/__init__.py +72 -0
- pyview/components/base.py +212 -0
- pyview/components/lifecycle.py +85 -0
- pyview/components/manager.py +366 -0
- pyview/components/renderer.py +14 -0
- pyview/components/slots.py +73 -0
- pyview/csrf.py +4 -2
- pyview/events/AutoEventDispatch.py +98 -0
- pyview/events/BaseEventHandler.py +51 -8
- pyview/events/__init__.py +2 -1
- pyview/instrumentation/__init__.py +3 -3
- pyview/instrumentation/interfaces.py +57 -33
- pyview/instrumentation/noop.py +21 -18
- pyview/js.py +20 -23
- pyview/live_routes.py +5 -3
- pyview/live_socket.py +167 -44
- pyview/live_view.py +24 -12
- pyview/meta.py +14 -2
- pyview/phx_message.py +7 -8
- pyview/playground/__init__.py +10 -0
- pyview/playground/builder.py +118 -0
- pyview/playground/favicon.py +39 -0
- pyview/pyview.py +54 -20
- pyview/session.py +2 -0
- pyview/static/assets/app.js +2088 -806
- pyview/static/assets/uploaders.js +221 -0
- pyview/stream.py +308 -0
- pyview/template/__init__.py +11 -1
- pyview/template/live_template.py +12 -8
- pyview/template/live_view_template.py +338 -0
- pyview/template/render_diff.py +33 -7
- pyview/template/root_template.py +21 -9
- pyview/template/serializer.py +2 -5
- pyview/template/template_view.py +170 -0
- pyview/template/utils.py +3 -2
- pyview/uploads.py +344 -55
- pyview/vendor/flet/pubsub/__init__.py +3 -1
- pyview/vendor/flet/pubsub/pub_sub.py +10 -18
- pyview/vendor/ibis/__init__.py +3 -7
- pyview/vendor/ibis/compiler.py +25 -32
- pyview/vendor/ibis/context.py +13 -15
- pyview/vendor/ibis/errors.py +0 -6
- pyview/vendor/ibis/filters.py +70 -76
- pyview/vendor/ibis/loaders.py +6 -7
- pyview/vendor/ibis/nodes.py +40 -42
- pyview/vendor/ibis/template.py +4 -5
- pyview/vendor/ibis/tree.py +62 -3
- pyview/vendor/ibis/utils.py +14 -15
- pyview/ws_handler.py +116 -86
- {pyview_web-0.3.0.dist-info → pyview_web-0.8.0a2.dist-info}/METADATA +39 -33
- pyview_web-0.8.0a2.dist-info/RECORD +80 -0
- pyview_web-0.8.0a2.dist-info/WHEEL +4 -0
- pyview_web-0.8.0a2.dist-info/entry_points.txt +3 -0
- pyview_web-0.3.0.dist-info/LICENSE +0 -21
- pyview_web-0.3.0.dist-info/RECORD +0 -58
- pyview_web-0.3.0.dist-info/WHEEL +0 -4
- pyview_web-0.3.0.dist-info/entry_points.txt +0 -3
pyview/__init__.py
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
from pyview.
|
|
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.
|
|
9
|
-
from pyview.
|
|
10
|
-
from pyview.pyview import RootTemplateContext,
|
|
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
|
@@ -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
|
+
}
|
pyview/assets/package-lock.json
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
"": {
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"nprogress": "^0.2.0",
|
|
9
|
-
"phoenix": "^1.7.0
|
|
9
|
+
"phoenix": "^1.7.0",
|
|
10
10
|
"phoenix_html": "^3.2.0",
|
|
11
|
-
"phoenix_live_view": "^0.
|
|
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.
|
|
21
|
-
"resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.
|
|
22
|
-
"integrity": "sha512-
|
|
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.
|
|
31
|
-
"resolved": "https://registry.npmjs.org/phoenix_live_view/-/phoenix_live_view-0.
|
|
32
|
-
"integrity": "sha512-
|
|
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.
|
|
43
|
-
"resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.
|
|
44
|
-
"integrity": "sha512-
|
|
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.
|
|
53
|
-
"resolved": "https://registry.npmjs.org/phoenix_live_view/-/phoenix_live_view-0.
|
|
54
|
-
"integrity": "sha512-
|
|
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
|
}
|
pyview/assets/package.json
CHANGED
pyview/async_stream_runner.py
CHANGED
pyview/auth/__init__.py
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
|
+
from .provider import AllowAllAuthProvider, AuthProvider, AuthProviderFactory
|
|
1
2
|
from .required import requires
|
|
2
|
-
|
|
3
|
+
|
|
4
|
+
__all__ = ["AllowAllAuthProvider", "AuthProvider", "AuthProviderFactory", "requires"]
|
pyview/auth/provider.py
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
from typing import Protocol, TypeVar
|
|
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
|
-
|
|
5
|
-
from .provider import AuthProvider, AuthProviderFactory
|
|
8
|
+
|
|
6
9
|
from pyview import LiveView
|
|
7
|
-
import sys
|
|
8
10
|
|
|
9
|
-
|
|
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
|
+
]
|
pyview/binding/binder.py
ADDED
|
@@ -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)
|