pyloid 0.26.3__py3-none-any.whl → 0.26.5__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.
- pyloid/__init__.py +4 -2
- pyloid/autostart.py +131 -68
- pyloid/base_ipc/base.py +395 -0
- pyloid/{js_api → base_ipc}/event_api.py +1 -1
- pyloid/browser_window.py +3771 -3006
- pyloid/custom/titlebar.py +153 -90
- pyloid/filewatcher.py +191 -161
- pyloid/ipc.py +142 -0
- pyloid/monitor.py +1117 -920
- pyloid/pyloid.py +3396 -2671
- pyloid/rpc.py +734 -527
- pyloid/serve.py +306 -214
- pyloid/store.py +253 -175
- pyloid/thread_pool.py +643 -496
- pyloid/timer.py +424 -305
- pyloid/tray.py +61 -45
- pyloid/url_interceptor.py +37 -20
- pyloid/utils.py +243 -193
- {pyloid-0.26.3.dist-info → pyloid-0.26.5.dist-info}/METADATA +1 -1
- pyloid-0.26.5.dist-info/RECORD +23 -0
- pyloid/api.py +0 -104
- pyloid/js_api/base.py +0 -259
- pyloid-0.26.3.dist-info/RECORD +0 -23
- /pyloid/{js_api → base_ipc}/window_api.py +0 -0
- {pyloid-0.26.3.dist-info → pyloid-0.26.5.dist-info}/LICENSE +0 -0
- {pyloid-0.26.3.dist-info → pyloid-0.26.5.dist-info}/WHEEL +0 -0
pyloid/serve.py
CHANGED
|
@@ -2,230 +2,322 @@ import threading
|
|
|
2
2
|
import asyncio
|
|
3
3
|
import mimetypes
|
|
4
4
|
import aiofiles
|
|
5
|
-
from aiohttp import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
from
|
|
9
|
-
|
|
5
|
+
from aiohttp import (
|
|
6
|
+
web,
|
|
7
|
+
)
|
|
8
|
+
from aiohttp.web_fileresponse import (
|
|
9
|
+
FileResponse,
|
|
10
|
+
)
|
|
11
|
+
from typing import (
|
|
12
|
+
Optional,
|
|
13
|
+
)
|
|
14
|
+
from pathlib import (
|
|
15
|
+
Path,
|
|
16
|
+
)
|
|
17
|
+
from .utils import (
|
|
18
|
+
get_free_port,
|
|
19
|
+
is_production,
|
|
20
|
+
)
|
|
10
21
|
import logging
|
|
11
22
|
|
|
12
23
|
logging.getLogger('aiohttp').setLevel(logging.WARNING)
|
|
13
24
|
|
|
14
25
|
|
|
15
26
|
class ZeroCopyFileResponse(FileResponse):
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
"""zero-copy optimized file response class"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
path,
|
|
32
|
+
chunk_size=65536,
|
|
33
|
+
headers=None,
|
|
34
|
+
):
|
|
35
|
+
"""
|
|
36
|
+
Args:
|
|
37
|
+
path: file path
|
|
38
|
+
chunk_size: streaming chunk size (64KB default)
|
|
39
|
+
headers: additional HTTP headers
|
|
40
|
+
"""
|
|
41
|
+
super().__init__(
|
|
42
|
+
path,
|
|
43
|
+
chunk_size=chunk_size,
|
|
44
|
+
headers=headers,
|
|
45
|
+
)
|
|
46
|
+
self._enable_sendfile = True # sendfile() system call activation
|
|
27
47
|
|
|
28
48
|
|
|
29
49
|
class ZeroCopyStaticHandler:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
50
|
+
"""zero-copy optimized static file handler"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
directory: str,
|
|
55
|
+
):
|
|
56
|
+
self.directory = Path(directory).resolve()
|
|
57
|
+
self.chunk_size = 65536 # 64KB chunk
|
|
58
|
+
|
|
59
|
+
async def handle_request(
|
|
60
|
+
self,
|
|
61
|
+
request,
|
|
62
|
+
):
|
|
63
|
+
"""HTTP request processing"""
|
|
64
|
+
try:
|
|
65
|
+
# URL path parsing
|
|
66
|
+
path = request.path_qs.split('?')[0] # remove query parameters
|
|
67
|
+
if path.endswith('/'):
|
|
68
|
+
path += 'index.html'
|
|
69
|
+
|
|
70
|
+
# Security: prevent directory traversal attacks
|
|
71
|
+
file_path = (self.directory / path.lstrip('/')).resolve()
|
|
72
|
+
if not str(file_path).startswith(str(self.directory)):
|
|
73
|
+
return web.Response(
|
|
74
|
+
status=403,
|
|
75
|
+
text='Forbidden',
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Check if the file exists
|
|
79
|
+
if not file_path.exists() or not file_path.is_file():
|
|
80
|
+
return web.Response(
|
|
81
|
+
status=404,
|
|
82
|
+
text='File not found',
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Check the file size and type
|
|
86
|
+
stat = file_path.stat()
|
|
87
|
+
file_size = stat.st_size
|
|
88
|
+
(
|
|
89
|
+
content_type,
|
|
90
|
+
_,
|
|
91
|
+
) = mimetypes.guess_type(str(file_path))
|
|
92
|
+
|
|
93
|
+
# Range request processing (partial download supported)
|
|
94
|
+
range_header = request.headers.get('Range')
|
|
95
|
+
if range_header:
|
|
96
|
+
return await self._handle_range_request(
|
|
97
|
+
file_path,
|
|
98
|
+
range_header,
|
|
99
|
+
file_size,
|
|
100
|
+
content_type,
|
|
101
|
+
request,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if is_production():
|
|
105
|
+
cache_control = 'public, max-age=3600' # 1 hour caching
|
|
106
|
+
else:
|
|
107
|
+
cache_control = 'no-cache'
|
|
108
|
+
|
|
109
|
+
# zero-copy file response creation
|
|
110
|
+
headers = {
|
|
111
|
+
'Content-Type': content_type or 'application/octet-stream',
|
|
112
|
+
'Content-Length': str(file_size),
|
|
113
|
+
'Accept-Ranges': 'bytes',
|
|
114
|
+
'Cache-Control': cache_control,
|
|
115
|
+
'ETag': f'"{stat.st_mtime}-{file_size}"',
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# ETag based cache check
|
|
119
|
+
if_none_match = request.headers.get('If-None-Match')
|
|
120
|
+
if if_none_match == headers['ETag']:
|
|
121
|
+
return web.Response(
|
|
122
|
+
status=304,
|
|
123
|
+
headers=headers,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# zero-copy response return (sendfile used)
|
|
127
|
+
return ZeroCopyFileResponse(
|
|
128
|
+
path=file_path,
|
|
129
|
+
chunk_size=self.chunk_size,
|
|
130
|
+
headers=headers,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
except Exception as e:
|
|
134
|
+
return web.Response(
|
|
135
|
+
status=500,
|
|
136
|
+
text=f'Internal Server Error: {str(e)}',
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
async def _handle_range_request(
|
|
140
|
+
self,
|
|
141
|
+
file_path,
|
|
142
|
+
range_header,
|
|
143
|
+
file_size,
|
|
144
|
+
content_type,
|
|
145
|
+
request,
|
|
146
|
+
):
|
|
147
|
+
"""Range request processing (partial download)"""
|
|
148
|
+
try:
|
|
149
|
+
# Range header parsing: bytes=start-end
|
|
150
|
+
range_match = range_header.replace(
|
|
151
|
+
'bytes=',
|
|
152
|
+
'',
|
|
153
|
+
).split('-')
|
|
154
|
+
start = int(range_match[0]) if range_match[0] else 0
|
|
155
|
+
end = int(range_match[1]) if range_match[1] else file_size - 1
|
|
156
|
+
|
|
157
|
+
# Range validation
|
|
158
|
+
if start >= file_size or end >= file_size or start > end:
|
|
159
|
+
return web.Response(
|
|
160
|
+
status=416, # Range Not Satisfiable
|
|
161
|
+
headers={'Content-Range': f'bytes */{file_size}'},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
content_length = end - start + 1
|
|
165
|
+
|
|
166
|
+
headers = {
|
|
167
|
+
'Content-Type': content_type or 'application/octet-stream',
|
|
168
|
+
'Content-Length': str(content_length),
|
|
169
|
+
'Content-Range': f'bytes {start}-{end}/{file_size}',
|
|
170
|
+
'Accept-Ranges': 'bytes',
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# zero-copy partial response
|
|
174
|
+
response = web.StreamResponse(
|
|
175
|
+
status=206,
|
|
176
|
+
headers=headers,
|
|
177
|
+
)
|
|
178
|
+
await response.prepare(request)
|
|
179
|
+
|
|
180
|
+
# aiofiles to async zero-copy transfer
|
|
181
|
+
async with aiofiles.open(
|
|
182
|
+
file_path,
|
|
183
|
+
'rb',
|
|
184
|
+
) as f:
|
|
185
|
+
await f.seek(start)
|
|
186
|
+
remaining = content_length
|
|
187
|
+
|
|
188
|
+
while remaining > 0:
|
|
189
|
+
chunk_size = min(
|
|
190
|
+
self.chunk_size,
|
|
191
|
+
remaining,
|
|
192
|
+
)
|
|
193
|
+
chunk = await f.read(chunk_size)
|
|
194
|
+
if not chunk:
|
|
195
|
+
break
|
|
196
|
+
await response.write(chunk)
|
|
197
|
+
remaining -= len(chunk)
|
|
198
|
+
|
|
199
|
+
await response.write_eof()
|
|
200
|
+
return response
|
|
201
|
+
|
|
202
|
+
except Exception as e:
|
|
203
|
+
return web.Response(
|
|
204
|
+
status=500,
|
|
205
|
+
text=f'Range Request Error: {str(e)}',
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def start_zero_copy_server(
|
|
210
|
+
directory: str,
|
|
211
|
+
port: int,
|
|
212
|
+
):
|
|
213
|
+
"""zero-copy optimized server starts"""
|
|
214
|
+
handler = ZeroCopyStaticHandler(directory)
|
|
215
|
+
|
|
216
|
+
# aiohttp app creation
|
|
217
|
+
app = web.Application()
|
|
218
|
+
app.router.add_route(
|
|
219
|
+
'GET',
|
|
220
|
+
'/{path:.*}',
|
|
221
|
+
handler.handle_request,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# server configuration optimization
|
|
225
|
+
runner = web.AppRunner(
|
|
226
|
+
app,
|
|
227
|
+
access_log=None, # access log disabled (performance improvement)
|
|
228
|
+
keepalive_timeout=75, # Keep-Alive timeout
|
|
229
|
+
client_timeout=600, # client timeout
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
await runner.setup()
|
|
233
|
+
|
|
234
|
+
site = web.TCPSite(
|
|
235
|
+
runner,
|
|
236
|
+
'127.0.0.1',
|
|
237
|
+
port,
|
|
238
|
+
backlog=1024, # connection backlog size
|
|
239
|
+
reuse_address=True, # port reuse only
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
await site.start()
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
runner,
|
|
246
|
+
site,
|
|
247
|
+
)
|
|
169
248
|
|
|
170
249
|
|
|
171
250
|
def pyloid_serve(
|
|
172
|
-
|
|
173
|
-
|
|
251
|
+
directory: str,
|
|
252
|
+
port: Optional[int] = None,
|
|
174
253
|
) -> str:
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
254
|
+
"""
|
|
255
|
+
zero-copy optimized static file server starts.
|
|
256
|
+
|
|
257
|
+
Args
|
|
258
|
+
----
|
|
259
|
+
directory (str): Path to the static file directory to serve
|
|
260
|
+
port (int, optional): Server port (default: None - will use a random free port)
|
|
261
|
+
|
|
262
|
+
Returns
|
|
263
|
+
-------
|
|
264
|
+
str
|
|
265
|
+
URL of the started server
|
|
266
|
+
|
|
267
|
+
Examples
|
|
268
|
+
--------
|
|
269
|
+
```python
|
|
270
|
+
from pyloid import (
|
|
271
|
+
Pyloid,
|
|
272
|
+
)
|
|
273
|
+
from pyloid.serve import (
|
|
274
|
+
pyloid_serve,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
app = Pyloid('Pyloid-App')
|
|
278
|
+
url = pyloid_serve('dist')
|
|
279
|
+
window = app.create_window('Pyloid-App')
|
|
280
|
+
window.load_url(url)
|
|
281
|
+
window.show_and_focus()
|
|
282
|
+
```
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
if port is None:
|
|
286
|
+
port = get_free_port()
|
|
287
|
+
|
|
288
|
+
def run_zero_copy_server():
|
|
289
|
+
"""run async server in a separate thread"""
|
|
290
|
+
loop = asyncio.new_event_loop()
|
|
291
|
+
asyncio.set_event_loop(loop)
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
(
|
|
295
|
+
runner,
|
|
296
|
+
site,
|
|
297
|
+
) = loop.run_until_complete(
|
|
298
|
+
start_zero_copy_server(
|
|
299
|
+
directory,
|
|
300
|
+
port,
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
print(f'🚀 Zero-copy frontend server started on http://127.0.0.1:{port}')
|
|
305
|
+
print(f'📁 Serving directory: {directory}')
|
|
306
|
+
print(f'⚡ Features: sendfile, Range requests, ETag caching')
|
|
307
|
+
|
|
308
|
+
# wait until the server starts
|
|
309
|
+
loop.run_forever()
|
|
310
|
+
|
|
311
|
+
except Exception as e:
|
|
312
|
+
print(f'Zero-copy server error: {e}')
|
|
313
|
+
finally:
|
|
314
|
+
loop.close()
|
|
315
|
+
|
|
316
|
+
# start server in a daemon thread
|
|
317
|
+
thread = threading.Thread(
|
|
318
|
+
target=run_zero_copy_server,
|
|
319
|
+
daemon=True,
|
|
320
|
+
)
|
|
321
|
+
thread.start()
|
|
322
|
+
|
|
323
|
+
return f'http://127.0.0.1:{port}'
|