pythonista-wkapp 0.1.0__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.
|
@@ -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,5 @@
|
|
|
1
|
+
wkapp.py,sha256=-PpbjklJ9X3ai2iPK2Z8gK1hTkAfq1_X4AlSl8dt4DY,25446
|
|
2
|
+
pythonista_wkapp-0.1.0.dist-info/licenses/LICENSE,sha256=eJni0v8cBIs6ZvXEWIixdkS7y1IrCNiV1Zcnrg37qF8,1063
|
|
3
|
+
pythonista_wkapp-0.1.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
4
|
+
pythonista_wkapp-0.1.0.dist-info/METADATA,sha256=nnO2AIY70gZsS6f0Ddfp6ld2YDezRRrAaqHzkEhBuvc,5915
|
|
5
|
+
pythonista_wkapp-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|
wkapp.py
ADDED
|
@@ -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
|
+
|