pythonhere 0.2.2__py3-none-any.whl → 0.3.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.
- pythonhere/.agents/skills/pythonhere/SKILL.md +230 -0
- pythonhere/.agents/skills/pythonhere/agents/openai.yaml +4 -0
- pythonhere/.agents/skills/pythonhere/references/able.md +554 -0
- pythonhere/.agents/skills/pythonhere/references/android-media.md +130 -0
- pythonhere/.agents/skills/pythonhere/references/android-packages.md +69 -0
- pythonhere/.agents/skills/pythonhere/references/android-permissions.md +195 -0
- pythonhere/.agents/skills/pythonhere/references/android-runtime.md +34 -0
- pythonhere/.agents/skills/pythonhere/references/jnius.md +432 -0
- pythonhere/.agents/skills/pythonhere/references/kivy-kv.md +241 -0
- pythonhere/.agents/skills/pythonhere/references/kivy-runtime.md +305 -0
- pythonhere/.agents/skills/pythonhere/references/midi.md +248 -0
- pythonhere/.agents/skills/pythonhere/references/plyer.md +202 -0
- pythonhere/magic_here/shortcuts.py +2 -2
- pythonhere/server_here.py +1 -1
- pythonhere/tools_here.py +359 -0
- pythonhere/version_here.py +1 -1
- pythonhere/window_here.py +24 -9
- {pythonhere-0.2.2.dist-info → pythonhere-0.3.0.dist-info}/METADATA +11 -2
- {pythonhere-0.2.2.dist-info → pythonhere-0.3.0.dist-info}/RECORD +22 -9
- {pythonhere-0.2.2.dist-info → pythonhere-0.3.0.dist-info}/WHEEL +1 -1
- {pythonhere-0.2.2.dist-info → pythonhere-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {pythonhere-0.2.2.dist-info → pythonhere-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
## Plyer helpers
|
|
2
|
+
|
|
3
|
+
Use this addon for Plyer-backed Android/device features:
|
|
4
|
+
notification, Android toast-style messages, vibration, audio recording, camera capture,
|
|
5
|
+
file chooser, GPS/location, battery, accelerometer, compass, text-to-speech,
|
|
6
|
+
and similar Plyer facades.
|
|
7
|
+
|
|
8
|
+
`plyer` is installed; do not need to check for import errors before normal use.
|
|
9
|
+
|
|
10
|
+
Rules:
|
|
11
|
+
- `plyer` is installed; do not need to check for import errors before normal use.
|
|
12
|
+
- Prefer the `plyer` package for the supported device facades listed here.
|
|
13
|
+
- Plyer does not have a separate `toast` facade. Do not write
|
|
14
|
+
`from plyer import toast`.
|
|
15
|
+
- For Android toast-style messages, use
|
|
16
|
+
`from plyer import notification` and call
|
|
17
|
+
`notification.notify(..., toast=True)`.
|
|
18
|
+
- Do not request permissions here unless the user explicitly asks; use the separate Android permissions prompt.
|
|
19
|
+
- Do not access camera, microphone, GPS/location, sensors, contacts, SMS, call logs, or private files unless the user requested that specific capability.
|
|
20
|
+
- Do not delete, overwrite, upload, or make network requests with selected files unless explicitly requested.
|
|
21
|
+
- For asynchronous Plyer callbacks, store results in globals. If a Kivy UI is
|
|
22
|
+
involved, update visible UI through the Kivy runtime pattern.
|
|
23
|
+
- For microphone recording, Android normally needs
|
|
24
|
+
`android.permission.RECORD_AUDIO` declared in the app manifest and granted at
|
|
25
|
+
runtime. Use the Android permissions prompt when the user asks to request or
|
|
26
|
+
check microphone permission.
|
|
27
|
+
|
|
28
|
+
Text-to-speech rules:
|
|
29
|
+
- For simple text-to-speech, use exactly this API shape:
|
|
30
|
+
|
|
31
|
+
from plyer import tts
|
|
32
|
+
tts.speak(message=text_to_read)
|
|
33
|
+
|
|
34
|
+
- Do not use low-level Android framework speech APIs through Pyjnius for
|
|
35
|
+
ordinary read-aloud, speech, voice output, poem reading, or narration
|
|
36
|
+
requests. Use Pyjnius speech only when the user explicitly asks for lower-level
|
|
37
|
+
Android speech controls that Plyer does not expose.
|
|
38
|
+
- Do not use legacy SL4A-style Android helper speech APIs.
|
|
39
|
+
- Do not use desktop speech packages or platform shell commands for
|
|
40
|
+
Android/PythonHere TTS snippets.
|
|
41
|
+
- Do not generate `TTS_AVAILABLE` fallback scaffolding or probe multiple TTS
|
|
42
|
+
backends unless the user explicitly asks for cross-platform desktop code.
|
|
43
|
+
- Do not run `tts.speak(...)` inside a background Python thread. Use the Plyer
|
|
44
|
+
call directly from the `there run` program or from a short Kivy callback.
|
|
45
|
+
- For a Kivy UI button or delayed speech start, the callback should call
|
|
46
|
+
`tts.speak(message=text)` directly and update UI state through the Kivy
|
|
47
|
+
runtime pattern.
|
|
48
|
+
|
|
49
|
+
Plyer audio recording rules:
|
|
50
|
+
- Use `plyer.audio` for audio recording workflows.
|
|
51
|
+
- Do not use `plyer.audio` as a general local-file playback API.
|
|
52
|
+
- Never call `audio.play(path)` or `audio.play("file.wav")`.
|
|
53
|
+
- For Android Plyer recording, prefer `.3gp` output paths unless this runtime has
|
|
54
|
+
verified another format.
|
|
55
|
+
- Do not name Plyer Android recordings `.wav` unless the backend is known to
|
|
56
|
+
write real WAV PCM data.
|
|
57
|
+
- Replay audio recorded through Plyer with `audio.play()` and no arguments after
|
|
58
|
+
`audio.stop()`.
|
|
59
|
+
- Stop recording or Plyer-managed playback with `audio.stop()`.
|
|
60
|
+
- Do not use Kivy SoundLoader to replay audio just recorded through Plyer on
|
|
61
|
+
Android. Kivy SoundLoader is for normal existing local audio files and is
|
|
62
|
+
covered by the Kivy Runtime prompt.
|
|
63
|
+
|
|
64
|
+
Toast example:
|
|
65
|
+
from plyer import notification
|
|
66
|
+
|
|
67
|
+
notification.notify(
|
|
68
|
+
title="",
|
|
69
|
+
message="Hello",
|
|
70
|
+
app_name="PythonHere",
|
|
71
|
+
toast=True,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
Notification example:
|
|
75
|
+
from plyer import notification
|
|
76
|
+
|
|
77
|
+
notification.notify(
|
|
78
|
+
title="PythonHere",
|
|
79
|
+
message="Done",
|
|
80
|
+
app_name="PythonHere",
|
|
81
|
+
timeout=5,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
Toast plus notification example:
|
|
85
|
+
from plyer import notification
|
|
86
|
+
|
|
87
|
+
notification.notify(
|
|
88
|
+
title="",
|
|
89
|
+
message="Done",
|
|
90
|
+
app_name="PythonHere",
|
|
91
|
+
toast=True,
|
|
92
|
+
)
|
|
93
|
+
notification.notify(
|
|
94
|
+
title="PythonHere",
|
|
95
|
+
message="Done",
|
|
96
|
+
app_name="PythonHere",
|
|
97
|
+
timeout=5,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
Vibration example:
|
|
101
|
+
from plyer import vibrator
|
|
102
|
+
|
|
103
|
+
vibrator.vibrate(0.2)
|
|
104
|
+
|
|
105
|
+
Text-to-speech example:
|
|
106
|
+
from plyer import tts
|
|
107
|
+
|
|
108
|
+
tts.speak(message="Hello from PythonHere.")
|
|
109
|
+
|
|
110
|
+
File chooser example:
|
|
111
|
+
from plyer import filechooser
|
|
112
|
+
|
|
113
|
+
def on_selection(paths):
|
|
114
|
+
plyer_filechooser_result = {
|
|
115
|
+
"paths": list(paths or []),
|
|
116
|
+
"cancelled_or_empty": not bool(paths),
|
|
117
|
+
}
|
|
118
|
+
globals()["plyer_filechooser_result"] = plyer_filechooser_result
|
|
119
|
+
|
|
120
|
+
filechooser.open_file(on_selection=on_selection)
|
|
121
|
+
|
|
122
|
+
Audio recording start example:
|
|
123
|
+
from pathlib import Path
|
|
124
|
+
from datetime import datetime
|
|
125
|
+
|
|
126
|
+
from plyer import audio
|
|
127
|
+
|
|
128
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
129
|
+
plyer_audio_recording_path = str(Path.cwd() / f"pythonhere-recording-{timestamp}.3gp")
|
|
130
|
+
audio.file_path = plyer_audio_recording_path
|
|
131
|
+
audio.start()
|
|
132
|
+
plyer_audio_recording_status = {
|
|
133
|
+
"recording": True,
|
|
134
|
+
"path": plyer_audio_recording_path,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
Audio recording stop example:
|
|
138
|
+
from plyer import audio
|
|
139
|
+
|
|
140
|
+
audio.stop()
|
|
141
|
+
plyer_audio_recording_status = {
|
|
142
|
+
"recording": False,
|
|
143
|
+
"path": plyer_audio_recording_path,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
Audio recording replay example:
|
|
147
|
+
from plyer import audio
|
|
148
|
+
|
|
149
|
+
audio.play()
|
|
150
|
+
|
|
151
|
+
Camera example:
|
|
152
|
+
from plyer import camera
|
|
153
|
+
|
|
154
|
+
camera.take_picture(
|
|
155
|
+
filename="photo.jpg",
|
|
156
|
+
on_complete=lambda path: globals().__setitem__(
|
|
157
|
+
"plyer_camera_result",
|
|
158
|
+
{"path": path, "cancelled_or_empty": not bool(path)},
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
GPS example:
|
|
163
|
+
from plyer import gps
|
|
164
|
+
|
|
165
|
+
def on_location(**kwargs):
|
|
166
|
+
globals()["plyer_gps_last_location"] = dict(kwargs)
|
|
167
|
+
|
|
168
|
+
gps.configure(on_location=on_location)
|
|
169
|
+
gps.start()
|
|
170
|
+
|
|
171
|
+
GPS stop example:
|
|
172
|
+
from plyer import gps
|
|
173
|
+
|
|
174
|
+
gps.stop()
|
|
175
|
+
|
|
176
|
+
Battery example:
|
|
177
|
+
from plyer import battery
|
|
178
|
+
|
|
179
|
+
status = battery.status
|
|
180
|
+
|
|
181
|
+
Accelerometer example:
|
|
182
|
+
from plyer import accelerometer
|
|
183
|
+
|
|
184
|
+
accelerometer.enable()
|
|
185
|
+
acceleration = accelerometer.acceleration
|
|
186
|
+
|
|
187
|
+
Compass example:
|
|
188
|
+
from plyer import compass
|
|
189
|
+
|
|
190
|
+
compass.enable()
|
|
191
|
+
heading = compass.orientation
|
|
192
|
+
|
|
193
|
+
Plyer callback state pattern:
|
|
194
|
+
- For every asynchronous Plyer facade, store callback results in a named global
|
|
195
|
+
such as `plyer_filechooser_result`, `plyer_camera_result`, or
|
|
196
|
+
`plyer_gps_last_location`.
|
|
197
|
+
- If a Kivy UI is involved, update a visible status widget from the callback
|
|
198
|
+
using `Clock.schedule_once(...)` when needed.
|
|
199
|
+
- Do not treat a callback returning an empty selection or `None` as an
|
|
200
|
+
exception; report it as a cancelled/empty result.
|
|
201
|
+
- Keep file chooser behavior read-only unless the user explicitly asks to open,
|
|
202
|
+
process, copy, upload, delete, or overwrite selected files.
|
|
@@ -16,12 +16,12 @@ load_kv_string(r'''{code} ''', clear_style={clear_style})
|
|
|
16
16
|
|
|
17
17
|
SCREENSHOT_COMMAND_TEMPLATE = """
|
|
18
18
|
import sys
|
|
19
|
-
from
|
|
19
|
+
from tools_here import encoded_screenshot
|
|
20
20
|
sys.stderr.write(encoded_screenshot())
|
|
21
21
|
"""
|
|
22
22
|
|
|
23
23
|
PIN_COMMAND_TEMPLATE = """
|
|
24
|
-
from
|
|
24
|
+
from tools_here import pin_shortcut
|
|
25
25
|
pin_shortcut(script="{script}", label="{label}")
|
|
26
26
|
"""
|
|
27
27
|
|
pythonhere/server_here.py
CHANGED
pythonhere/tools_here.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Reusable tools for the live PythonHere runtime."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import threading
|
|
7
|
+
from concurrent.futures import Future
|
|
8
|
+
from concurrent.futures import TimeoutError as FutureTimeoutError
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
MAIN_THREAD_BRIDGE_TIMEOUT = 10.0
|
|
13
|
+
DEFAULT_UI_SNAPSHOT_DEPTH = 6
|
|
14
|
+
DEFAULT_UI_SNAPSHOT_WIDGETS = 200
|
|
15
|
+
MAX_UI_SNAPSHOT_CLASS = 120
|
|
16
|
+
MAX_UI_SNAPSHOT_DEPTH = 48
|
|
17
|
+
MAX_UI_SNAPSHOT_ERROR = 240
|
|
18
|
+
MAX_UI_SNAPSHOT_WIDGETS = 500
|
|
19
|
+
MAX_UI_SNAPSHOT_TEXT = 240
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MainThreadBridgeError(RuntimeError):
|
|
23
|
+
"""Kivy could not execute a requested main-thread operation."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MainThreadTimeoutError(MainThreadBridgeError):
|
|
27
|
+
"""Kivy did not execute a requested operation before its deadline."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _clock_boundary():
|
|
31
|
+
"""Return the Kivy Clock objects used by the worker-thread bridge."""
|
|
32
|
+
from kivy.clock import Clock, ClockNotRunningError
|
|
33
|
+
|
|
34
|
+
return Clock, ClockNotRunningError
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _run_on_main_thread(function, /, *args, _timeout=None, **kwargs):
|
|
38
|
+
"""Run ``function`` on Kivy's thread and synchronously return its result."""
|
|
39
|
+
if threading.current_thread() is threading.main_thread():
|
|
40
|
+
return function(*args, **kwargs)
|
|
41
|
+
|
|
42
|
+
timeout = MAIN_THREAD_BRIDGE_TIMEOUT if _timeout is None else _timeout
|
|
43
|
+
completion = Future()
|
|
44
|
+
|
|
45
|
+
def execute(_delta_time):
|
|
46
|
+
if not completion.set_running_or_notify_cancel():
|
|
47
|
+
return
|
|
48
|
+
try:
|
|
49
|
+
result = function(*args, **kwargs)
|
|
50
|
+
except BaseException as exc: # Ensure every invocation releases its waiter.
|
|
51
|
+
completion.set_exception(exc)
|
|
52
|
+
else:
|
|
53
|
+
completion.set_result(result)
|
|
54
|
+
|
|
55
|
+
def clock_ended(_event):
|
|
56
|
+
if completion.set_running_or_notify_cancel():
|
|
57
|
+
completion.set_exception(
|
|
58
|
+
MainThreadBridgeError(
|
|
59
|
+
"Kivy's Clock stopped before PythonHere could run the "
|
|
60
|
+
"requested main-thread operation"
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
clock, clock_not_running_error = _clock_boundary()
|
|
65
|
+
try:
|
|
66
|
+
event = clock.create_lifecycle_aware_trigger(
|
|
67
|
+
execute,
|
|
68
|
+
clock_ended,
|
|
69
|
+
timeout=0,
|
|
70
|
+
release_ref=False,
|
|
71
|
+
)
|
|
72
|
+
event()
|
|
73
|
+
except clock_not_running_error as exc:
|
|
74
|
+
raise MainThreadBridgeError(
|
|
75
|
+
"Kivy's Clock is not running; PythonHere cannot run the requested "
|
|
76
|
+
"main-thread operation"
|
|
77
|
+
) from exc
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
return completion.result(timeout=timeout)
|
|
81
|
+
except FutureTimeoutError:
|
|
82
|
+
# An implementation may itself raise TimeoutError. Preserve it when it
|
|
83
|
+
# completed before the bridge deadline instead of mislabelling it.
|
|
84
|
+
if completion.done():
|
|
85
|
+
return completion.result()
|
|
86
|
+
|
|
87
|
+
cancelled_before_start = completion.cancel()
|
|
88
|
+
event.cancel()
|
|
89
|
+
if cancelled_before_start:
|
|
90
|
+
detail = "The operation was cancelled before it started."
|
|
91
|
+
elif completion.done():
|
|
92
|
+
return completion.result()
|
|
93
|
+
else:
|
|
94
|
+
detail = "The operation had started and may still complete."
|
|
95
|
+
raise MainThreadTimeoutError(
|
|
96
|
+
f"Kivy did not complete the requested main-thread operation within "
|
|
97
|
+
f"{timeout:g} seconds. {detail} Do not blindly retry mutations."
|
|
98
|
+
) from None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _current_root(widget=None):
|
|
102
|
+
if widget is not None:
|
|
103
|
+
return widget
|
|
104
|
+
|
|
105
|
+
from kivy.app import App
|
|
106
|
+
from kivy.core.window import Window
|
|
107
|
+
|
|
108
|
+
app = App.get_running_app()
|
|
109
|
+
if app is not None and app.root is not None:
|
|
110
|
+
return app.root
|
|
111
|
+
if Window.children:
|
|
112
|
+
return Window.children[0]
|
|
113
|
+
raise RuntimeError("PythonHere has no visible root widget")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _json_number(value):
|
|
117
|
+
number = float(value)
|
|
118
|
+
if not math.isfinite(number):
|
|
119
|
+
return None
|
|
120
|
+
return round(number, 2)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _snapshot_limit(name: str, value: int, minimum: int, maximum: int) -> int:
|
|
124
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
125
|
+
raise TypeError(f"{name} must be an integer")
|
|
126
|
+
if not minimum <= value <= maximum:
|
|
127
|
+
raise ValueError(f"{name} must be between {minimum} and {maximum}")
|
|
128
|
+
return value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _optional_text(widget):
|
|
132
|
+
try:
|
|
133
|
+
value = widget.text
|
|
134
|
+
return (str(value) if value is not None else None), False
|
|
135
|
+
except AttributeError:
|
|
136
|
+
return None, False
|
|
137
|
+
except Exception: # A custom diagnostic property must not abort the snapshot.
|
|
138
|
+
return None, True
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _snapshot_widget(current, path):
|
|
142
|
+
children = list(reversed(getattr(current, "children", ())))
|
|
143
|
+
text, text_unavailable = _optional_text(current)
|
|
144
|
+
text_length = len(text) if text is not None else None
|
|
145
|
+
text_truncated = bool(text is not None and len(text) > MAX_UI_SNAPSHOT_TEXT)
|
|
146
|
+
if text_truncated:
|
|
147
|
+
text = text[: MAX_UI_SNAPSHOT_TEXT - 3] + "..."
|
|
148
|
+
|
|
149
|
+
class_name = type(current).__name__
|
|
150
|
+
class_truncated = len(class_name) > MAX_UI_SNAPSHOT_CLASS
|
|
151
|
+
if class_truncated:
|
|
152
|
+
class_name = class_name[: MAX_UI_SNAPSHOT_CLASS - 3] + "..."
|
|
153
|
+
|
|
154
|
+
item = {
|
|
155
|
+
"path": path,
|
|
156
|
+
"class": class_name,
|
|
157
|
+
"text": text,
|
|
158
|
+
"disabled": bool(getattr(current, "disabled", False)),
|
|
159
|
+
"pos": [_json_number(value) for value in current.pos],
|
|
160
|
+
"size": [_json_number(value) for value in current.size],
|
|
161
|
+
"child_count": len(children),
|
|
162
|
+
}
|
|
163
|
+
if class_truncated:
|
|
164
|
+
item["class_truncated"] = True
|
|
165
|
+
if text_unavailable:
|
|
166
|
+
item["text_unavailable"] = True
|
|
167
|
+
if text_length is not None:
|
|
168
|
+
item["text_length"] = text_length
|
|
169
|
+
if text_truncated:
|
|
170
|
+
item["text_truncated"] = True
|
|
171
|
+
return item, children
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _compact_inspection_error(exc):
|
|
175
|
+
error_type = type(exc).__name__
|
|
176
|
+
if len(error_type) > MAX_UI_SNAPSHOT_CLASS:
|
|
177
|
+
error_type = error_type[: MAX_UI_SNAPSHOT_CLASS - 3] + "..."
|
|
178
|
+
try:
|
|
179
|
+
message = str(exc)
|
|
180
|
+
except Exception:
|
|
181
|
+
message = "<error message unavailable>"
|
|
182
|
+
if len(message) > MAX_UI_SNAPSHOT_ERROR:
|
|
183
|
+
message = message[: MAX_UI_SNAPSHOT_ERROR - 3] + "..."
|
|
184
|
+
return {"type": error_type, "message": message}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _customize_snapshot_widget(current, default_item, callback):
|
|
188
|
+
if callback is None:
|
|
189
|
+
return default_item
|
|
190
|
+
|
|
191
|
+
callback_item = copy.deepcopy(default_item)
|
|
192
|
+
try:
|
|
193
|
+
item = callback(current, callback_item)
|
|
194
|
+
except Exception as exc:
|
|
195
|
+
default_item["inspection_error"] = _compact_inspection_error(exc)
|
|
196
|
+
return default_item
|
|
197
|
+
|
|
198
|
+
if item is None:
|
|
199
|
+
return None
|
|
200
|
+
if not isinstance(item, dict):
|
|
201
|
+
exc = TypeError("widget_record_callback must return a dictionary or None")
|
|
202
|
+
default_item["inspection_error"] = _compact_inspection_error(exc)
|
|
203
|
+
return default_item
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
json.dumps(item, allow_nan=False)
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
default_item["inspection_error"] = _compact_inspection_error(exc)
|
|
209
|
+
return default_item
|
|
210
|
+
return item
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _snapshot_ui(
|
|
214
|
+
widget=None,
|
|
215
|
+
*,
|
|
216
|
+
max_depth: int = DEFAULT_UI_SNAPSHOT_DEPTH,
|
|
217
|
+
max_widgets: int = DEFAULT_UI_SNAPSHOT_WIDGETS,
|
|
218
|
+
widget_record_callback=None,
|
|
219
|
+
) -> dict[str, Any]:
|
|
220
|
+
"""Return observable widget facts from Kivy's main thread."""
|
|
221
|
+
max_depth = _snapshot_limit(
|
|
222
|
+
"max_depth",
|
|
223
|
+
max_depth,
|
|
224
|
+
0,
|
|
225
|
+
MAX_UI_SNAPSHOT_DEPTH,
|
|
226
|
+
)
|
|
227
|
+
max_widgets = _snapshot_limit(
|
|
228
|
+
"max_widgets",
|
|
229
|
+
max_widgets,
|
|
230
|
+
1,
|
|
231
|
+
MAX_UI_SNAPSHOT_WIDGETS,
|
|
232
|
+
)
|
|
233
|
+
if widget_record_callback is not None and not callable(widget_record_callback):
|
|
234
|
+
raise TypeError("widget_record_callback must be callable or None")
|
|
235
|
+
widgets = []
|
|
236
|
+
pending = [("0", _current_root(widget), 0)]
|
|
237
|
+
omitted_descendants = False
|
|
238
|
+
visited_widgets = 0
|
|
239
|
+
|
|
240
|
+
while pending and visited_widgets < max_widgets:
|
|
241
|
+
path, current, depth = pending.pop()
|
|
242
|
+
visited_widgets += 1
|
|
243
|
+
default_item, children = _snapshot_widget(current, path)
|
|
244
|
+
item = _customize_snapshot_widget(
|
|
245
|
+
current,
|
|
246
|
+
default_item,
|
|
247
|
+
widget_record_callback,
|
|
248
|
+
)
|
|
249
|
+
if item is not None:
|
|
250
|
+
widgets.append(item)
|
|
251
|
+
|
|
252
|
+
if depth >= max_depth:
|
|
253
|
+
omitted_descendants = omitted_descendants or bool(children)
|
|
254
|
+
else:
|
|
255
|
+
for index in range(len(children) - 1, -1, -1):
|
|
256
|
+
pending.append((f"{path}/{index}", children[index], depth + 1))
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
"widgets": widgets,
|
|
260
|
+
"widget_count": len(widgets),
|
|
261
|
+
"truncated": omitted_descendants or bool(pending),
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def snapshot_ui(
|
|
266
|
+
widget=None,
|
|
267
|
+
*,
|
|
268
|
+
max_depth: int = DEFAULT_UI_SNAPSHOT_DEPTH,
|
|
269
|
+
max_widgets: int = DEFAULT_UI_SNAPSHOT_WIDGETS,
|
|
270
|
+
widget_record_callback=None,
|
|
271
|
+
) -> dict[str, Any]:
|
|
272
|
+
"""Return observable widget facts, marshaling worker calls to Kivy."""
|
|
273
|
+
return _run_on_main_thread(
|
|
274
|
+
_snapshot_ui,
|
|
275
|
+
widget,
|
|
276
|
+
max_depth=max_depth,
|
|
277
|
+
max_widgets=max_widgets,
|
|
278
|
+
widget_record_callback=widget_record_callback,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _runtime_info(app=None, root=None) -> dict[str, Any]:
|
|
283
|
+
import kivy
|
|
284
|
+
from kivy.app import App
|
|
285
|
+
from kivy.core.window import Window
|
|
286
|
+
from kivy.utils import platform
|
|
287
|
+
|
|
288
|
+
if app is None:
|
|
289
|
+
app = App.get_running_app()
|
|
290
|
+
root = _current_root(root)
|
|
291
|
+
return {
|
|
292
|
+
"app_class": type(app).__name__ if app is not None else None,
|
|
293
|
+
"root_class": type(root).__name__,
|
|
294
|
+
"kivy_version": kivy.__version__,
|
|
295
|
+
"platform": platform,
|
|
296
|
+
"window_size": [_json_number(value) for value in Window.size],
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def runtime_info(app=None, root=None) -> dict[str, Any]:
|
|
301
|
+
"""Return live Kivy runtime information, marshaling worker calls."""
|
|
302
|
+
return _run_on_main_thread(_runtime_info, app, root)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _save_screenshot(
|
|
306
|
+
path: str | Path = "pythonhere-screenshot.png",
|
|
307
|
+
widget=None,
|
|
308
|
+
) -> str:
|
|
309
|
+
from kivy.app import App
|
|
310
|
+
from window_here import save_screenshot as save_window_screenshot
|
|
311
|
+
|
|
312
|
+
destination = Path(path)
|
|
313
|
+
if not destination.is_absolute():
|
|
314
|
+
app = App.get_running_app()
|
|
315
|
+
upload_dir = getattr(app, "upload_dir", None) if app is not None else None
|
|
316
|
+
destination = Path(upload_dir or Path.cwd()) / destination
|
|
317
|
+
return save_window_screenshot(destination, widget=widget)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def save_screenshot(
|
|
321
|
+
path: str | Path = "pythonhere-screenshot.png",
|
|
322
|
+
widget=None,
|
|
323
|
+
) -> str:
|
|
324
|
+
"""Save visible content as PNG, marshaling worker calls to Kivy."""
|
|
325
|
+
return _run_on_main_thread(_save_screenshot, path, widget)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _encoded_screenshot(widget=None) -> str:
|
|
329
|
+
from window_here import encoded_screenshot as encode_window_screenshot
|
|
330
|
+
|
|
331
|
+
return encode_window_screenshot(widget=widget)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def encoded_screenshot(widget=None) -> str:
|
|
335
|
+
"""Encode visible content as PNG, marshaling worker calls to Kivy."""
|
|
336
|
+
return _run_on_main_thread(_encoded_screenshot, widget)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _pin_shortcut(script: str, label: str | None = None) -> None:
|
|
340
|
+
from android_here import pin_shortcut as pin_android_shortcut
|
|
341
|
+
|
|
342
|
+
shortcut_label = label or script.rstrip("/").rsplit("/", 1)[-1]
|
|
343
|
+
pin_android_shortcut(script=script, label=shortcut_label)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def pin_shortcut(script: str, label: str | None = None) -> None:
|
|
347
|
+
"""Request an Android shortcut, marshaling worker calls to Kivy."""
|
|
348
|
+
return _run_on_main_thread(_pin_shortcut, script, label)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
__all__ = (
|
|
352
|
+
"encoded_screenshot",
|
|
353
|
+
"MainThreadBridgeError",
|
|
354
|
+
"MainThreadTimeoutError",
|
|
355
|
+
"pin_shortcut",
|
|
356
|
+
"runtime_info",
|
|
357
|
+
"save_screenshot",
|
|
358
|
+
"snapshot_ui",
|
|
359
|
+
)
|
pythonhere/version_here.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.
|
|
1
|
+
__version__ = "0.3.0"
|
pythonhere/window_here.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""Utilities for working with Kivy window."""
|
|
2
2
|
|
|
3
|
-
import os
|
|
4
3
|
import time
|
|
5
4
|
from base64 import b64encode
|
|
6
5
|
from pathlib import Path
|
|
@@ -53,13 +52,29 @@ def load_kv_string(code: str, clear_style: bool):
|
|
|
53
52
|
app.update_ssh_server_namespace({"root": root})
|
|
54
53
|
|
|
55
54
|
|
|
56
|
-
def
|
|
57
|
-
"""
|
|
55
|
+
def save_screenshot(path: str | Path, widget=None) -> str:
|
|
56
|
+
"""Save a widget, or the visible window content, as a PNG."""
|
|
58
57
|
from kivy.core.window import Window # pylint: disable=import-outside-toplevel
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
59
|
+
if widget is None:
|
|
60
|
+
if not Window.children:
|
|
61
|
+
raise RuntimeError("PythonHere has no visible content to capture")
|
|
62
|
+
widget = Window.children[0]
|
|
63
|
+
|
|
64
|
+
destination = Path(path).expanduser().resolve()
|
|
65
|
+
if not destination.parent.is_dir():
|
|
66
|
+
raise FileNotFoundError(
|
|
67
|
+
f"Screenshot directory does not exist: {destination.parent}"
|
|
68
|
+
)
|
|
69
|
+
widget.export_to_png(str(destination))
|
|
70
|
+
return str(destination)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def encoded_screenshot(widget=None) -> str:
|
|
74
|
+
"""Return base64 encoded displayed image."""
|
|
75
|
+
path = Path(f"screenshot_{time.time()}.png").resolve()
|
|
76
|
+
try:
|
|
77
|
+
save_screenshot(path, widget=widget)
|
|
78
|
+
return b64encode(path.read_bytes()).decode()
|
|
79
|
+
finally:
|
|
80
|
+
path.unlink(missing_ok=True)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pythonhere
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Here is the Kivy based app to run code from the Jupyter magic %there
|
|
5
5
|
Author-email: b3b <ash.b3b@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
16
16
|
Requires-Python: >=3.10
|
|
17
17
|
Description-Content-Type: text/x-rst
|
|
18
18
|
License-File: LICENSE
|
|
19
|
-
Requires-Dist: herethere[magic]>=0.
|
|
19
|
+
Requires-Dist: herethere[magic]>=0.3.1
|
|
20
20
|
Requires-Dist: ipython
|
|
21
21
|
Requires-Dist: ipywidgets
|
|
22
22
|
Requires-Dist: Pillow
|
|
@@ -111,6 +111,15 @@ Commands to run locally::
|
|
|
111
111
|
jupyter notebook
|
|
112
112
|
|
|
113
113
|
|
|
114
|
+
Coding agents
|
|
115
|
+
-------------
|
|
116
|
+
|
|
117
|
+
PythonHere includes agent skills for working with the live app through the
|
|
118
|
+
``there`` command-line interface. See `Coding agent skills
|
|
119
|
+
<https://herethere.me/pythonhere/examples/coding-agents.html>`_ for skill setup
|
|
120
|
+
and practical examples.
|
|
121
|
+
|
|
122
|
+
|
|
114
123
|
Build Android app
|
|
115
124
|
-----------------
|
|
116
125
|
|