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