pyloid 0.26.3__py3-none-any.whl → 0.26.4__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/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 web
6
- from aiohttp.web_fileresponse import FileResponse
7
- from typing import Optional
8
- from pathlib import Path
9
- from .utils import get_free_port, is_production
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
- """zero-copy optimized file response class"""
17
-
18
- def __init__(self, path, chunk_size=65536, headers=None):
19
- """
20
- Args:
21
- path: file path
22
- chunk_size: streaming chunk size (64KB default)
23
- headers: additional HTTP headers
24
- """
25
- super().__init__(path, chunk_size=chunk_size, headers=headers)
26
- self._enable_sendfile = True # sendfile() system call activation
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
- """zero-copy optimized static file handler"""
31
-
32
- def __init__(self, directory: str):
33
- self.directory = Path(directory).resolve()
34
- self.chunk_size = 65536 # 64KB chunk
35
-
36
- async def handle_request(self, request):
37
- """HTTP request processing"""
38
- try:
39
- # URL path parsing
40
- path = request.path_qs.split('?')[0] # remove query parameters
41
- if path.endswith('/'):
42
- path += 'index.html'
43
-
44
- # Security: prevent directory traversal attacks
45
- file_path = (self.directory / path.lstrip('/')).resolve()
46
- if not str(file_path).startswith(str(self.directory)):
47
- return web.Response(status=403, text="Forbidden")
48
-
49
- # Check if the file exists
50
- if not file_path.exists() or not file_path.is_file():
51
- return web.Response(status=404, text="File not found")
52
-
53
- # Check the file size and type
54
- stat = file_path.stat()
55
- file_size = stat.st_size
56
- content_type, _ = mimetypes.guess_type(str(file_path))
57
-
58
- # Range request processing (partial download supported)
59
- range_header = request.headers.get('Range')
60
- if range_header:
61
- return await self._handle_range_request(file_path, range_header, file_size, content_type, request)
62
-
63
- if is_production():
64
- cache_control = 'public, max-age=3600' # 1 hour caching
65
- else:
66
- cache_control = 'no-cache'
67
-
68
- # zero-copy file response creation
69
- headers = {
70
- 'Content-Type': content_type or 'application/octet-stream',
71
- 'Content-Length': str(file_size),
72
- 'Accept-Ranges': 'bytes',
73
- 'Cache-Control': cache_control,
74
- 'ETag': f'"{stat.st_mtime}-{file_size}"'
75
- }
76
-
77
- # ETag based cache check
78
- if_none_match = request.headers.get('If-None-Match')
79
- if if_none_match == headers['ETag']:
80
- return web.Response(status=304, headers=headers)
81
-
82
- # zero-copy response return (sendfile used)
83
- return ZeroCopyFileResponse(
84
- path=file_path,
85
- chunk_size=self.chunk_size,
86
- headers=headers
87
- )
88
-
89
- except Exception as e:
90
- return web.Response(status=500, text=f"Internal Server Error: {str(e)}")
91
-
92
- async def _handle_range_request(self, file_path, range_header, file_size, content_type, request):
93
- """Range request processing (partial download)"""
94
- try:
95
- # Range header parsing: bytes=start-end
96
- range_match = range_header.replace('bytes=', '').split('-')
97
- start = int(range_match[0]) if range_match[0] else 0
98
- end = int(range_match[1]) if range_match[1] else file_size - 1
99
-
100
- # Range validation
101
- if start >= file_size or end >= file_size or start > end:
102
- return web.Response(
103
- status=416, # Range Not Satisfiable
104
- headers={'Content-Range': f'bytes */{file_size}'}
105
- )
106
-
107
- content_length = end - start + 1
108
-
109
- headers = {
110
- 'Content-Type': content_type or 'application/octet-stream',
111
- 'Content-Length': str(content_length),
112
- 'Content-Range': f'bytes {start}-{end}/{file_size}',
113
- 'Accept-Ranges': 'bytes'
114
- }
115
-
116
- # zero-copy partial response
117
- response = web.StreamResponse(status=206, headers=headers)
118
- await response.prepare(request)
119
-
120
- # aiofiles to async zero-copy transfer
121
- async with aiofiles.open(file_path, 'rb') as f:
122
- await f.seek(start)
123
- remaining = content_length
124
-
125
- while remaining > 0:
126
- chunk_size = min(self.chunk_size, remaining)
127
- chunk = await f.read(chunk_size)
128
- if not chunk:
129
- break
130
- await response.write(chunk)
131
- remaining -= len(chunk)
132
-
133
- await response.write_eof()
134
- return response
135
-
136
- except Exception as e:
137
- return web.Response(status=500, text=f"Range Request Error: {str(e)}")
138
-
139
-
140
- async def start_zero_copy_server(directory: str, port: int):
141
- """zero-copy optimized server starts"""
142
- handler = ZeroCopyStaticHandler(directory)
143
-
144
- # aiohttp app creation
145
- app = web.Application()
146
- app.router.add_route('GET', '/{path:.*}', handler.handle_request)
147
-
148
- # server configuration optimization
149
- runner = web.AppRunner(
150
- app,
151
- access_log=None, # access log disabled (performance improvement)
152
- keepalive_timeout=75, # Keep-Alive timeout
153
- client_timeout=600, # client timeout
154
- )
155
-
156
- await runner.setup()
157
-
158
- site = web.TCPSite(
159
- runner,
160
- '127.0.0.1',
161
- port,
162
- backlog=1024, # connection backlog size
163
- reuse_address=True # port reuse only
164
- )
165
-
166
- await site.start()
167
-
168
- return runner, site
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
- directory: str,
173
- port: Optional[int] = None,
251
+ directory: str,
252
+ port: Optional[int] = None,
174
253
  ) -> str:
175
- """
176
- zero-copy optimized static file server starts.
177
-
178
- Args
179
- ----
180
- directory (str): Path to the static file directory to serve
181
- port (int, optional): Server port (default: None - will use a random free port)
182
-
183
- Returns
184
- -------
185
- str
186
- URL of the started server
187
-
188
- Examples
189
- --------
190
- ```python
191
- from pyloid import Pyloid
192
- from pyloid.serve import pyloid_serve
193
-
194
- app = Pyloid("Pyloid-App")
195
- url = pyloid_serve("dist")
196
- window = app.create_window("Pyloid-App")
197
- window.load_url(url)
198
- window.show_and_focus()
199
- ```
200
- """
201
-
202
- if port is None:
203
- port = get_free_port()
204
-
205
- def run_zero_copy_server():
206
- """run async server in a separate thread"""
207
- loop = asyncio.new_event_loop()
208
- asyncio.set_event_loop(loop)
209
-
210
- try:
211
- runner, site = loop.run_until_complete(
212
- start_zero_copy_server(directory, port)
213
- )
214
-
215
- print(f"🚀 Zero-copy frontend server started on http://127.0.0.1:{port}")
216
- print(f"📁 Serving directory: {directory}")
217
- print(f"⚡ Features: sendfile, Range requests, ETag caching")
218
-
219
- # wait until the server starts
220
- loop.run_forever()
221
-
222
- except Exception as e:
223
- print(f"Zero-copy server error: {e}")
224
- finally:
225
- loop.close()
226
-
227
- # start server in a daemon thread
228
- thread = threading.Thread(target=run_zero_copy_server, daemon=True)
229
- thread.start()
230
-
231
- return f"http://127.0.0.1:{port}"
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}'