pyloid 0.24.7__tar.gz → 0.24.9__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pyloid
3
- Version: 0.24.7
3
+ Version: 0.24.9
4
4
  Summary:
5
5
  Author: aesthetics-of-record
6
6
  Author-email: 111675679+aesthetics-of-record@users.noreply.github.com
@@ -11,10 +11,11 @@ Classifier: Programming Language :: Python :: 3.10
11
11
  Classifier: Programming Language :: Python :: 3.11
12
12
  Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Dist: aiofiles (>=24.1.0,<25.0.0)
14
15
  Requires-Dist: aiohttp-cors (>=0.8.1,<0.9.0)
15
16
  Requires-Dist: pickledb (>=1.3.2,<2.0.0)
16
17
  Requires-Dist: platformdirs (>=4.3.7,<5.0.0)
17
- Requires-Dist: pyside6 (==6.8.3)
18
+ Requires-Dist: pyside6 (==6.9.2)
18
19
  Description-Content-Type: text/markdown
19
20
 
20
21
  <h1 style="text-align: center; font-size: 200px; font-weight: 500;">
@@ -23,7 +24,7 @@ Description-Content-Type: text/markdown
23
24
 
24
25
  ![example image](example.png)
25
26
 
26
- <h2 align="center" style="font-size: 28px;"><b>Pyloid: Thread-Safe Desktop Apps—Unified with Any Frontend and Python Technology</b></h2>
27
+ <h2 align="center" style="font-size: 28px;"><b>Pyloid: Electron for Python Developer Web-based desktop app framework</b></h2>
27
28
 
28
29
  ## 💡 Key Features
29
30
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  ![example image](example.png)
6
6
 
7
- <h2 align="center" style="font-size: 28px;"><b>Pyloid: Thread-Safe Desktop Apps—Unified with Any Frontend and Python Technology</b></h2>
7
+ <h2 align="center" style="font-size: 28px;"><b>Pyloid: Electron for Python Developer Web-based desktop app framework</b></h2>
8
8
 
9
9
  ## 💡 Key Features
10
10
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pyloid"
3
- version = "0.24.7"
3
+ version = "0.24.9"
4
4
  description = ""
5
5
  authors = ["aesthetics-of-record <111675679+aesthetics-of-record@users.noreply.github.com>"]
6
6
  readme = "README.md"
@@ -10,10 +10,11 @@ packages = [
10
10
 
11
11
  [tool.poetry.dependencies]
12
12
  python = ">=3.9,<3.14"
13
- pyside6 = "6.8.3"
13
+ pyside6 = "6.9.2"
14
14
  platformdirs = "^4.3.7"
15
15
  pickledb = "^1.3.2"
16
16
  aiohttp-cors = "^0.8.1"
17
+ aiofiles = "^24.1.0"
17
18
 
18
19
  [build-system]
19
20
  requires = ["poetry-core"]
@@ -82,7 +82,7 @@ class CustomWebPage(QWebEnginePage):
82
82
  # return
83
83
  # print("Desktop media request received:", request)
84
84
 
85
- # # 사용 가능한 화면 목록 확인
85
+ # # check the available screen list
86
86
  # screens_model = request.screensModel()
87
87
  # print("\n=== Available Screens ===")
88
88
  # for i in range(screens_model.rowCount()):
@@ -90,7 +90,7 @@ class CustomWebPage(QWebEnginePage):
90
90
  # screen_name = screens_model.data(screen_index)
91
91
  # print(f"Screen {i}: {screen_name}")
92
92
 
93
- # # 사용 가능한 목록 확인
93
+ # # check the available window list
94
94
  # windows_model = request.windowsModel()
95
95
  # print("\n=== Available Windows ===")
96
96
  # for i in range(windows_model.rowCount()):
@@ -102,7 +102,7 @@ class CustomWebPage(QWebEnginePage):
102
102
 
103
103
  # # interceptor ( navigation request )
104
104
  # def acceptNavigationRequest(self, url, navigation_type, is_main_frame):
105
- # """네비게이션 요청을 처리하는 메서드"""
105
+ # """method to handle navigation requests"""
106
106
  # print(f"Navigation Request - URL: {url.toString()}")
107
107
  # print(f"Navigation Type: {navigation_type}")
108
108
  # print(f"Is Main Frame: {is_main_frame}")
@@ -228,10 +228,10 @@ class CustomWebEngineView(QWebEngineView):
228
228
 
229
229
  def eventFilter(self, source, event):
230
230
  if self.focusProxy() is source:
231
- # 리사이징 영역에 있을 때는 모든 클릭 이벤트를 가로채기
231
+ # when in the resize area, all click events are intercepted
232
232
  if self.is_in_resize_area and event.type() == QEvent.MouseButtonPress:
233
233
  self.mouse_press_event(event)
234
- return True # 이벤트를 소비하여 웹뷰로 전달되지 않도록
234
+ return True # consume the event so it is not passed to the web view
235
235
 
236
236
  if event.type() == QEvent.MouseButtonPress:
237
237
  self.mouse_press_event(event)
@@ -358,18 +358,18 @@ class _BrowserWindow:
358
358
  self.close_on_load = True
359
359
  self.splash_screen = None
360
360
  ###########################################################################################
361
- # RPC 서버가 없으면 추가하지 않음
361
+ # if the RPC server is not present, do not add it
362
362
  if not self.rpc:
363
363
  return
364
364
 
365
365
  self.rpc.pyloid = self.app.pyloid_wrapper
366
366
  # self.rpc.window = self.window_wrapper
367
367
 
368
- # RPC 서버 중복 방지
368
+ # prevent duplicate RPC servers
369
369
  if self.rpc in self.app.rpc_servers:
370
370
  return
371
371
 
372
- # RPC 서버 추가
372
+ # add the RPC server
373
373
  self.app.rpc_servers.add(self.rpc)
374
374
 
375
375
  # Start unique RPC servers
@@ -405,7 +405,7 @@ class _BrowserWindow:
405
405
  central_widget.setLayout(layout)
406
406
  self._window.setCentralWidget(central_widget)
407
407
 
408
- # Add properties for window movement
408
+ # add properties for window movement
409
409
  self._window.moving = False
410
410
  self._window.offset = QPoint()
411
411
  else:
@@ -490,6 +490,9 @@ class _BrowserWindow:
490
490
  self.web_view.settings().setAttribute(
491
491
  QWebEngineSettings.WebAttribute.WebRTCPublicInterfacesOnly, False
492
492
  )
493
+ self.web_view.settings().setAttribute(
494
+ QWebEngineSettings.WebAttribute.WebGLEnabled, True
495
+ )
493
496
 
494
497
  # Set icon
495
498
  if self.app.icon:
@@ -540,10 +543,10 @@ class _BrowserWindow:
540
543
  # Set F12 shortcut
541
544
  self.set_dev_tools(self.dev_tools)
542
545
 
543
- # 프로필 가져오기 인터셉터 설정
546
+ # get the profile and set the interceptor
544
547
  profile = self.web_view.page().profile()
545
548
 
546
- # # 기존 인터셉터가 있다면 제거
549
+ # # if the existing interceptor is present, remove it
547
550
  # if self.interceptor:
548
551
  # profile.setUrlRequestInterceptor(None)
549
552
 
@@ -562,15 +565,15 @@ class _BrowserWindow:
562
565
  new QWebChannel(qt.webChannelTransport, function (channel) {
563
566
  window.pyloid = {
564
567
  EventAPI: {
565
- _listeners: {}, // 콜백 함수들을 저장할 객체
568
+ _listeners: {}, // object to store the callback functions
566
569
 
567
570
  listen: function(eventName, callback) {
568
- // 이벤트에 대한 콜백 배열이 없다면 생성
571
+ // if the callback array for the event is not present, create it
569
572
  if (!this._listeners[eventName]) {
570
573
  this._listeners[eventName] = [];
571
574
  }
572
575
 
573
- // 콜백 함수 저장
576
+ // save the callback function
574
577
  this._listeners[eventName].push(callback);
575
578
 
576
579
  document.addEventListener(eventName, function(event) {
@@ -585,12 +588,12 @@ class _BrowserWindow:
585
588
  },
586
589
 
587
590
  unlisten: function(eventName) {
588
- // 해당 이벤트의 모든 리스너 제거
591
+ // remove all listeners for the event
589
592
  if (this._listeners[eventName]) {
590
593
  this._listeners[eventName].forEach(callback => {
591
594
  document.removeEventListener(eventName, callback);
592
595
  });
593
- // 저장된 콜백 제거
596
+ // remove the saved callback
594
597
  delete this._listeners[eventName];
595
598
  }
596
599
  }
@@ -0,0 +1,232 @@
1
+ import threading
2
+ import asyncio
3
+ import mimetypes
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
10
+ import logging
11
+
12
+ # 로깅 설정
13
+ logging.getLogger('aiohttp').setLevel(logging.WARNING)
14
+
15
+
16
+ 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
28
+
29
+
30
+ 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
170
+
171
+
172
+ def pyloid_serve(
173
+ directory: str,
174
+ port: Optional[int] = None,
175
+ ) -> 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}"
@@ -1,56 +0,0 @@
1
- import threading
2
- from http.server import HTTPServer, SimpleHTTPRequestHandler
3
- from typing import Optional
4
- from .utils import get_free_port
5
-
6
- class CustomStaticHandler(SimpleHTTPRequestHandler):
7
- def __init__(self, *args, directory=None, **kwargs):
8
- super().__init__(*args, directory=directory, **kwargs)
9
-
10
- def log_message(self, format, *args):
11
- pass
12
-
13
- def pyloid_serve(
14
- directory: str,
15
- port: Optional[int] = None,
16
- ) -> str:
17
- """
18
- Static file server starts.
19
-
20
- Args
21
- ----
22
- directory (str): Path to the static file directory to serve
23
- port (int, optional): Server port (default: None - will use a random free port)
24
-
25
- Returns
26
- -------
27
- str
28
- URL of the started server
29
-
30
- Examples
31
- --------
32
- ```python
33
- from pyloid import Pyloid
34
- from pyloid.serve import pyloid_serve
35
-
36
- app = Pyloid("Pyloid-App")
37
- url = pyloid_serve("dist")
38
- window = app.create_window("Pyloid-App")
39
- window.load_url(url)
40
- window.show_and_focus()
41
- ```
42
- """
43
-
44
- if port is None:
45
- port = get_free_port()
46
-
47
- handler = lambda *args: CustomStaticHandler(*args, directory=directory)
48
- server = HTTPServer(("127.0.0.1", port), handler)
49
-
50
- thread = threading.Thread(
51
- target=server.serve_forever,
52
- daemon=True
53
- )
54
- thread.start()
55
-
56
- return f"http://127.0.0.1:{port}"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes