pythonista-wkapp 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 M4nw3l
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: pythonista-wkapp
3
+ Version: 0.1.0
4
+ Summary: WKApp - A modern HTML5 UI framework for building iOS apps with Pythonista 3 and WebKit
5
+ Author-email: M4nw3l <63550247+M4nw3l@users.noreply.github.com>
6
+ Requires-Python: >= 3.10
7
+ Description-Content-Type: text/markdown
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: iOS
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
14
+ Classifier: Topic :: Software Development :: User Interfaces
15
+ License-File: LICENSE
16
+ Requires-Dist: bottle
17
+ Requires-Dist: Mako
18
+ Project-URL: Homepage, https://github.com/m4nw3l/pythonista-wkapp
19
+
20
+ # pythonista-wkapp
21
+ ## WKApp - A modern HTML5 UI framework for building iOS apps with Pythonista 3 and WebKit
22
+
23
+ WKApp is a modern, lightweight and minimal application framework for developing Python applications with desktop-class HTML 5 based user interfaces on Apple iOS devices in the [Pythonista 3 IDE](https://omz-software.com/pythonista/) for iOS.
24
+ It is a comprehensive and fully featured alternative to Pythonista's native app `ui` library, allowing user interfaces to be created with standard web technologies. Using powerful Python templating for dynamic HTML5/CSS/JavaScript views rendering with Mako, simple instanced view state binding supporting auto-wiring GET/POST values and two way Python/JavaScript interop via JSON over thread-safe message handlers.
25
+
26
+ Creating user interfaces for Python Apps in Pythonista becomes as simple as adding new .html view template files into your project. Which are then served locally from a Bottle.py HTTP/WSGI server and shown in a native WKWebView browser component. WKApp supports creating user interfaces with anything supported by modern web browsers using HTML5, CSS, JavaScript or even WebAssembly and browser based 2D/3D graphics!
27
+
28
+
29
+ ## Getting started
30
+ Pip is the recommended installation method for WKApp.
31
+ Install [StaSh](https://github.com/ywangd/stash) for Pythonista 3 using the installation instructions from its README first.
32
+ Then install the `pythonista-wkapp` module with pip.
33
+ ```
34
+ pip install pythonista-wkapp
35
+ ```
36
+ Alternatively you can also clone or download a zip of this repository and copy/extract the project files into `site-packages/wkapp`.
37
+
38
+ To create an app, simply add a folder somewhere containing an `app.py` file as follows.
39
+
40
+ ```python
41
+
42
+ from wkapp import *
43
+
44
+ app = WKApp(__file__)
45
+ app.run()
46
+
47
+ ```
48
+
49
+ Run this file and you should see a fullscreen browser control and the main view index.html placeholder page shown.
50
+ You can then start making your own views straight away!
51
+
52
+ To replace the initial main view / index.html placeholder page:
53
+ - Create a `views` folder in the same folder as your `app.py` file.
54
+ - Create a file `views/index.html`.
55
+ - Then add your html and setup a `view_class` mixin definition like as below.
56
+ - An instance of this class will be maintained with your view which can be used to store state, bind/manipulate elements, provide functions to be called from Javascript and evaluate Javascript from Python in the view to inspect and alter the DOM or backend state.
57
+
58
+ A simple `views/index.html` view example:
59
+
60
+ ```python
61
+ <%!
62
+
63
+ class ViewClass:
64
+ def on_init(self):
65
+ self.name = ''
66
+
67
+ def view_action(self, text,*args):
68
+ print(text,args)
69
+ self.element('header').set('text',f'hello javascript! text was {text} args were {args}')
70
+
71
+
72
+ view_class = ViewClass
73
+
74
+ %>
75
+ ```
76
+ ```html
77
+ <!-- inherit from the view.html template to render the views content inside the apps customisable base layout and structure -->
78
+ <%inherit file="view.html"/>
79
+ <!-- Your page content goes here -->
80
+ <script type="text/javascript">
81
+ function invoke_view_action() {
82
+ view.invoke('view_action', 'hello python!',
83
+ {pass:'any',json:['compatible'], args:{ints:1}, floats:0.5},
84
+ ['lists',{},1,2.2],
85
+ 'strings',
86
+ 'numbers',1,1.5
87
+ );
88
+ }
89
+ </script>
90
+ <button onclick="invoke_view_action()">Call Python</button>
91
+ <button onclick="app.exit()">Exit Application</button>
92
+ <div>
93
+ <h1 id="header">Hello World!</h1>
94
+ <form method="POST">
95
+ <label>Enter your name:</label>
96
+ <input name="name" type="text" value="${view.name}" />
97
+ <br />
98
+ <input type="submit" value="Submit" />
99
+ </form>
100
+ % if view.name != '':
101
+ <h2> Hello ${view.name}! </h2>
102
+ % endif
103
+ </div>
104
+ ```
105
+ Note: Code above is one file, it is just shown in two parts here for code highlighting purposes.
106
+
107
+ ### Dependencies
108
+ WKApp requires the Pythonista 3 app on iOS to run but otherwise uses a minimal set of dependencies:
109
+
110
+ - [Bottle.py 0.13.4](https://github.com/bottlepy/bottle)
111
+ - [Mako 1.13.10](https://github.com/sqlalchemy/mako)
112
+ - pythonista-wkwebview 1.2 (Bundled)
113
+ - 1.2 is an extended version for WKApp, updated with fixes and new features for using the native WKWebView from UIKit on iOS. Including a WKURLSchemeHandler implementation allowing creating custom url schemes with a single handler in a subclass, simplified javascript handlers threading concerns with a Dispatcher, arbitrary arguments passing from javascript to python via json.
114
+ - 1.1 [Gist (@sbbosco)](https://gist.github.com/sbbosco/1290f59d79c6963e62bb678f0f05b035)
115
+ - 1.0 [Github (@mikaelho)](https://github.com/mikaelho/pythonista-webview)
116
+
117
+ ### Bundled Web frontend libraries:
118
+ The base app html template bundles with it Bootstrap and JQuery to offer a way to just start developing apps rapidly right away straight out of the box.
119
+ However if you prefer other frameworks the `base/app.html` template can also be customised.
120
+ Simply create a copy of `views/base/app.html` from the repository and add it to your `views` folder in the same structure `views/base/app.html`.
121
+ Any base template file may be replaced in this same way.
122
+
123
+ - [Bootstrap 5.3.8](https://getbootstrap.com/docs/5.3/getting-started/introduction/)
124
+ - [JQuery 3.7.1](https://jquery.com)
125
+
@@ -0,0 +1,105 @@
1
+ # pythonista-wkapp
2
+ ## WKApp - A modern HTML5 UI framework for building iOS apps with Pythonista 3 and WebKit
3
+
4
+ WKApp is a modern, lightweight and minimal application framework for developing Python applications with desktop-class HTML 5 based user interfaces on Apple iOS devices in the [Pythonista 3 IDE](https://omz-software.com/pythonista/) for iOS.
5
+ It is a comprehensive and fully featured alternative to Pythonista's native app `ui` library, allowing user interfaces to be created with standard web technologies. Using powerful Python templating for dynamic HTML5/CSS/JavaScript views rendering with Mako, simple instanced view state binding supporting auto-wiring GET/POST values and two way Python/JavaScript interop via JSON over thread-safe message handlers.
6
+
7
+ Creating user interfaces for Python Apps in Pythonista becomes as simple as adding new .html view template files into your project. Which are then served locally from a Bottle.py HTTP/WSGI server and shown in a native WKWebView browser component. WKApp supports creating user interfaces with anything supported by modern web browsers using HTML5, CSS, JavaScript or even WebAssembly and browser based 2D/3D graphics!
8
+
9
+
10
+ ## Getting started
11
+ Pip is the recommended installation method for WKApp.
12
+ Install [StaSh](https://github.com/ywangd/stash) for Pythonista 3 using the installation instructions from its README first.
13
+ Then install the `pythonista-wkapp` module with pip.
14
+ ```
15
+ pip install pythonista-wkapp
16
+ ```
17
+ Alternatively you can also clone or download a zip of this repository and copy/extract the project files into `site-packages/wkapp`.
18
+
19
+ To create an app, simply add a folder somewhere containing an `app.py` file as follows.
20
+
21
+ ```python
22
+
23
+ from wkapp import *
24
+
25
+ app = WKApp(__file__)
26
+ app.run()
27
+
28
+ ```
29
+
30
+ Run this file and you should see a fullscreen browser control and the main view index.html placeholder page shown.
31
+ You can then start making your own views straight away!
32
+
33
+ To replace the initial main view / index.html placeholder page:
34
+ - Create a `views` folder in the same folder as your `app.py` file.
35
+ - Create a file `views/index.html`.
36
+ - Then add your html and setup a `view_class` mixin definition like as below.
37
+ - An instance of this class will be maintained with your view which can be used to store state, bind/manipulate elements, provide functions to be called from Javascript and evaluate Javascript from Python in the view to inspect and alter the DOM or backend state.
38
+
39
+ A simple `views/index.html` view example:
40
+
41
+ ```python
42
+ <%!
43
+
44
+ class ViewClass:
45
+ def on_init(self):
46
+ self.name = ''
47
+
48
+ def view_action(self, text,*args):
49
+ print(text,args)
50
+ self.element('header').set('text',f'hello javascript! text was {text} args were {args}')
51
+
52
+
53
+ view_class = ViewClass
54
+
55
+ %>
56
+ ```
57
+ ```html
58
+ <!-- inherit from the view.html template to render the views content inside the apps customisable base layout and structure -->
59
+ <%inherit file="view.html"/>
60
+ <!-- Your page content goes here -->
61
+ <script type="text/javascript">
62
+ function invoke_view_action() {
63
+ view.invoke('view_action', 'hello python!',
64
+ {pass:'any',json:['compatible'], args:{ints:1}, floats:0.5},
65
+ ['lists',{},1,2.2],
66
+ 'strings',
67
+ 'numbers',1,1.5
68
+ );
69
+ }
70
+ </script>
71
+ <button onclick="invoke_view_action()">Call Python</button>
72
+ <button onclick="app.exit()">Exit Application</button>
73
+ <div>
74
+ <h1 id="header">Hello World!</h1>
75
+ <form method="POST">
76
+ <label>Enter your name:</label>
77
+ <input name="name" type="text" value="${view.name}" />
78
+ <br />
79
+ <input type="submit" value="Submit" />
80
+ </form>
81
+ % if view.name != '':
82
+ <h2> Hello ${view.name}! </h2>
83
+ % endif
84
+ </div>
85
+ ```
86
+ Note: Code above is one file, it is just shown in two parts here for code highlighting purposes.
87
+
88
+ ### Dependencies
89
+ WKApp requires the Pythonista 3 app on iOS to run but otherwise uses a minimal set of dependencies:
90
+
91
+ - [Bottle.py 0.13.4](https://github.com/bottlepy/bottle)
92
+ - [Mako 1.13.10](https://github.com/sqlalchemy/mako)
93
+ - pythonista-wkwebview 1.2 (Bundled)
94
+ - 1.2 is an extended version for WKApp, updated with fixes and new features for using the native WKWebView from UIKit on iOS. Including a WKURLSchemeHandler implementation allowing creating custom url schemes with a single handler in a subclass, simplified javascript handlers threading concerns with a Dispatcher, arbitrary arguments passing from javascript to python via json.
95
+ - 1.1 [Gist (@sbbosco)](https://gist.github.com/sbbosco/1290f59d79c6963e62bb678f0f05b035)
96
+ - 1.0 [Github (@mikaelho)](https://github.com/mikaelho/pythonista-webview)
97
+
98
+ ### Bundled Web frontend libraries:
99
+ The base app html template bundles with it Bootstrap and JQuery to offer a way to just start developing apps rapidly right away straight out of the box.
100
+ However if you prefer other frameworks the `base/app.html` template can also be customised.
101
+ Simply create a copy of `views/base/app.html` from the repository and add it to your `views` folder in the same structure `views/base/app.html`.
102
+ Any base template file may be replaced in this same way.
103
+
104
+ - [Bootstrap 5.3.8](https://getbootstrap.com/docs/5.3/getting-started/introduction/)
105
+ - [JQuery 3.7.1](https://jquery.com)
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "pythonista-wkapp"
3
+ requires-python = ">= 3.10"
4
+ dependencies = [
5
+ "bottle",
6
+ "Mako"
7
+ ]
8
+ authors = [
9
+ {name = "M4nw3l", email = "63550247+M4nw3l@users.noreply.github.com"},
10
+ ]
11
+ readme = "README.md"
12
+ license = "MIT"
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Operating System :: iOS",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
19
+ "Topic :: Software Development :: User Interfaces"
20
+ ]
21
+ dynamic = ["version", "description"]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/m4nw3l/pythonista-wkapp"
25
+
26
+ [tool.flit.module]
27
+ name = "wkapp"
28
+
29
+ [build-system]
30
+ requires = ["flit_core >=3.11,<4"]
31
+ build-backend = "flit_core.buildapi"
@@ -0,0 +1,787 @@
1
+ '''
2
+ WKApp - A modern HTML5 UI framework for building iOS apps with Pythonista 3 and WebKit
3
+
4
+ https://github.com/M4nw3l/pythonista-wkapp
5
+ '''
6
+ __version__ = '0.1.0'
7
+
8
+ try:
9
+ #pythonista specific libraries
10
+ import ui
11
+ except:
12
+ raise Exception("Pythonista 3 is required.")
13
+
14
+ import inspect
15
+ import os
16
+ import sys
17
+ import threading
18
+ from urllib.parse import urlparse, quote as urlencode, unquote as urldecode
19
+
20
+ import requests
21
+
22
+ import bottle
23
+ from bottle import Bottle, default_app, BaseTemplate
24
+ from bottle import WSGIRefServer
25
+ from bottle import (
26
+ request,
27
+ response,
28
+ route,
29
+ static_file,
30
+ mako_template as template,
31
+ )
32
+
33
+ from mako.lookup import TemplateLookup
34
+ from mako import parsetree
35
+ from mako.lexer import Lexer
36
+
37
+ import logging
38
+
39
+ log = logging.getLogger(__name__)
40
+
41
+ try:
42
+ from .WKWebView import *
43
+ except:
44
+ from WKWebView import *
45
+
46
+
47
+ class WKAppWebView(WKWebView):
48
+
49
+ def init(self):
50
+ self.scheme_handler = None
51
+
52
+ def scheme_wkapp(self, task):
53
+ log.warning(f'WKAppWebView - URL Scheme - {task.url}')
54
+ if not self.scheme_handler is None and hasattr(self.scheme_handler,
55
+ 'webview_scheme_wkapp'):
56
+ self.scheme_handler.webview_scheme_wkapp(self, task)
57
+ else:
58
+ task.failed(f'WKAppWebView - URL Scheme Unhandled')
59
+
60
+ def on_javascript_console_message(self, level, content):
61
+ log.warning(f'WKAppWebView - JS - {level.upper()}: {content}')
62
+
63
+ def webview_did_start_load(self, url):
64
+ log.warning(f'WKAppWebView - Start loading {url}')
65
+
66
+ def webview_did_finish_load(self, url):
67
+ log.warning(f'WKAppWebView - Finish loading {url}')
68
+
69
+
70
+ class WKAppView(ui.View):
71
+
72
+ @property
73
+ def webview(self):
74
+ return self['webview']
75
+
76
+ def did_load(self):
77
+ if self.webview is None:
78
+ raise RuntimeError("WKWebView not loaded")
79
+
80
+ def load(self, app):
81
+ self.app = app
82
+ if self.webview is None:
83
+ raise RuntimeError("WKWebView not loaded")
84
+ self.webview.delegate = self.app
85
+ self.webview.scheme_handler = self.app
86
+ self.webview.load_url(self.app.app_url,
87
+ no_cache=self.app.no_cache,
88
+ clear_cache=self.app.clear_cache)
89
+
90
+ def will_close(self):
91
+ self.webview.close()
92
+ self.app.cleanup()
93
+
94
+
95
+ class WKAppServer(threading.Thread):
96
+
97
+ def __init__(self, app, host='localhost', port=8080, server_class=None):
98
+ threading.Thread.__init__(self)
99
+ self.app = app
100
+ self.host = host
101
+ self.port = port
102
+ self.server_class = server_class
103
+
104
+ def run(self):
105
+ log.warning(f'WKApp - Server Starting...')
106
+ if self.server_class is None:
107
+ self.server_class = WSGIRefServer
108
+ server_class = self.server_class
109
+ self.server = server_class(port=self.port)
110
+ self.app.run(host=self.host,
111
+ port=self.port,
112
+ server=self.server,
113
+ debug=True)
114
+
115
+ def get_id(self):
116
+ if hasattr(self, '_thread_id'):
117
+ return self._thread_id
118
+ for id, thread in threading._active.items():
119
+ if thread is self:
120
+ return id
121
+ return None
122
+
123
+ def stop(self):
124
+ log.warning(f'WKApp - Server Stopping...')
125
+ if hasattr(self, 'server') and hasattr(self.server, 'srv'):
126
+ server = self.server.srv
127
+ if hasattr(server, 'shutdown'):
128
+ server.shutdown()
129
+ if hasattr(server, 'close'):
130
+ server.close()
131
+ elif hasattr(server, 'server_close'):
132
+ server.server_close()
133
+ else:
134
+ thread_id = self.get_id()
135
+ if not thread_id is None:
136
+ res = ctypes.pythonapi.PyThreadState_SetAsyncExc(
137
+ thread_id, ctypes.py_object(KeyboardInterrupt))
138
+ if res > 1:
139
+ ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id, 0)
140
+ self.join()
141
+ log.warning(f'WKApp - Server Stopped.')
142
+
143
+
144
+ class WKConstants:
145
+ unspecfied = object()
146
+
147
+
148
+ class WKJavascript:
149
+
150
+ @staticmethod
151
+ def str_escape(value, delim='`'):
152
+ value = value.replace(delim, f'\\{delim}')
153
+ return value
154
+
155
+ @staticmethod
156
+ def value_to_js(value):
157
+ if isinstance(value, str):
158
+ value = WKJavascript.str_escape(value, '`')
159
+ value = f'`{value}`'
160
+ elif isinstance(value, bool):
161
+ value = 'true' if value == True else 'false'
162
+ elif isinstance(value, (int, float, complex)):
163
+ pass
164
+ else:
165
+ obj = json.dumps(value)
166
+ value = f'{obj}'
167
+ return value
168
+
169
+ @staticmethod
170
+ def value_to_py(value, typ, default=WKConstants.unspecfied):
171
+ if value is None:
172
+ if default == WKConstants.unspecfied:
173
+ if typ in [str, int, float, complex, bool]:
174
+ return typ()
175
+ return value
176
+ else:
177
+ return default
178
+ return typ(value)
179
+
180
+ @staticmethod
181
+ def function_call(name, *args, chain=False):
182
+ code = [f'{name}(']
183
+ first = True
184
+ for arg in args:
185
+ if not first:
186
+ code.append(',')
187
+ code.append(WKJavascript.value_to_js(arg))
188
+ first = False
189
+ code.append(')')
190
+ if not chain:
191
+ code.append(';')
192
+ code = ''.join(code)
193
+ return code
194
+
195
+ @staticmethod
196
+ def field(instance, name):
197
+ return f'{instance}.{name}'
198
+
199
+ @staticmethod
200
+ def field_get(instance, name):
201
+ return WKJavascript.field(instance, name) + ';'
202
+
203
+ @staticmethod
204
+ def field_set(instance, name, value):
205
+ value = WKJavascript.value_to_js(value)
206
+ return WKJavascript.field(instance, name) + f' = {value};'
207
+
208
+ @staticmethod
209
+ def instance_call(instance, name, *args, chain=False):
210
+ return WKJavascript.field(
211
+ instance, WKJavascript.function_call(name, *args, chain=chain))
212
+
213
+ @staticmethod
214
+ def jquery(selector):
215
+ return f'$("{selector}")'
216
+
217
+ @staticmethod
218
+ def document_get_element_by_id(id):
219
+ return WKJavascript.instance_call('document', 'getElementById', id)
220
+
221
+
222
+ class WKElementsRef:
223
+
224
+ def __init__(self, view, selector, js=WKJavascript):
225
+ self.view = view
226
+ self.selector = selector
227
+ self.js = js
228
+ self.elem = js.jquery(self.selector)
229
+
230
+ def call(self, name, *args):
231
+ script = self.js.instance_call(self.elem, name, *args)
232
+ return self.view.eval_js(script)
233
+
234
+ def get(self, name, typ=str, default=WKConstants.unspecfied):
235
+ value = self.call(name)
236
+ return self.js.value_to_py(value, typ, default)
237
+
238
+ def set(self, name, value):
239
+ self.call(name, value)
240
+
241
+
242
+ class WKView:
243
+
244
+ def __init__(self,
245
+ app=None,
246
+ url='',
247
+ path='',
248
+ template=None,
249
+ js=WKJavascript):
250
+ self.app = app
251
+ self.url = url
252
+ self.path = path
253
+ self.template = template
254
+ self.js = js
255
+ self.event('on_init')
256
+
257
+ def webview(self):
258
+ return self.app.app_webview
259
+
260
+ def eval_js(self, script):
261
+ return self.webview().eval_js(script)
262
+
263
+ def eval_js_async(self, script):
264
+ return self.webview().eval_js_async(script)
265
+
266
+ def elements(self, selector):
267
+ return WKElementsRef(self, selector, self.js)
268
+
269
+ def element(self, id):
270
+ return WKElementsRef(self, f'#{id}', self.js)
271
+
272
+ def event(self, name, *args, **kwargs):
273
+ if hasattr(self, name):
274
+ func = getattr(self, name)
275
+ func(*args, **kwargs)
276
+
277
+
278
+ class WKViewsLexer(Lexer):
279
+
280
+ def match_expression(self):
281
+ match = self.match(r"(\$\$|%%){")
282
+ if not match is None:
283
+ # matches an 'escaped' expression for emitting an expression-like literal
284
+ escape = match.group(1)
285
+ match = self.match(r"(.*?)}", re.S)
286
+ self.append_node(parsetree.Text,
287
+ escape[0] + "{" + match.group(1) + "}")
288
+ return True
289
+ match = self.match(r"(\$|%){") # match expressions with ${} or %{}
290
+ if not match:
291
+ return False
292
+ # matched expression
293
+ line, pos = self.matched_lineno, self.matched_charpos
294
+ text, end = self.parse_until_text(True, r"\|", r"}")
295
+ if end == "|":
296
+ escapes, end = self.parse_until_text(True, r"}")
297
+ else:
298
+ escapes = ""
299
+ text = text.replace("\r\n", "\n")
300
+ self.append_node(
301
+ parsetree.Expression,
302
+ text,
303
+ escapes.strip(),
304
+ lineno=line,
305
+ pos=pos,
306
+ )
307
+ return True
308
+
309
+ def match_text(self):
310
+ match = self.match(
311
+ r"""
312
+ (.*?) # anything, followed by:
313
+ (
314
+ (?<=\n)(?=[ \t]*(?=%|\#\#)) # an eval or line-based
315
+ # comment, preceded by a
316
+ # consumed newline and whitespace
317
+ |
318
+ (?=(\$\$|%%){) # an escaped expression
319
+ |
320
+ (?=(\$|%){) # an expression
321
+ |
322
+ (?=</?%) # a substitution or block or call start or end
323
+ # - don't consume
324
+ |
325
+ (\\\r?\n) # an escaped newline - throw away
326
+ |
327
+ \Z # end of string
328
+ )""",
329
+ re.X | re.S,
330
+ )
331
+ if match:
332
+ text = match.group(1)
333
+ if text:
334
+ self.append_node(parsetree.Text, text)
335
+ return True
336
+ else:
337
+ return False
338
+
339
+ @staticmethod
340
+ def preprocessor(template, *args, **kwargs):
341
+ #log.warning(f"WKViewsTemplate preprocess: {args} {kwargs}")
342
+ #log.warning(template)
343
+ return template
344
+
345
+
346
+ class WKAppTemplate(bottle.MakoTemplate):
347
+
348
+ def prepare(self, **options):
349
+ from mako.template import Template
350
+ from mako.lookup import TemplateLookup
351
+ options.update({'input_encoding': self.encoding})
352
+ options.setdefault('format_exceptions', bool(bottle.DEBUG))
353
+ lookup = options.pop('template_lookup',
354
+ None) # threading can cause problems here
355
+ if lookup is None:
356
+ directories = options.pop('directories', self.lookup)
357
+ lookup = TemplateLookup(directories=directories, **options)
358
+ if self.source:
359
+ self.tpl = Template(self.source, lookup=lookup, **options)
360
+ else:
361
+ self.tpl = Template(uri=self.name,
362
+ filename=self.filename,
363
+ lookup=lookup,
364
+ **options)
365
+
366
+
367
+ wkapp_template = functools.partial(bottle.template,
368
+ template_adapter=WKAppTemplate)
369
+
370
+
371
+ class WKViews:
372
+
373
+ def __init__(self, app, app_path, app_views_path, module_path,
374
+ module_views_path):
375
+ bottle.TEMPLATE_PATH.clear()
376
+ bottle.TEMPLATE_PATH.append(app_views_path)
377
+ bottle.TEMPLATE_PATH.append(module_views_path)
378
+ imports = []
379
+ self.template_settings = {
380
+ 'directories': bottle.TEMPLATE_PATH,
381
+ #'module_directory': os.path.join(app_path, 'views-cache'),
382
+ 'imports': imports,
383
+ 'preprocessor': WKViewsLexer.preprocessor,
384
+ 'lexer_cls': WKViewsLexer
385
+ }
386
+ self.lookup = TemplateLookup(**self.template_settings)
387
+ self.app = app
388
+ self.load_view = None
389
+ self.next_view = None
390
+ self.views = {}
391
+ self.view = WKView()
392
+ self.views[self.view.url] = self.view
393
+ self.about_blank_view = WKView('about:blank')
394
+ self.views[self.about_blank_view.url] = self.about_blank_view
395
+
396
+ def template(self, path, **kwargs):
397
+ return wkapp_template(path,
398
+ template_settings=self.template_settings,
399
+ **kwargs)
400
+
401
+ @property
402
+ def base_url(self):
403
+ return self.app.base_url
404
+
405
+ @property
406
+ def url(self):
407
+ return self.view.url if self.view else ''
408
+
409
+ @property
410
+ def load_url(self):
411
+ return self.load_view.url if not self.load_view is None else ''
412
+
413
+ @property
414
+ def next_url(self):
415
+ return self.next_view.url if not self.next_view is None else ''
416
+
417
+ def cancel_load_view(self):
418
+ self.load_view = self.about_blank_view
419
+ return False
420
+
421
+ def get_url_path(self, url=None, path=None):
422
+ if url is None and path is None:
423
+ raise Exception('Must specify one of url or path')
424
+ if url == 'about:blank':
425
+ return url, url
426
+ if not path is None and not path.startswith('/'):
427
+ path = '/' + path
428
+ if url is None and not path is None:
429
+ url = self.base_url + path
430
+ if path is None and not url is None:
431
+ parsed_url = urlparse(url)
432
+ path = parsed_url.path
433
+ if url == self.base_url + '/' or path == '/':
434
+ path = '/index.html'
435
+ url = self.base_url + path
436
+ return url, path
437
+
438
+ def get_view(self, url=None, path=None, create=False):
439
+ view = None
440
+ url, path = self.get_url_path(url, path)
441
+ if url == 'about:blank':
442
+ return self.about_blank_view
443
+ if create and not path in self.views:
444
+ try:
445
+ view_template = self.lookup.get_template(path)
446
+ if view_template is None:
447
+ raise Exception("Mako template not found.")
448
+ log.warning(
449
+ f'WKViewState - Template found for {path} {view_template}')
450
+ except Exception as e:
451
+ log.warning(
452
+ f'WKViewState - No template found for path {path} {view_template}, {e}'
453
+ )
454
+ if not view_template is None and hasattr(view_template.module,
455
+ 'view_class'):
456
+ view_class = view_template.module.view_class
457
+
458
+ class view_class_mixin(view_class, WKView):
459
+ pass
460
+
461
+ view = view_class_mixin(self.app, url, path, view_template)
462
+ if view is None:
463
+ raise Exception(
464
+ f"view_class is defined but returned None or not an object value = '{view}'"
465
+ )
466
+ else:
467
+ view = WKView(self.app, url, path, view_template)
468
+ self.views[path] = view
469
+ return view
470
+ if not path is None:
471
+ view = self.views[path]
472
+ return view
473
+
474
+ def prepare_load_view(self, url, scheme, nav_type):
475
+ log.warning(f'WKViewState - Preparing load {url}')
476
+ view = self.get_view(url=url, create=True)
477
+ self.load_view = view
478
+ view.event('on_prepare')
479
+ return True
480
+
481
+ def start_load_view(self, url):
482
+ log.warning(f'WKViewState - Start load {url}')
483
+ url, path = self.get_url_path(url=url)
484
+ if self.load_url != url:
485
+ raise Exception(
486
+ f'Unexpected view url "{url}" expected "{self.load_url}"')
487
+ self.load_view = None
488
+ view = self.get_view(url=url)
489
+ self.next_view = view
490
+ view.event('on_loading')
491
+
492
+ def finish_load_view(self, url):
493
+ log.warning(f'WKViewState - Finish load {url}')
494
+ url, path = self.get_url_path(url)
495
+ if self.next_url != url:
496
+ raise Exception(
497
+ f'Unexpected view url "{url}" expected "{self.next_url}"')
498
+ self.next_view = None
499
+ view = self.get_view(url=url)
500
+ self.view = view
501
+ view.event('on_loaded')
502
+
503
+
504
+ class WKAppPlugin:
505
+
506
+ def __init__(self, app):
507
+ self.app = app
508
+ self.callbacks = {}
509
+ pass
510
+
511
+ def setup(self, app):
512
+ pass
513
+
514
+ def has_args(self, callback, *args):
515
+ spec = self.callbacks.get(callback, None)
516
+ if spec is None:
517
+ spec = inspect.getfullargspec(callback)
518
+ spec = spec[0]
519
+ self.callbacks[callback] = spec
520
+ for arg in args:
521
+ if not arg in spec:
522
+ return False
523
+ return True
524
+
525
+ def response_headers(self):
526
+ # Enable cross origin isolation for browser to consider context secure enough for full web assembly and webgl support
527
+ # https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements
528
+ response.add_header('Permissions-Policy', 'cross-origin-isolated=self')
529
+ response.add_header('Access-Control-Allow-Origin', '*')
530
+ response.add_header(
531
+ 'Access-Control-Allow-Headers',
532
+ '"Origin, X-Requested-With, Content-Type, Accept"')
533
+ response.add_header('Access-Control-Allow-Methods', '*')
534
+ response.add_header('Cross-Origin-Opener-Policy', 'same-origin')
535
+ response.add_header('Cross-Origin-Embedder-Policy', 'require-corp')
536
+ response.add_header('Cross-Origin-Resource-Policy', 'cross-origin')
537
+
538
+ def apply(self, callback, route):
539
+ if not self.has_args(callback, 'view'):
540
+ return callback
541
+
542
+ def wrapper(*args, **kwargs):
543
+ view = self.app.get_view(url=request.url, create=True)
544
+ kwargs['view'] = view
545
+ self.response_headers()
546
+ return callback(*args, **kwargs)
547
+
548
+ return wrapper
549
+
550
+
551
+ class WKApp:
552
+
553
+ def __init__(self,
554
+ root=None,
555
+ port=8080,
556
+ host='localhost',
557
+ app=None,
558
+ server=None,
559
+ app_views_path='views',
560
+ app_static_path='static',
561
+ module_views_path='views',
562
+ module_static_path='static',
563
+ custom_scheme=False,
564
+ no_cache=True,
565
+ clear_cache=False):
566
+ self.module_path = os.path.dirname(__file__)
567
+ self.module_static_path = os.path.join(self.module_path,
568
+ module_static_path)
569
+ self.module_views_path = os.path.join(self.module_path,
570
+ module_views_path)
571
+ self._app_path = ''
572
+ if root is None:
573
+ root = self.module_static_path
574
+ if os.path.isfile(root):
575
+ root = os.path.dirname(root)
576
+ self._app_path = root
577
+ self.app_static_path = os.path.join(self.app_path, app_static_path)
578
+ self.app_views_path = os.path.join(self.app_path, app_views_path)
579
+ self.custom_scheme = custom_scheme
580
+ self.no_cache = no_cache
581
+ self.clear_cache = clear_cache
582
+ if app is None:
583
+ self._app = Bottle()
584
+ default_app.push(self.app)
585
+ else:
586
+ self._app = app
587
+
588
+ self._app_view = None
589
+ self._views = WKViews(self, self.app_path, self.app_views_path,
590
+ self.module_path, self.module_views_path)
591
+ self.host = host
592
+ self.port = port
593
+ self.server = server
594
+ self.server_internal = self.server is None
595
+
596
+ self.plugin = WKAppPlugin(self)
597
+ self.app.install(self.plugin)
598
+ with self.app:
599
+ self.setup_server_routes()
600
+
601
+ log.warning(f"WKApp - Init\n" +
602
+ f" - Module path: {self.module_path}\n"
603
+ f" - App path: {self.app_path}\n" +
604
+ f" - TEMPLATE_PATH: {bottle.TEMPLATE_PATH}")
605
+
606
+ @property
607
+ def app(self):
608
+ return self._app
609
+
610
+ @property
611
+ def app_path(self):
612
+ return self._app_path
613
+
614
+ @property
615
+ def base_url(self):
616
+ return f'http://{self.host}:{self.port}'
617
+
618
+ @property
619
+ def app_url(self):
620
+ if self.custom_scheme:
621
+ return f'wkapp://localhost/'
622
+ return self.base_url
623
+
624
+ def start_server(self):
625
+ if self.server is None and self.server_internal:
626
+ self.server = WKAppServer(self.app, self.host, self.port)
627
+ self.server.start()
628
+
629
+ def stop_server(self):
630
+ if not self.server is None and self.server_internal:
631
+ self.server.stop()
632
+ self.server = None
633
+
634
+ def present(self,
635
+ mode='fullscreen',
636
+ no_cache=True,
637
+ clear_cache=False,
638
+ **kwargs):
639
+ self._app_view = ui.load_view(
640
+ os.path.join(self.module_path, 'wkapp.pyui'))
641
+ self.no_cache = no_cache
642
+ self.clear_cache = clear_cache
643
+ self.app_view.load(self)
644
+ self.app_view.present(mode, **kwargs)
645
+
646
+ def run(self, **kwargs):
647
+ log.warning(f'WKApp - Run')
648
+ self.start_server()
649
+ self.present(**kwargs)
650
+
651
+ def exit(self):
652
+ if not self.app_view:
653
+ return
654
+ self.app_view.close()
655
+
656
+ def cleanup(self):
657
+ self.stop_server()
658
+
659
+ def static_file(self, filepath, root='/'):
660
+ if root == '/':
661
+ root = self.app_path
662
+ if root != self.module_static_path and not os.path.exists(
663
+ os.path.join(root, filepath)):
664
+ root = self.module_static_path
665
+ return static_file(filepath, root=root)
666
+
667
+ def template(self, path, **kwargs):
668
+ return self.views.template(path, **kwargs)
669
+
670
+ def get_view(self, url=None, path=None, create=False):
671
+ view = self.views.get_view(url=url, path=path, create=create)
672
+ log.warning(f'WKApp.get_view("{url}", "{path}", {create}) -> {view}')
673
+ if view is None:
674
+ return view
675
+ values = {}
676
+ query = {}
677
+ kwargs = {'request': request, 'values': values, 'query': query}
678
+ method = request.method
679
+ for k, v in request.query.iteritems():
680
+ query[k] = v
681
+ values[k] = v
682
+ if hasattr(view, k):
683
+ setattr(view, k, v)
684
+ if method == 'POST':
685
+ for k, v in request.forms.iteritems():
686
+ values[k] = v
687
+ if hasattr(view, k):
688
+ setattr(view, k, v)
689
+ view.event('on_' + method, **kwargs)
690
+ return view
691
+
692
+ def setup_server_routes(self):
693
+
694
+ @route('/static/<filepath:path>')
695
+ def server_static(filepath):
696
+ return self.static_file(filepath)
697
+
698
+ @route('/<filepath:path>')
699
+ def server_template_get(filepath, view):
700
+ return self.template(filepath, view=view)
701
+
702
+ @route('/<filepath:path>', method='POST')
703
+ def server_template_post(filepath, view):
704
+ return self.template(filepath, view=view)
705
+
706
+ @route('/')
707
+ def server_index_get(view):
708
+ return server_template_get('index.html', view)
709
+
710
+ @route('/', method='POST')
711
+ def server_index_post(view):
712
+ return server_template_post('index.html', view)
713
+
714
+ @property
715
+ def app_view(self):
716
+ return self._app_view
717
+
718
+ @property
719
+ def app_webview(self):
720
+ return self.app_view.webview if self.app_view else None
721
+
722
+ @property
723
+ def views(self):
724
+ return self._views
725
+
726
+ @property
727
+ def view(self):
728
+ return self.views.view
729
+
730
+ def webview_should_start_load(self, webview, url, scheme, nav_type):
731
+ start = self.views.prepare_load_view(url, scheme, nav_type)
732
+ return start
733
+
734
+ def webview_did_start_load(self, webview, url):
735
+ self.views.start_load_view(url)
736
+
737
+ def webview_did_finish_load(self, webview, url):
738
+ self.views.finish_load_view(url)
739
+
740
+ def webview_on_invoke(self, sender, typ, context, target, args, kwargs):
741
+ url = sender.current_url
742
+ url, path = self.views.get_url_path(url=url)
743
+ log.warning(
744
+ f'WKApp - INVOKE "{url}" "{typ}" "{context}" "{target}" "{args}" "{kwargs}"'
745
+ )
746
+ pycontext = None
747
+ if typ == "WKApp":
748
+ pycontext = self
749
+ elif typ == "WKView":
750
+ if url == self.view.url:
751
+ pycontext = self.view
752
+ else:
753
+ pycontext = self.views.get_view(url=url, path=path)
754
+ else:
755
+ raise Exception(f"Context type {typ} unhandled")
756
+ if not hasattr(pycontext, target):
757
+ raise Exception(
758
+ f"Target '{target}' not found in context {pycontext}")
759
+ pytarget = getattr(pycontext, target)
760
+ if not callable(pytarget):
761
+ raise Exception(
762
+ f"Target '{target} {pytarget}' in context {pycontext} not callable"
763
+ )
764
+ pytarget(*args, **kwargs)
765
+
766
+ def webview_scheme_wkapp(self, webview, task):
767
+ command = task.host
768
+ if command == "localhost" or command == "proxy":
769
+ url = task.path
770
+ if command == "localhost":
771
+ url = self.base_url + url
772
+ else:
773
+ url = urldecode(url[1:])
774
+ response = requests.request(task.method,
775
+ url,
776
+ headers=task.headers,
777
+ data=task.body)
778
+ task.finish(status_code=response.status_code,
779
+ headers=response.headers,
780
+ data=response.content,
781
+ content_type=response.headers.get('Content-Type'))
782
+
783
+
784
+ if __name__ == '__main__':
785
+ app = WKApp(__file__, app_views_path='test/views')
786
+ app.run()
787
+