-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathtools_here.py
More file actions
359 lines (292 loc) · 11.2 KB
/
Copy pathtools_here.py
File metadata and controls
359 lines (292 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
"""Reusable tools for the live PythonHere runtime."""
import copy
import json
import math
import threading
from concurrent.futures import Future
from concurrent.futures import TimeoutError as FutureTimeoutError
from pathlib import Path
from typing import Any
MAIN_THREAD_BRIDGE_TIMEOUT = 10.0
DEFAULT_UI_SNAPSHOT_DEPTH = 6
DEFAULT_UI_SNAPSHOT_WIDGETS = 200
MAX_UI_SNAPSHOT_CLASS = 120
MAX_UI_SNAPSHOT_DEPTH = 48
MAX_UI_SNAPSHOT_ERROR = 240
MAX_UI_SNAPSHOT_WIDGETS = 500
MAX_UI_SNAPSHOT_TEXT = 240
class MainThreadBridgeError(RuntimeError):
"""Kivy could not execute a requested main-thread operation."""
class MainThreadTimeoutError(MainThreadBridgeError):
"""Kivy did not execute a requested operation before its deadline."""
def _clock_boundary():
"""Return the Kivy Clock objects used by the worker-thread bridge."""
from kivy.clock import Clock, ClockNotRunningError
return Clock, ClockNotRunningError
def _run_on_main_thread(function, /, *args, _timeout=None, **kwargs):
"""Run ``function`` on Kivy's thread and synchronously return its result."""
if threading.current_thread() is threading.main_thread():
return function(*args, **kwargs)
timeout = MAIN_THREAD_BRIDGE_TIMEOUT if _timeout is None else _timeout
completion = Future()
def execute(_delta_time):
if not completion.set_running_or_notify_cancel():
return
try:
result = function(*args, **kwargs)
except BaseException as exc: # Ensure every invocation releases its waiter.
completion.set_exception(exc)
else:
completion.set_result(result)
def clock_ended(_event):
if completion.set_running_or_notify_cancel():
completion.set_exception(
MainThreadBridgeError(
"Kivy's Clock stopped before PythonHere could run the "
"requested main-thread operation"
)
)
clock, clock_not_running_error = _clock_boundary()
try:
event = clock.create_lifecycle_aware_trigger(
execute,
clock_ended,
timeout=0,
release_ref=False,
)
event()
except clock_not_running_error as exc:
raise MainThreadBridgeError(
"Kivy's Clock is not running; PythonHere cannot run the requested "
"main-thread operation"
) from exc
try:
return completion.result(timeout=timeout)
except FutureTimeoutError:
# An implementation may itself raise TimeoutError. Preserve it when it
# completed before the bridge deadline instead of mislabelling it.
if completion.done():
return completion.result()
cancelled_before_start = completion.cancel()
event.cancel()
if cancelled_before_start:
detail = "The operation was cancelled before it started."
elif completion.done():
return completion.result()
else:
detail = "The operation had started and may still complete."
raise MainThreadTimeoutError(
f"Kivy did not complete the requested main-thread operation within "
f"{timeout:g} seconds. {detail} Do not blindly retry mutations."
) from None
def _current_root(widget=None):
if widget is not None:
return widget
from kivy.app import App
from kivy.core.window import Window
app = App.get_running_app()
if app is not None and app.root is not None:
return app.root
if Window.children:
return Window.children[0]
raise RuntimeError("PythonHere has no visible root widget")
def _json_number(value):
number = float(value)
if not math.isfinite(number):
return None
return round(number, 2)
def _snapshot_limit(name: str, value: int, minimum: int, maximum: int) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError(f"{name} must be an integer")
if not minimum <= value <= maximum:
raise ValueError(f"{name} must be between {minimum} and {maximum}")
return value
def _optional_text(widget):
try:
value = widget.text
return (str(value) if value is not None else None), False
except AttributeError:
return None, False
except Exception: # A custom diagnostic property must not abort the snapshot.
return None, True
def _snapshot_widget(current, path):
children = list(reversed(getattr(current, "children", ())))
text, text_unavailable = _optional_text(current)
text_length = len(text) if text is not None else None
text_truncated = bool(text is not None and len(text) > MAX_UI_SNAPSHOT_TEXT)
if text_truncated:
text = text[: MAX_UI_SNAPSHOT_TEXT - 3] + "..."
class_name = type(current).__name__
class_truncated = len(class_name) > MAX_UI_SNAPSHOT_CLASS
if class_truncated:
class_name = class_name[: MAX_UI_SNAPSHOT_CLASS - 3] + "..."
item = {
"path": path,
"class": class_name,
"text": text,
"disabled": bool(getattr(current, "disabled", False)),
"pos": [_json_number(value) for value in current.pos],
"size": [_json_number(value) for value in current.size],
"child_count": len(children),
}
if class_truncated:
item["class_truncated"] = True
if text_unavailable:
item["text_unavailable"] = True
if text_length is not None:
item["text_length"] = text_length
if text_truncated:
item["text_truncated"] = True
return item, children
def _compact_inspection_error(exc):
error_type = type(exc).__name__
if len(error_type) > MAX_UI_SNAPSHOT_CLASS:
error_type = error_type[: MAX_UI_SNAPSHOT_CLASS - 3] + "..."
try:
message = str(exc)
except Exception:
message = "<error message unavailable>"
if len(message) > MAX_UI_SNAPSHOT_ERROR:
message = message[: MAX_UI_SNAPSHOT_ERROR - 3] + "..."
return {"type": error_type, "message": message}
def _customize_snapshot_widget(current, default_item, callback):
if callback is None:
return default_item
callback_item = copy.deepcopy(default_item)
try:
item = callback(current, callback_item)
except Exception as exc:
default_item["inspection_error"] = _compact_inspection_error(exc)
return default_item
if item is None:
return None
if not isinstance(item, dict):
exc = TypeError("widget_record_callback must return a dictionary or None")
default_item["inspection_error"] = _compact_inspection_error(exc)
return default_item
try:
json.dumps(item, allow_nan=False)
except Exception as exc:
default_item["inspection_error"] = _compact_inspection_error(exc)
return default_item
return item
def _snapshot_ui(
widget=None,
*,
max_depth: int = DEFAULT_UI_SNAPSHOT_DEPTH,
max_widgets: int = DEFAULT_UI_SNAPSHOT_WIDGETS,
widget_record_callback=None,
) -> dict[str, Any]:
"""Return observable widget facts from Kivy's main thread."""
max_depth = _snapshot_limit(
"max_depth",
max_depth,
0,
MAX_UI_SNAPSHOT_DEPTH,
)
max_widgets = _snapshot_limit(
"max_widgets",
max_widgets,
1,
MAX_UI_SNAPSHOT_WIDGETS,
)
if widget_record_callback is not None and not callable(widget_record_callback):
raise TypeError("widget_record_callback must be callable or None")
widgets = []
pending = [("0", _current_root(widget), 0)]
omitted_descendants = False
visited_widgets = 0
while pending and visited_widgets < max_widgets:
path, current, depth = pending.pop()
visited_widgets += 1
default_item, children = _snapshot_widget(current, path)
item = _customize_snapshot_widget(
current,
default_item,
widget_record_callback,
)
if item is not None:
widgets.append(item)
if depth >= max_depth:
omitted_descendants = omitted_descendants or bool(children)
else:
for index in range(len(children) - 1, -1, -1):
pending.append((f"{path}/{index}", children[index], depth + 1))
return {
"widgets": widgets,
"widget_count": len(widgets),
"truncated": omitted_descendants or bool(pending),
}
def snapshot_ui(
widget=None,
*,
max_depth: int = DEFAULT_UI_SNAPSHOT_DEPTH,
max_widgets: int = DEFAULT_UI_SNAPSHOT_WIDGETS,
widget_record_callback=None,
) -> dict[str, Any]:
"""Return observable widget facts, marshaling worker calls to Kivy."""
return _run_on_main_thread(
_snapshot_ui,
widget,
max_depth=max_depth,
max_widgets=max_widgets,
widget_record_callback=widget_record_callback,
)
def _runtime_info(app=None, root=None) -> dict[str, Any]:
import kivy
from kivy.app import App
from kivy.core.window import Window
from kivy.utils import platform
if app is None:
app = App.get_running_app()
root = _current_root(root)
return {
"app_class": type(app).__name__ if app is not None else None,
"root_class": type(root).__name__,
"kivy_version": kivy.__version__,
"platform": platform,
"window_size": [_json_number(value) for value in Window.size],
}
def runtime_info(app=None, root=None) -> dict[str, Any]:
"""Return live Kivy runtime information, marshaling worker calls."""
return _run_on_main_thread(_runtime_info, app, root)
def _save_screenshot(
path: str | Path = "pythonhere-screenshot.png",
widget=None,
) -> str:
from kivy.app import App
from window_here import save_screenshot as save_window_screenshot
destination = Path(path)
if not destination.is_absolute():
app = App.get_running_app()
upload_dir = getattr(app, "upload_dir", None) if app is not None else None
destination = Path(upload_dir or Path.cwd()) / destination
return save_window_screenshot(destination, widget=widget)
def save_screenshot(
path: str | Path = "pythonhere-screenshot.png",
widget=None,
) -> str:
"""Save visible content as PNG, marshaling worker calls to Kivy."""
return _run_on_main_thread(_save_screenshot, path, widget)
def _encoded_screenshot(widget=None) -> str:
from window_here import encoded_screenshot as encode_window_screenshot
return encode_window_screenshot(widget=widget)
def encoded_screenshot(widget=None) -> str:
"""Encode visible content as PNG, marshaling worker calls to Kivy."""
return _run_on_main_thread(_encoded_screenshot, widget)
def _pin_shortcut(script: str, label: str | None = None) -> None:
from android_here import pin_shortcut as pin_android_shortcut
shortcut_label = label or script.rstrip("/").rsplit("/", 1)[-1]
pin_android_shortcut(script=script, label=shortcut_label)
def pin_shortcut(script: str, label: str | None = None) -> None:
"""Request an Android shortcut, marshaling worker calls to Kivy."""
return _run_on_main_thread(_pin_shortcut, script, label)
__all__ = (
"encoded_screenshot",
"MainThreadBridgeError",
"MainThreadTimeoutError",
"pin_shortcut",
"runtime_info",
"save_screenshot",
"snapshot_ui",
)