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.
@@ -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 window_here import encoded_screenshot
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 android_here import pin_shortcut
24
+ from tools_here import pin_shortcut
25
25
  pin_shortcut(script="{script}", label="{label}")
26
26
  """
27
27
 
pythonhere/server_here.py CHANGED
@@ -31,7 +31,7 @@ async def run_ssh_server(app):
31
31
  config = ServerConfig(
32
32
  host="",
33
33
  sftp_root=app.upload_dir,
34
- key_path=Path("./key.rsa").resolve(),
34
+ key_path=Path("./ssh_host_key").resolve(),
35
35
  **app.get_pythonhere_config(),
36
36
  )
37
37
  server = await start_server(
@@ -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
+ )
@@ -1 +1 @@
1
- __version__ = "0.2.2"
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 encoded_screenshot() -> str:
57
- """Return base64 encoded displayed image."""
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
- path = str(Path(f"screenshot_{time.time()}.png").resolve())
61
- Window.children[0].export_to_png(path)
62
- with open(path, "rb") as png_file:
63
- data = b64encode(png_file.read()).decode()
64
- os.remove(path)
65
- return data
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.2.2
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.2.3
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