diff --git a/.dockerignore b/.dockerignore index b0204c4..d62788c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,5 +17,8 @@ __pycache__ **/.* **/.venv +*.ipynb +**/*.ipynb + midistream herethere diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 82d157b..84dc39c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,17 @@ Changelog ========= +0.2.2 +----- + +This release adds ``%%there ai`` for AI-assisted PythonHere code generation. +From a notebook, describe what you want to build, modify, or inspect on the +connected device, and PythonHere generates a reviewable ``%%there`` cell for +the live Android/Kivy app. + +* Added PythonHere runtime context for ``herethere`` AI cell generation +* Registered the PythonHere AI prompt stack with ``%load_ext pythonhere`` + 0.2.1 ----- diff --git a/Dockerfile b/Dockerfile index e84e036..c912930 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,7 +30,7 @@ RUN cd /home/${NB_USER}/src && \ EOF RUN cd /home/${NB_USER}/examples && \ - jupytext --to ipynb *.md && \ - rm *.md && \ + find . -name "*.md" -exec jupytext --to ipynb {} + && \ + find . -name "*.md" -delete && \ rm -rf /home/${NB_USER}/.cache && \ rm -rf /home/${NB_USER}/src diff --git a/README.rst b/README.rst index f7087ef..8b94724 100644 --- a/README.rst +++ b/README.rst @@ -99,6 +99,7 @@ To build with `Buildozer `_, run in the sourc Related resources ----------------- -* `Kivy Remote Shell `_ : Remote SSH+Python interactive shell application * `herethere `_ : Library for interactive code execution, based on AsyncSSH * `AsyncSSH `_ : Asynchronous SSH for Python +* `Buildozer `_ : Tool used to build the Android APK. +* `python-for-android `_ : Build toolchain used by Buildozer to package Python applications for Android. diff --git a/buildozer.spec b/buildozer.spec index 81bc95d..7791144 100644 --- a/buildozer.spec +++ b/buildozer.spec @@ -37,14 +37,15 @@ requirements = # herethere dependencies asyncssh==2.23.0, python-dotenv==1.2.2, - herethere==0.2.1, + herethere==0.2.3, # asyncssh dependencies cryptography, typing_extensions, # additional packages pyjnius==1.7.0, - plyer==2.1.0, - able_recipe, + # Plyer PyPI version is outdated, so pin a newer GitHub commit. + git+https://github.com/kivy/plyer.git@f8c4e24c7e224360fd963939a7ea1814541a9456#egg=plyer, + able_recipe==1.0.17, midistream==0.3.1, # Pillow is a recipe, not a package Pillow, @@ -84,6 +85,12 @@ android.permissions = BLUETOOTH_SCAN, BLUETOOTH_CONNECT, BLUETOOTH_ADVERTISE, + android.permission.MANAGE_EXTERNAL_STORAGE, + android.permission.QUERY_ALL_PACKAGES, + android.permission.READ_MEDIA_IMAGES, + android.permission.PACKAGE_USAGE_STATS, + android.permission.POST_NOTIFICATIONS, + android.permission.RECORD_AUDIO, android.wakelock=True android.manifest.launch_mode = singleTask diff --git a/examples/README.md b/examples/README.md index 867514a..1128ea9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -12,11 +12,27 @@ jupyter: name: python3 --- +# Usage examples + ## Before run -These examples use connection settings from the **there.env** file. +Examples in this section use connection settings from the **there.env** file. + +**there.env** should be filled with values from the *PythonHere* app Settings section: + +```text +# PythonHere device IP address +THERE_HOST=127.0.0.1 + +# Port, as set in PythonHere app Settings section +THERE_PORT=8022 + +# Username, as set in PythonHere app Settings section +THERE_USERNAME=here -**there.env** should be filled with values from the PythonHere app Settings section. +# Password, as set in PythonHere app Settings section +THERE_PASSWORD=xxx +``` ## When using with Docker diff --git a/examples/commands.md b/examples/commands.md index abea826..294e294 100644 --- a/examples/commands.md +++ b/examples/commands.md @@ -1,22 +1,21 @@ --- -jupyter: - jupytext: - text_representation: - extension: .md - format_name: markdown - format_version: '1.2' - jupytext_version: 1.7.1 - kernelspec: - display_name: Python 3 - language: python - name: python3 +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.19.3 +kernelspec: + display_name: Python 3 + language: python + name: python3 --- # Jupyter magic commands Commands are provided by the *pythonhere* extension -```python +```{code-cell} %load_ext pythonhere ``` @@ -28,12 +27,12 @@ If argument is not provided, values are loaded from the **there.env** file. Config values could be overridden by environment variables with same names. -```python +```{code-cell} import os os.environ["THERE_PORT"] = "8022" ``` -```python +```{code-cell} %connect-there there.env ``` @@ -43,36 +42,70 @@ os.environ["THERE_PORT"] = "8022" THERE_HOST=127.0.0.1 # Port, as set in PythonHere app Settings section -THERE_PORT=8023 +THERE_PORT=8022 # Username, as set in PythonHere app Settings section -THERE_USERNAME=admin +THERE_USERNAME=here # Password, as set in PythonHere app Settings section THERE_PASSWORD=xxx ``` ++++ ## %there group of commands -```python +```{code-cell} %there --help ``` Default action for *%there*, if command is not specified - execute python code. ++++ ### there **Execute python code on the remote side.**
-```python +```{code-cell} python +:tags: ["hide-output"] %%there import this ``` +### get + +Evaluate a Python expression on the remote PythonHere side and return the value to the local notebook. + +```{code-cell} +%there get --help +``` + +```{code-cell} +%%there +device_status = { + "root_class": root.__class__.__name__ if "root" in globals() else None, + "child_count": len(root.children) if "root" in globals() else None, +} +``` + +```{code-cell} +status = %there get device_status +status +``` + +The expression is evaluated remotely, so it can also inspect live objects: + +```{code-cell} +root_size = %there get tuple(root.size) +root_size +``` + +Use `%there get` for small and inspectable values. For large text, binary data, +images, CSV files, or directories, write a remote file and use `%there download`. + ### kv -```python +```{code-cell} %there kv --help ``` @@ -82,8 +115,7 @@ are unloaded before command execution. If root widget is defined, it will replace App's current root. - -```python +```{code-cell} %%there kv Image: source: "../app/data/logo/logo-128.png" @@ -98,16 +130,16 @@ Image: ### shell -```python +```{code-cell} %there shell --help ``` -```python +```{code-cell} %%there shell pwd ``` -```python +```{code-cell} %%there shell for i in 1 2 3 do @@ -115,76 +147,120 @@ do done ``` - ++++ {"hideCode": false} + Listen to Android system logs in the background and show last two lines of output: - -```python +```{code-cell} %%there -bl 2 shell logcat ``` ### upload -```python +```{code-cell} %there upload --help ``` *upload* root directory is application current working directory. -```python -!touch some.ico script.py -!mkdir -p dir1/dir2 +```{code-cell} +%%bash +touch some.ico script.py +mkdir -p dir1/dir2 ``` -```python +```{code-cell} %there upload some.ico script.py dir1 ../ ``` -```python +```{code-cell} python %%there shell find ``` +### download + +```{code-cell} +%there download --help +``` + +Files are downloaded from the same remote SFTP root used by `%there upload`. + +With one remote path, the destination is the current local directory: + +```{code-cell} +%there download some.ico +``` + +Provide a local destination path explicitly: + +```{code-cell} +%there download some.ico ./downloaded-some.ico +``` + +Directories use the same command: + +```{code-cell} +%there download dir1 ./downloaded-dir1 +``` + +For generated data that is too large for `%there get`, save it remotely first: + +```{code-cell} +%%there +import csv + +rows = [ + {"name": "root_class", "value": root.__class__.__name__}, + {"name": "child_count", "value": len(root.children)}, +] + +with open("pythonhere-report.csv", "w", newline="") as file: + writer = csv.DictWriter(file, fieldnames=["name", "value"]) + writer.writeheader() + writer.writerows(rows) +``` + +```{code-cell} +%there download pythonhere-report.csv ./pythonhere-report.csv +``` ### pin -```python +```{code-cell} %there pin --help ``` -```python +```{code-cell} %there pin script.py --label "My script" ``` ### log -```python +```{code-cell} %there log --help ``` ```{note} -Since the command blocks and never ends, it is useful to run with --backgroud (-b) option +Since the command blocks and never ends, it is useful to run with --background (-b) option ``` -```python -%there -b -l 1 log -``` +Listen to Python logs in the background and show the last line of output: -```python -# wait, to make sure *log* cell connection is established before next cell is executed -import asyncio ; await asyncio.sleep(3) +```{code-cell} +%there -b -l 1 log ``` -```python -%%there +```{code-cell} +%%there --delay 4 from kivy.logger import Logger -Logger.info(f"Hello from the main cell") +Logger.info("Example: Hey, Logger!") ``` -### screeenshot +### screenshot -```python +```{code-cell} %there screenshot --help ``` @@ -193,6 +269,39 @@ Logger.info(f"Hello from the main cell") * display a result constrained to 200px width, * and save image to a local file: -```python +```{code-cell} %there -d 0.5 screenshot -w 200 -o /tmp/screenshot_test.png ``` + +## `%%there ai` + +Generate a reviewable `%%there` Python cell from a plain-language request. +The generated cell is inserted locally below the prompt cell. Review or edit it +before running it on the connected PythonHere device. + +```{code-cell} +%there ai --help +``` + +```{code-cell} +:tags: ["remove-output"] +%%there ai +Show Python version, Kivy platform, current working directory, +and root widget class. +``` + +Add an optional prompt section for one request: + +```{code-cell} +:tags: ["remove-output"] +%%there ai --prompts midi +Build a small MIDI note test UI with play, stop, and status controls. +``` + +Generate a replacement for the last executed Python `%%there` cell: + +```{code-cell} +:tags: ["remove-output"] +%%there ai --fix +Button was not imported. +``` diff --git a/examples/random_animation.md b/examples/random_animation.md index 71b3a06..4b3d901 100644 --- a/examples/random_animation.md +++ b/examples/random_animation.md @@ -25,7 +25,7 @@ jupyter: #:import ew kivy.uix.effectwidget : - moves: ("\(-_- )\\", "/( -_-)/") + moves: ("\\(-_- )\\", "/( -_-)/") font_size: 100 seq: 0 text: self.moves[int(self.seq)] @@ -77,5 +77,5 @@ RootLayout: ``` ```python -%there -d 1 screenshot -w 200 +%there -d 1 screenshot -w 250 ``` diff --git a/examples/there_ai/README.md b/examples/there_ai/README.md new file mode 100644 index 0000000..3ffea3a --- /dev/null +++ b/examples/there_ai/README.md @@ -0,0 +1,41 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.19.3 +kernelspec: + display_name: Python 3 + language: python + name: python3 +--- + +# `%%there ai` examples + +## Before run + +Examples in this section use connection settings from the **there.env** file. + +**there.env** should be filled with values from the *PythonHere* app Settings +section. + +`%%there ai` also needs model settings. Put them in **there_ai.env** next to the +notebook: + +```text +# Model name for the OpenAI-compatible chat/completions API +THERE_AI_MODEL= + +# API key for hosted providers; leave empty for local providers +THERE_AI_API_KEY= + +# OpenAI-compatible API base URL +THERE_AI_BASE_URL=https://api.openai.com/v1 + +# Sampling temperature for generated code +THERE_AI_TEMPERATURE=0.2 + +# Request timeout in seconds +THERE_AI_TIMEOUT=300 +``` diff --git a/examples/there_ai/guide.md b/examples/there_ai/guide.md new file mode 100644 index 0000000..2ee4fc4 --- /dev/null +++ b/examples/there_ai/guide.md @@ -0,0 +1,186 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.19.3 +kernelspec: + display_name: Python 3 + language: python + name: python3 +--- + +# Prompt-to-cell generation + +`%%there ai` turns a plain-language request into a new `%%there` Python cell. +The generated cell is inserted in the notebook below the prompt cell. Review or +edit it, then run it on the connected PythonHere device. + +The AI command is provided by the underlying `herethere` library. PythonHere +adds Android/Kivy prompt sections on top of it. For the generic library +behavior, see the `herethere` docs: +https://herethere.me/library/there_ai.html + +```{code-cell} +%load_ext pythonhere +%connect-there +``` + +## Configure an AI provider + +`%%there ai` uses an OpenAI-compatible chat API. Put provider settings in +`there_ai.env` next to the notebook: + +```text +# Model name for the OpenAI-compatible chat/completions API +THERE_AI_MODEL= + +# API key for hosted providers; leave empty for local providers +THERE_AI_API_KEY= + +# OpenAI-compatible API base URL +THERE_AI_BASE_URL=https://api.openai.com/v1 + +# Sampling temperature for generated code +THERE_AI_TEMPERATURE=0.2 + +# Request timeout in seconds +THERE_AI_TIMEOUT=300 +``` + +Only `THERE_AI_MODEL` is required for local providers that do not need an API +key. Hosted providers usually need `THERE_AI_API_KEY`. + +`THERE_AI_BASE_URL` defaults to `https://api.openai.com/v1`. +`THERE_AI_TEMPERATURE` defaults to `0.2`. +`THERE_AI_TIMEOUT` defaults to `300` seconds. + +Internally, `%%there ai` sends a POST request to +`${THERE_AI_BASE_URL}/chat/completions` with `model`, `messages`, and +`temperature`, and uses `Authorization: Bearer ${THERE_AI_API_KEY}` when an API +key is configured. See the +[OpenAI-compatible adapter](https://github.com/b3b/herethere/blob/master/herethere/there/ai/llm.py) +for the current implementation. + +Environment variables with the same names override values from `there_ai.env`: + +```{code-cell} +:tags: ["remove-output"] +%env THERE_AI_TIMEOUT=120 +``` + +Use a different settings file for the current notebook session: + +```{code-cell} +from herethere.there.ai import set_ai_config_path + +set_ai_config_path("there_ai.local.env") +``` + +Example local-provider settings: + +```text +THERE_AI_MODEL=qwen2.5-coder +THERE_AI_BASE_URL=http://localhost:11434/v1 +THERE_AI_TEMPERATURE=0.1 +THERE_AI_TIMEOUT=300 +``` + +## First generated cell + +Write the request in the cell body: + +```{code-cell} +:tags: ["remove-output"] +%%there ai +Inspect the live PythonHere runtime. +Print Python version, Kivy platform, current working directory, screen size, +and whether globals named app and root are available. +Store the structured result in pythonhere_ai_runtime_report. +``` + +The generated cell will be inserted locally as a `%%there` cell. Read it before +running it on the connected device. + +It may look like this: + +```python +%%there +# Generated locally by %%there ai. Review before running. +import os +import platform +import sys + +from kivy import platform as kivy_platform +from kivy.core.window import Window + +pythonhere_ai_runtime_report = { + "python": sys.version, + "platform": platform.platform(), + "kivy_platform": kivy_platform, + "cwd": os.getcwd(), + "window_size": tuple(Window.size), + "has_app": "app" in globals(), + "has_root": "root" in globals(), + "root_class": root.__class__.__name__ if "root" in globals() else None, +} + +for key, value in pythonhere_ai_runtime_report.items(): + print(f"{key}: {value}") +``` + +## PythonHere prompt context + +When you load `%load_ext pythonhere`, PythonHere registers prompt sections for +Kivy, Android, Pyjnius, permissions, packages, media, and Plyer. Normal +`%%there ai` requests use that context automatically. + +For tasks that need extra context, add a prompt section for one request: + +```{code-cell} +:tags: ["remove-output"] +%%there ai --prompts able +Build a BLE scanner prototype that lists discovered devices with name, address, +RSSI, and last-seen time. +``` + +For the full prompt list, source links, custom prompt registration, and prompt +composition rules, see [Prompt sections](prompts.md). + +## Fix a previous cell + +`%%there ai --fix` generates a replacement `%%there` cell. The AI request uses +the active system prompt stack plus the built-in `fix` prompt section. Its user +message includes the last executed Python `%%there` magic line, that cell body, +and the text you write in the `--fix` cell. It does not automatically include +previous output, traceback text, screenshots, remote variables, or live device +state. Paste the important error message or requested change into the `--fix` +cell body. The old cell is not changed. + +Run a broken cell: + +```{code-cell} +%%there +root.clear_widgets() +root.add_widget(Button(text="Click me)) +``` + +Then ask for a fix: + +```{code-cell} +:tags: ["remove-output"] +%%there ai --fix +SyntaxError +``` + +```{code-cell} +%%there +# Generated locally by %%there ai. Review before running. +# AI mode: fix +# Fix: close the string quote and import Button before using it. +from kivy.uix.button import Button + +root.clear_widgets() +root.add_widget(Button(text="Click me")) +``` diff --git a/examples/there_ai/intro_0.2.2.md b/examples/there_ai/intro_0.2.2.md new file mode 100644 index 0000000..b767805 --- /dev/null +++ b/examples/there_ai/intro_0.2.2.md @@ -0,0 +1,1088 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.19.3 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +# PythonHere 0.2.2 release intro + +The intro was generated with `%%there ai` to show the PythonHere 0.2.x release features as a short audiovisual app. + +Watch the result as a YouTube Short: https://www.youtube.com/shorts/ExN0YnTljVc + +## Connect + +```{code-cell} ipython3 +%load_ext pythonhere +%connect-there +``` + +## Prompt used to generate the intro + +````{code-cell} ipython3 +%%there ai --prompts midi +You are the PythonHere 0.2.x release. +Express yourself as a short audiovisual demoscene-style intro. + +Goal: generate a compact audiovisual intro. + +Your release features: + +```python +announcements = [ + { + "title": "PythonHere 0.2.*", + "subtitle": "the stack wakes again" + }, + { + "title": "GitHub Releases", + "subtitle": "APK builds are back" + }, + { + "title": "%%there get {variable}", + "subtitle": "get remote variables" + }, + { + "title": "%%there download {file}", + "subtitle": "pull files from the target" + }, + { + "title": "%%there ai", + "subtitle": "prompt -> cell" + }, + { + "title": "Generate -> Edit -> Run", + "subtitle": "generated cells stay under your control" + }, + { + "title": "%%there ai --prompts masterpiece", + "subtitle": "custom context / custom style" + }, + { + "title": "%%there ai --fix", + "subtitle": "failure becomes feedback" + }, +] +``` + +Creative direction: +Make it feel like a compact demoscene intro built out of Kivy itself. + +Use: + +* dark background +* neon / terminal / retro-futurist mood +* rhythmic motion +* Kivy widgets used playfully as visual objects +* text effects +* short readable text beats +* autoplay when ready +* endless loop after the intro sequence +* single feature display at a time + +Title is: PythonHere 0.2.x. + +Make the version beat-synced: + +* show `0.2.0` +* then `0.2.1` +* then `0.2.2` +* then `0.2.x` +* repeat this pattern every 16 sequencer steps + +Make `Python` and `Here` with separate colors: + +* Python: `#306998` +* Here: `#FFD43B` +* version: smaller cyan/white text + +Core timing model: +Use one explicit 8-second / 64-step timing grid. + +Constants must be: + +```python +INTRO_SECONDS = 8.0 +STEPS_PER_LOOP = 64 +TICK_SECONDS = INTRO_SECONDS / STEPS_PER_LOOP +VISUAL_FPS = 30.0 + +FEATURES_PER_LOOP = 8 +FEATURE_STEPS = STEPS_PER_LOOP // FEATURES_PER_LOOP # 8 + +BLOCKS_PER_LOOP = 4 +BLOCK_STEPS = STEPS_PER_LOOP // BLOCKS_PER_LOOP # 16 +``` + +Everything must derive from this sequencer grid: + +* loop step: `0..63` +* musical block: `step // BLOCK_STEPS` +* local block step: `step % BLOCK_STEPS` +* feature card: `step // FEATURE_STEPS` +* beat marker: `step % BLOCK_STEPS` +* version character: `(step % BLOCK_STEPS) // 4` + +The intro must be perfectly looped in 8 seconds: + +* 64 sequencer steps total +* 8 feature cards total +* each feature card lasts exactly 8 sequencer steps +* 4 musical blocks total +* each musical block lasts exactly 16 sequencer steps +* loop returns exactly to the initial visual and musical state at step 0 + +Sequencer / MIDI clock: +The intro should be built around a live MIDI sequencer. + +The sequencer step is the master state. +The Kivy Clock interval that calls `midi_tick` is the sequencer clock. +`midi_tick` advances exactly one integer step each tick. +Visuals must follow the sequencer state. + +Do not derive visual state directly from `loop_start_time`. +Do not use `phase * len(announcements)` for feature cards. +Do not use independent wall-clock animation as the master. + +Keep this state: + +```python +self.step +self.last_played_step +self.last_tick_time +``` + +Use this helper for visual timing: + +```python +def _loop_position(self): + elapsed_since_tick = max(0.0, time.perf_counter() - self.last_tick_time) + micro = min(0.999, elapsed_since_tick / TICK_SECONDS) + + step_float = self.last_played_step + micro + step_float %= STEPS_PER_LOOP + + step = int(step_float) + phase = step_float / STEPS_PER_LOOP + + return step, micro, phase +``` + +In `midi_tick`: + +* if stopped, return `False` +* at the start of each sequencer tick, decrement active note lifetimes +* send `note_off` for notes whose `remaining_steps` reaches zero +* then generate MIDI events for the current integer `step` +* then send the new `note_on` events +* then add those notes to the active note list with `remaining_steps` +* then set `last_played_step = played_step` +* then set `last_tick_time = time.perf_counter()` +* then advance `step = (step + 1) % STEPS_PER_LOOP` + +In `visual_tick`: + +* never schedule MIDI +* never play MIDI +* never stop MIDI +* only read sequencer state +* call `_loop_position()` +* derive all visual state from `visual_step`, `micro`, and `phase` + +Use this visual timing pattern: + +```python +visual_step, micro, phase = self._loop_position() + +feature_index = min( + len(announcements) - 1, + visual_step // FEATURE_STEPS, +) + +local_feature_phase = ( + (visual_step % FEATURE_STEPS) + micro +) / FEATURE_STEPS + +active16 = visual_step % BLOCK_STEPS + +version_char = ["0", "1", "2", "x"][ + (visual_step % BLOCK_STEPS) // 4 +] +``` + +Requirements: + +* The intro should be built around a live MIDI sequencer. +* The sequencer state is the master clock. +* Visuals follow the sequencer state. +* Portrait Android mode. +* Text should always fit screen and containers. +* The result should not look like a normal app screen. +* Intro should be perfectly looped in 8 seconds. +* Should show all 8 features in 8 seconds. +* Should loop to exactly the initial state. +* Avoid audio slowdown by keeping `midi_tick` very lightweight. + +Audio: +Generate and play a live MIDI looping synthpop positive rhythmic tune. + +The tune must follow the 64-step grid: + +* 64 steps per loop +* 4 musical blocks +* 16 steps per musical block +* use `block = step // BLOCK_STEPS` +* use `local = step % BLOCK_STEPS` +* drums, bass, lead, and chords should all loop exactly at step 64 +* keep the MIDI event count per tick small +* avoid heavy computation inside `midi_tick` + +Note lifetime: +Do not use `Clock.schedule_once` for every `note_off`. + +Instead, keep a list of active notes with `remaining_steps`. + +At the start of each sequencer tick: + +* decrement `remaining_steps` +* send `note_off` for notes whose `remaining_steps` reaches zero + +Then send new `note_on` events and add them to the active note list. + +This keeps MIDI event scheduling deterministic and avoids many tiny callbacks. + +Kivy visual concept: +Use ordinary Kivy widgets in extraordinary ways. + +For example: + +* Labels as glowing title cards +* Buttons as pulsing blocks or beat markers +* Sliders as oscilloscopes +* ProgressBars as equalizers +* BoxLayouts as moving panels +* canvas lines, grids, particles, waves, scanlines, or glow-like effects + +The result should not look like a normal app screen. +It should look like a tiny audiovisual intro made from the runtime’s own UI system. + +Kivy scheduling: +Use Kivy Clock carefully. + +Allowed: + +* one `Clock.schedule_interval` for the MIDI sequencer tick +* one `Clock.schedule_interval` for lightweight visual refresh + +Not allowed: + +* many scheduled callbacks +* `Clock.schedule_once` for every MIDI note-off +* rebuilding layouts every frame +* creating/destroying widgets during animation +* recalculating installed distributions during animation +* calling `texture_update` every frame +* scheduling MIDI events from the visual update function +* creating new canvas instructions during animation +* creating large temporary lists during animation + +Performance: + +* `midi_tick` must be extremely lightweight +* package scanning must happen once before animation starts +* runtime ticker string must be built once before animation starts +* widgets must be created once +* canvas instructions must be created once +* animation should update existing widget properties and existing canvas instructions only +* visual update may set existing `Color.rgba`, `Line.points`, `Rectangle.pos`, `Rectangle.size`, `Label.text`, `Label.opacity`, `Slider.value`, and `ProgressBar.value` +* avoid expensive work in both scheduled callbacks +* never let visual effects slow down MIDI timing + +Effects: +Use canvas for effects. + +Runtime scroller: +Create a continuous demoscene-style ticker at the bottom or edge of the screen. + +The scroller must use real runtime information, not hardcoded fake package versions. + +Build it dynamically once before animation starts: + +* Use `sys.version_info` for the real Python interpreter version +* Use `[f"{dist.metadata['Name']}=={dist.version}" for dist in distributions()]` for installed packages +* Format the ticker like: `PYTHON==3.x.x | Kivy==2.3.1 | cffi==... | ...` + +Ticker timing: + +* ticker starts outside the right edge of the screen +* text is not visible at the first frame +* ticker moves from right to left +* ticker position is derived from sequencer `phase` +* ticker returns to exactly the initial outside-right position when `phase` wraps to `0` +* perfect loop sync is more important than showing every package clearly +* if the ticker text is very long, it does not need to fully reveal every package in one loop + +Use this ticker motion model: + +```python +ticker.x = self.ui.width - phase * (self.ui.width + self.ui._ticker_width) +``` +```` + +## Generated intro code + +```{code-cell} ipython3 +%%there +# Generated locally by %%there ai. Review before running. +from importlib.metadata import distributions +import math +import sys +import time + +from kivy.clock import Clock +from kivy.graphics import Color, Ellipse, Line, Rectangle +from kivy.lang import Builder +from kivy.logger import Logger +from kivy.metrics import dp, sp +from kivy.uix.button import Button +from kivy.uix.floatlayout import FloatLayout +from kivy.uix.popup import Popup +from kivy.uix.progressbar import ProgressBar +from kivy.uix.label import Label + +from midistream import Synthesizer, MIDIException, ReverbPreset +from midistream.helpers import ( + Control, + midi_control_change, + midi_instruments, + midi_note_off, + midi_note_on, + midi_program_change, +) + +INTRO_SECONDS = 8.0 +STEPS_PER_LOOP = 64 +TICK_SECONDS = INTRO_SECONDS / STEPS_PER_LOOP +VISUAL_FPS = 30.0 + +FEATURES_PER_LOOP = 8 +FEATURE_STEPS = STEPS_PER_LOOP // FEATURES_PER_LOOP # 8 + +BLOCKS_PER_LOOP = 4 +BLOCK_STEPS = STEPS_PER_LOOP // BLOCKS_PER_LOOP # 16 + +announcements = [ + {"title": "PythonHere 0.2.*", "subtitle": "the stack wakes again"}, + {"title": "GitHub Releases", "subtitle": "APK builds are back"}, + {"title": "%%there get {variable}", "subtitle": "get remote variables"}, + {"title": "%%there download {path}", "subtitle": "pull files from the target"}, + {"title": "%%there ai", "subtitle": "prompt -> cell"}, + {"title": "Generate -> Edit -> Run", "subtitle": "generated cells stay under your control"}, + {"title": "%%there ai --prompts masterpiece", "subtitle": "custom context / custom style"}, + {"title": "%%there ai --fix", "subtitle": "failure becomes feedback"}, +] + +try: + previous_intro = globals().get("pythonhere_intro_controller") + if previous_intro is not None: + previous_intro.stop(close_midi=False) +except Exception: + Logger.exception("PythonHere: Could not stop previous intro") + +if "midistream_active_notes" not in globals(): + midistream_active_notes = set() +if "midistream_used_channels" not in globals(): + midistream_used_channels = set() + +installed_runtime_packages = [f"{dist.metadata['Name']}=={dist.version}" for dist in distributions()] +pythonhere_intro_ticker_text = ( + f"PYTHON=={sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + + " | " + + " | ".join(installed_runtime_packages) +) + +pythonhere_intro_state = { + "ok": False, + "stage": "initializing", + "message": "Building PythonHere 0.2.x intro", + "error": None, +} + + +def get_synthesizer(): + global synthesizer + if "synthesizer" not in globals() or synthesizer is None: + synthesizer = Synthesizer() + return synthesizer + + +class IntroRoot(FloatLayout): + pass + + +KV = """ +#:import dp kivy.metrics.dp +#:import sp kivy.metrics.sp + +: + Label: + id: deck_label + text: "LIVE GRID 64 / MIDI CLOCK / KIVY CORE" + size_hint: 0.94, None + height: dp(22) + pos_hint: {"center_x": 0.5, "top": 0.998} + color: 0.25, 0.95, 1.0, 0.82 + font_size: sp(10) + bold: True + halign: "center" + valign: "middle" + text_size: self.size + + BoxLayout: + id: title_band + orientation: "horizontal" + padding: dp(4), 0 + spacing: dp(2) + size_hint: 0.96, None + height: dp(74) + pos_hint: {"center_x": 0.5, "top": 0.965} + + Label: + id: python_label + text: "Python" + color: 0.188, 0.412, 0.596, 1 + font_size: sp(32) + bold: True + halign: "right" + valign: "middle" + text_size: self.size + size_hint_x: 0.42 + + Label: + id: here_label + text: "Here" + color: 1.0, 0.831, 0.231, 1 + font_size: sp(32) + bold: True + halign: "left" + valign: "middle" + text_size: self.size + size_hint_x: 0.30 + + Label: + id: version_label + text: "0.2.0" + color: 0.65, 1.0, 1.0, 1 + font_size: sp(18) + bold: True + halign: "left" + valign: "middle" + text_size: self.size + size_hint_x: 0.28 + + BoxLayout: + id: feature_panel + orientation: "vertical" + padding: dp(12), dp(8) + spacing: dp(4) + size_hint: 0.92, 0.30 + pos_hint: {"center_x": 0.5, "center_y": 0.595} + canvas.before: + Color: + rgba: 0.02, 0.09, 0.12, 0.78 + Rectangle: + pos: self.pos + size: self.size + Color: + rgba: 0.0, 0.85, 1.0, 0.28 + Line: + rectangle: self.x, self.y, self.width, self.height + width: 1.2 + + Label: + id: feature_title + text: "" + color: 0.70, 1.0, 1.0, 1 + font_size: sp(22) + bold: True + halign: "center" + valign: "middle" + text_size: self.width - dp(18), self.height + shorten: True + shorten_from: "right" + size_hint_y: 0.58 + + Label: + id: feature_subtitle + text: "" + color: 1.0, 0.88, 0.42, 1 + font_size: sp(15) + halign: "center" + valign: "middle" + text_size: self.width - dp(18), self.height + shorten: True + shorten_from: "right" + size_hint_y: 0.42 + + BoxLayout: + id: osc_bank + orientation: "vertical" + spacing: dp(4) + size_hint: 0.88, None + height: dp(86) + pos_hint: {"center_x": 0.5, "center_y": 0.315} + + Slider: + id: osc_a + min: 0 + max: 100 + value: 0 + + Slider: + id: osc_b + min: 0 + max: 100 + value: 0 + + Slider: + id: osc_c + min: 0 + max: 100 + value: 0 + + BoxLayout: + id: meter_bank + orientation: "vertical" + spacing: dp(2) + size_hint: 0.88, None + height: dp(70) + pos_hint: {"center_x": 0.5, "y": 0.145} + + BoxLayout: + id: beat_row + orientation: "horizontal" + spacing: dp(2) + size_hint: 0.94, None + height: dp(34) + pos_hint: {"center_x": 0.5, "y": 0.085} + + Label: + id: status_label + text: "arming sequencer" + size_hint: 0.96, None + height: dp(24) + pos_hint: {"center_x": 0.5, "y": 0.048} + color: 0.55, 1.0, 0.84, 0.9 + font_size: sp(10) + bold: True + halign: "center" + valign: "middle" + text_size: self.size + shorten: True + shorten_from: "right" + + Label: + id: ticker + text: "" + size_hint: None, None + height: dp(24) + x: root.width + y: dp(3) + color: 0.20, 1.0, 0.58, 0.92 + font_size: sp(10) + bold: True + halign: "left" + valign: "middle" + text_size: None, self.height + +IntroRoot: +""" + + +def build_midi_pattern(): + roots = [48, 45, 41, 43] + chords = [ + [60, 64, 67], + [57, 60, 64], + [53, 57, 60], + [55, 59, 62], + ] + leads = [ + [72, 74, 76, 79, 76, 74], + [69, 72, 76, 81, 76, 72], + [65, 69, 72, 77, 72, 69], + [67, 71, 74, 79, 83, 79], + ] + lead_slots = [2, 5, 7, 10, 13, 15] + bass_slots = [0, 3, 6, 8, 11, 14] + pattern = [[] for _ in range(STEPS_PER_LOOP)] + + for step in range(STEPS_PER_LOOP): + block = step // BLOCK_STEPS + local = step % BLOCK_STEPS + root_note = roots[block] + + if local in (0, 8): + pattern[step].append((9, 36, 108, 1)) + if local in (4, 12): + pattern[step].append((9, 38, 96, 1)) + if local % 2 == 0: + pattern[step].append((9, 42, 62, 1)) + if local == 14: + pattern[step].append((9, 46, 72, 1)) + + if local in bass_slots: + bass_note = root_note if local in (0, 6, 8, 14) else root_note + 7 + pattern[step].append((0, bass_note, 92, 2)) + + if local in (0, 8): + for chord_note in chords[block]: + pattern[step].append((2, chord_note, 50, 7)) + + if local in lead_slots: + lead_index = lead_slots.index(local) + pattern[step].append((1, leads[block][lead_index], 78, 2)) + + return pattern + + +class PythonHereIntroController: + def __init__(self, ui, ticker_text, cards): + self.ui = ui + self.cards = cards + self.ticker_text = ticker_text + self.step = 0 + self.last_played_step = 0 + self.last_tick_time = time.perf_counter() + self.running = False + self.midi_enabled = True + self.midi_error = None + self.active_notes = [] + self.used_channels = {0, 1, 2, 9} + self.midi_event = None + self.visual_event = None + self.current_feature_index = None + self.current_version_text = None + self.note_pattern = build_midi_pattern() + self.beat_buttons = [] + self.meters = [] + self.particle_items = [] + self.grid_lines = [] + self.scan_color = None + self.scan_rect = None + self.wave_color = None + self.wave_line = None + self.bg_rect = None + self.bg_color = None + self._setup_widgets() + self._setup_canvas() + self.ui.bind(pos=self._resize_canvas, size=self._resize_canvas) + self._resize_canvas() + + def _setup_widgets(self): + self.ui.ids.ticker.text = self.ticker_text + self.ui.ids.ticker.texture_update() + self.ui._ticker_width = max(1, int(self.ui.ids.ticker.texture_size[0])) + self.ui.ids.ticker.width = self.ui._ticker_width + + for index in range(16): + btn = Button( + text=f"{index:02d}", + font_size=sp(9), + bold=True, + background_normal="", + background_down="", + color=(0.65, 1.0, 1.0, 0.85), + ) + btn.background_color = (0.02, 0.12, 0.16, 0.70) + self.ui.ids.beat_row.add_widget(btn) + self.beat_buttons.append(btn) + + for index in range(8): + meter = ProgressBar(max=100, value=0) + self.ui.ids.meter_bank.add_widget(meter) + self.meters.append(meter) + + def _setup_canvas(self): + with self.ui.canvas.before: + self.bg_color = Color(0.005, 0.008, 0.018, 1.0) + self.bg_rect = Rectangle(pos=self.ui.pos, size=self.ui.size) + + for _ in range(10): + color = Color(0.0, 0.45, 0.58, 0.10) + line = Line(points=[0, 0, 0, 0], width=0.8) + self.grid_lines.append((color, line)) + + self.wave_color = Color(0.0, 0.95, 1.0, 0.52) + self.wave_line = Line(points=[], width=1.4) + + self.scan_color = Color(0.2, 1.0, 0.75, 0.10) + self.scan_rect = Rectangle(pos=(0, 0), size=(1, dp(3))) + + for i in range(28): + color = Color(0.20, 1.0, 0.75, 0.0) + dot = Ellipse(pos=(0, 0), size=(dp(2), dp(2))) + self.particle_items.append((color, dot, i)) + + def _resize_canvas(self, *args): + w = max(1.0, float(self.ui.width)) + h = max(1.0, float(self.ui.height)) + self.bg_rect.pos = self.ui.pos + self.bg_rect.size = self.ui.size + + for i, pair in enumerate(self.grid_lines): + color, line = pair + if i < 5: + x = w * (i + 1) / 6.0 + line.points = [x, 0, x, h] + else: + y = h * (i - 4) / 6.0 + line.points = [0, y, w, y] + color.rgba = (0.0, 0.55, 0.70, 0.08) + + def _program_synth(self): + global midistream_used_channels + synth = get_synthesizer() + synth.volume = 86 + synth.reverb = ReverbPreset.ROOM + setup = [] + setup += midi_program_change(38, channel=0) + setup += midi_program_change(81, channel=1) + setup += midi_program_change(88, channel=2) + setup += midi_control_change(Control.volume, 104, channel=0) + setup += midi_control_change(Control.volume, 92, channel=1) + setup += midi_control_change(Control.volume, 70, channel=2) + setup += midi_control_change(Control.pan, 44, channel=0) + setup += midi_control_change(Control.pan, 82, channel=1) + setup += midi_control_change(Control.pan, 64, channel=2) + synth.write(setup) + midistream_used_channels.update(self.used_channels) + + def _safe_note_off_list(self, notes): + global midistream_active_notes + if not notes or not self.midi_enabled: + return + cmd = [] + for channel, note in notes: + cmd += midi_note_off(note, channel=channel, velocity=0) + get_synthesizer().write(cmd) + for channel, note in notes: + midistream_active_notes.discard((channel, note)) + + def all_sound_off(self): + global midistream_active_notes + if not self.midi_enabled: + self.active_notes = [] + midistream_active_notes.clear() + return + try: + off_notes = [(channel, note) for channel, note, _remaining in self.active_notes] + if off_notes: + self._safe_note_off_list(off_notes) + cmd = [] + for channel in sorted(self.used_channels): + cmd += midi_control_change(Control.all_sound_off, 0, channel=channel) + get_synthesizer().write(cmd) + except Exception: + Logger.exception("PythonHere: Could not silence intro MIDI") + self.active_notes = [] + midistream_active_notes.clear() + + def start(self): + pythonhere_intro_state.update( + ok=True, + stage="running", + message="PythonHere 0.2.x intro running", + error=None, + ) + self.running = True + try: + self._program_synth() + self.ui.ids.status_label.text = "MIDI locked / 64 step loop / visual clock slaved" + except Exception as exc: + self.midi_enabled = False + self.midi_error = f"{type(exc).__name__}: {exc}" + pythonhere_intro_state.update( + ok=False, + stage="midi_init", + message="Visual intro running without MIDI", + error=self.midi_error, + ) + self.ui.ids.status_label.text = "MIDI init failed / visual clock continues" + Logger.exception("PythonHere: Could not initialize MIDI intro") + + self.visual_tick(0) + self.midi_tick(0) + self.midi_event = Clock.schedule_interval(self.midi_tick, TICK_SECONDS) + self.visual_event = Clock.schedule_interval(self.visual_tick, 1.0 / VISUAL_FPS) + + def stop(self, close_midi=False): + self.running = False + if self.midi_event is not None: + self.midi_event.cancel() + self.midi_event = None + if self.visual_event is not None: + self.visual_event.cancel() + self.visual_event = None + self.all_sound_off() + if close_midi: + try: + synth = globals().get("synthesizer") + if synth is not None: + synth.close() + globals()["synthesizer"] = None + except Exception: + Logger.exception("PythonHere: Could not close synthesizer") + pythonhere_intro_state.update( + ok=True, + stage="stopped", + message="PythonHere intro stopped", + error=None, + ) + + def _loop_position(self): + elapsed_since_tick = max(0.0, time.perf_counter() - self.last_tick_time) + micro = min(0.999, elapsed_since_tick / TICK_SECONDS) + + step_float = self.last_played_step + micro + step_float %= STEPS_PER_LOOP + + step = int(step_float) + phase = step_float / STEPS_PER_LOOP + + return step, micro, phase + + def midi_tick(self, dt): + global midistream_active_notes, midistream_used_channels + if not self.running: + return False + + played_step = self.step + + if self.midi_enabled: + try: + expired = [] + survivors = [] + for channel, note, remaining_steps in self.active_notes: + remaining_steps -= 1 + if remaining_steps <= 0: + expired.append((channel, note)) + else: + survivors.append((channel, note, remaining_steps)) + self.active_notes = survivors + + if expired: + self._safe_note_off_list(expired) + + events = self.note_pattern[played_step] + if events: + cmd = [] + for channel, note, velocity, _duration in events: + cmd += midi_note_on(note, channel=channel, velocity=velocity) + get_synthesizer().write(cmd) + + for channel, note, _velocity, duration in events: + self.active_notes.append((channel, note, duration)) + midistream_active_notes.add((channel, note)) + midistream_used_channels.add(channel) + self.used_channels.add(channel) + + except MIDIException as exc: + self.midi_enabled = False + self.midi_error = f"{type(exc).__name__}: {exc}" + pythonhere_intro_state.update( + ok=False, + stage="midi_tick", + message="MIDI disabled after sequencer error", + error=self.midi_error, + ) + self.ui.ids.status_label.text = "MIDI error / visual sequencer still loops" + Logger.exception("PythonHere: MIDI intro tick failed") + except Exception as exc: + self.midi_enabled = False + self.midi_error = f"{type(exc).__name__}: {exc}" + pythonhere_intro_state.update( + ok=False, + stage="midi_tick", + message="MIDI disabled after sequencer error", + error=self.midi_error, + ) + self.ui.ids.status_label.text = "MIDI error / visual sequencer still loops" + Logger.exception("PythonHere: MIDI intro tick failed") + + self.last_played_step = played_step + self.last_tick_time = time.perf_counter() + self.step = (self.step + 1) % STEPS_PER_LOOP + pythonhere_intro_state["step"] = played_step + pythonhere_intro_state["next_step"] = self.step + return True + + def visual_tick(self, dt): + if not self.running: + return False + + visual_step, micro, phase = self._loop_position() + + feature_index = min( + len(self.cards) - 1, + visual_step // FEATURE_STEPS, + ) + + local_feature_phase = ( + (visual_step % FEATURE_STEPS) + micro + ) / FEATURE_STEPS + + active16 = visual_step % BLOCK_STEPS + + version_char = ["0", "1", "2", "x"][ + (visual_step % BLOCK_STEPS) // 4 + ] + version_text = "0.2." + version_char + + block = visual_step // BLOCK_STEPS + local = visual_step % BLOCK_STEPS + + if feature_index != self.current_feature_index: + card = self.cards[feature_index] + self.ui.ids.feature_title.text = card["title"] + self.ui.ids.feature_subtitle.text = card["subtitle"] + title_len = len(card["title"]) + self.ui.ids.feature_title.font_size = sp(18 if title_len > 28 else 22) + self.current_feature_index = feature_index + + if version_text != self.current_version_text: + self.ui.ids.version_label.text = version_text + self.current_version_text = version_text + + fade_in = min(1.0, local_feature_phase * 5.5) + fade_out = min(1.0, (1.0 - local_feature_phase) * 5.5) + panel_alpha = max(0.0, min(fade_in, fade_out)) + pulse = 0.5 + 0.5 * math.sin(2.0 * math.pi * ((active16 + micro) / BLOCK_STEPS)) + + self.ui.ids.feature_panel.opacity = 0.62 + 0.38 * panel_alpha + self.ui.ids.python_label.opacity = 0.82 + 0.18 * pulse + self.ui.ids.here_label.opacity = 0.82 + 0.18 * (1.0 - pulse) + self.ui.ids.version_label.color = ( + 0.72 + 0.20 * pulse, + 1.0, + 1.0, + 1.0, + ) + + for i, btn in enumerate(self.beat_buttons): + distance = min((i - active16) % BLOCK_STEPS, (active16 - i) % BLOCK_STEPS) + intensity = max(0.0, 1.0 - distance / 5.0) + if i == active16: + btn.background_color = (0.05, 0.95, 1.0, 0.98) + btn.color = (0.0, 0.02, 0.04, 1.0) + elif i % 4 == 0: + btn.background_color = (0.18, 0.34 + 0.24 * intensity, 0.62, 0.78) + btn.color = (0.78, 1.0, 1.0, 0.82) + else: + btn.background_color = (0.02, 0.11 + 0.22 * intensity, 0.16 + 0.28 * intensity, 0.70) + btn.color = (0.42, 0.95, 1.0, 0.74) + + for i, meter in enumerate(self.meters): + meter.value = 8 + 86 * abs(math.sin(2.0 * math.pi * (phase * (i + 1) + i * 0.091))) + + self.ui.ids.osc_a.value = 50 + 48 * math.sin(2.0 * math.pi * (phase * 4.0)) + self.ui.ids.osc_b.value = 50 + 48 * math.sin(2.0 * math.pi * (phase * 8.0 + 0.18)) + self.ui.ids.osc_c.value = 50 + 48 * math.sin(2.0 * math.pi * (phase * 16.0 + 0.37)) + + w = max(1.0, float(self.ui.width)) + h = max(1.0, float(self.ui.height)) + + self.ui.ids.ticker.x = self.ui.width - phase * (self.ui.width + self.ui._ticker_width) + + scan_y = ((visual_step + micro) / STEPS_PER_LOOP) * h + self.scan_rect.pos = (0, scan_y) + self.scan_rect.size = (w, dp(3)) + self.scan_color.rgba = (0.0, 1.0, 0.72, 0.06 + 0.08 * pulse) + + wave_points = [] + base_y = h * 0.455 + amp = h * 0.025 + for i in range(32): + x = w * i / 31.0 + y = base_y + amp * math.sin(2.0 * math.pi * (phase * 8.0 + i / 7.0)) + wave_points.extend([x, y]) + self.wave_line.points = wave_points + self.wave_color.rgba = (0.0, 0.82 + 0.18 * pulse, 1.0, 0.34 + 0.22 * pulse) + + for color, dot, i in self.particle_items: + px = (w * ((i * 37) % 101) / 100.0 + phase * w * (0.25 + (i % 5) * 0.06)) % w + py = h * (0.20 + 0.62 * (((i * 19) % 97) / 96.0)) + py += math.sin(2.0 * math.pi * (phase * (1 + (i % 4)) + i * 0.13)) * h * 0.018 + size = dp(1.6 + (i % 4)) + dot.pos = (px, py) + dot.size = (size, size) + color.rgba = ( + 0.18 + 0.12 * (i % 3), + 0.78 + 0.22 * pulse, + 1.0, + 0.18 + 0.22 * ((i + active16) % 4 == 0), + ) + + self.ui.ids.status_label.text = ( + f"STEP {visual_step:02d}/63 BLOCK {block + 1}/4 LOCAL {local:02d} " + f"FEATURE {feature_index + 1}/8" + ) + + pythonhere_intro_state["visual_step"] = visual_step + pythonhere_intro_state["phase"] = phase + pythonhere_intro_state["feature_index"] = feature_index + pythonhere_intro_state["version"] = version_text + + return True + + +def stop_pythonhere_intro(close_midi=False): + controller = globals().get("pythonhere_intro_controller") + if controller is not None: + controller.stop(close_midi=close_midi) + + +try: + pythonhere_intro_ui = Builder.load_string(KV) + if pythonhere_intro_ui is None: + raise RuntimeError("Builder.load_string(KV) returned None") + + pythonhere_intro_controller = PythonHereIntroController( + pythonhere_intro_ui, + pythonhere_intro_ticker_text, + announcements, + ) + + root.clear_widgets() + root.add_widget(pythonhere_intro_ui) + + pythonhere_intro_controller.start() + + +except Exception as exc: + pythonhere_last_error = f"{type(exc).__name__}: {exc}" + pythonhere_intro_state.update( + ok=False, + stage="setup_failed", + message="Could not start PythonHere 0.2.x intro", + error=pythonhere_last_error, + ) + Logger.exception("PythonHere: Could not start PythonHere 0.2.x intro") + Popup( + title="PythonHere intro error", + content=Label( + text=pythonhere_last_error, + text_size=(dp(280), None), + halign="center", + valign="middle", + ), + size_hint=(0.86, 0.34), + ).open() +``` diff --git a/examples/there_ai/prompts.md b/examples/there_ai/prompts.md new file mode 100644 index 0000000..1f613b1 --- /dev/null +++ b/examples/there_ai/prompts.md @@ -0,0 +1,1008 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.19.3 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +# Prompt sections + +PythonHere registers project-specific prompt sections for `%%there ai` when the +`pythonhere` extension is loaded. + +```{code-cell} +%load_ext pythonhere +%connect-there +``` + +## Active by default + +Normal `%%there ai` requests use this prompt stack: + +- [`default`](https://github.com/b3b/herethere/blob/master/herethere/there/ai/prompts/default.md): + generic `%%there` cell generation rules. +- [`kivy-runtime`](https://github.com/b3b/pythonhere/blob/master/pythonhere/magic_here/prompts/kivy-runtime.md): + live Kivy app context, `app`, `root`, main-thread behavior, UI replacement, + and cleanup expectations. +- [`kivy-kv`](https://github.com/b3b/pythonhere/blob/master/pythonhere/magic_here/prompts/kivy-kv.md): + rules for generating valid Kv strings inside Python cells. +- [`android-runtime`](https://github.com/b3b/pythonhere/blob/master/pythonhere/magic_here/prompts/android-runtime.md): + Android availability checks and runtime diagnostics. +- [`jnius`](https://github.com/b3b/pythonhere/blob/master/pythonhere/magic_here/prompts/jnius.md): + Pyjnius usage patterns and Android Java API guardrails. +- [`android-permissions`](https://github.com/b3b/pythonhere/blob/master/pythonhere/magic_here/prompts/android-permissions.md): + runtime permission checks, request flows, and Android + version differences. +- [`android-packages`](https://github.com/b3b/pythonhere/blob/master/pythonhere/magic_here/prompts/android-packages.md): + installed package inventory patterns. +- [`android-media`](https://github.com/b3b/pythonhere/blob/master/pythonhere/magic_here/prompts/android-media.md): + MediaStore and thumbnail handling guidance. +- [`plyer`](https://github.com/b3b/pythonhere/blob/master/pythonhere/magic_here/prompts/plyer.md): + Plyer helper APIs for portable device features. + +## Available on request + +Use these prompt sections with `--prompts` when a task needs them: + +- [`able`](https://github.com/b3b/pythonhere/blob/master/pythonhere/magic_here/prompts/able.md): + Bluetooth Low Energy work with the `able` package. +- [`midi`](https://github.com/b3b/pythonhere/blob/master/pythonhere/magic_here/prompts/midi.md): + MIDI playback with `midistream`. + +```{code-cell} +:tags: ["remove-output"] +%%there ai --prompts able,midi +Build a BLE ... +``` + +## How prompt composition works + +`%%there ai` sends two chat messages to the AI provider: + +- a `system` message built by joining the selected prompt sections +- a `user` message containing the text you wrote in the `%%there ai` cell body + +The system prompt is the active prompt stack from the sections above, joined +together in order. +When you use `--prompts`, those sections are appended for that request. + +`%%there ai --fix` also adds the built-in +[`fix`](https://github.com/b3b/herethere/blob/master/herethere/there/ai/prompts/fix.md) +prompt section. Its user prompt is built from the last executed Python +`%%there` cell plus the fix instruction you write in the `%%there ai --fix` +cell. + +## Inspect registered prompts + +If you want to see what prompt sections are available in the current notebook: + +```{code-cell} +from herethere.there.ai import list_ai_prompts + +list_ai_prompts() +``` + +To inspect the complete chat request for a normal `%%there ai` cell, build the +messages from the same text you would put in the cell body: + +```{code-cell} +:tags: ["remove-output"] +from herethere.there.ai import build_messages + +user_prompt = """ +Build a soft portrait-mode control panel for simulated sensor data. +""" + +messages = build_messages(user_prompt) +for message in messages: + print("##", message["role"]) + print(message["content"]) + print() +``` + + +To read one prompt section: + +```{code-cell} +:tags: ["remove-output"] +from herethere.there.ai import get_ai_prompt + +print(get_ai_prompt("kivy-runtime")) +``` + +## Register a custom prompt + +Use `register_ai_prompt(...)` in a notebook to add reusable prompt sections for +the current session. Registering a prompt with the same name as an existing +section replaces that section for the current notebook session. + +This prompt adds visual and interaction style: + +```{code-cell} +from herethere.there.ai import register_ai_prompt + +register_ai_prompt("style", """## Visual style +Visual style for this Kivy prototype: + +Create a light, natural-looking, toy-like interface for Android portrait mode. +The UI should feel soft, tactile, friendly, and physical, like a beautifully designed +interactive object rather than a conventional app screen. + +Style goals: +- Avoid generic Material Design, dense dark dashboards, and default Kivy widget skins. +- Use a bright, airy palette with soft neutrals and gentle accent colors: + warm white, sand, light stone, pale mint, sky blue, soft coral, muted yellow. +- Favor rounded, organic, pebble-like shapes over hard rectangles. +- Make the interface feel approachable, playful, and calm. + +Visual language: +- Use soft layers, subtle shadows, rounded modules, curved dividers, and generous breathing room. +- Prefer natural grouping and flowing composition instead of rigid boxed sections. +- Controls should feel tactile, like parts of a physical toy or instrument panel. +- Use circles, capsules, blobs, rounded sliders, pill buttons, and soft segmented controls. +- Keep contrast clear, but never harsh. + +Interaction: +- Buttons should feel pressable and satisfying, with clear depth and soft pressed feedback. +- Motion should be gentle and meaningful: bounce, fill, pulse, slide, or smooth transitions. +- Use animation to suggest liveliness and responsiveness, not technical intensity. + +Typography and wording: +- Keep wording simple, friendly, and minimal. +- Use clear labels and compact readouts. +- Prefer approachable language over technical jargon. + +Kivy implementation direction: +- Do not rely on default widget skins. +- Override backgrounds and draw custom surfaces with canvas.before / canvas.after. +- Use RoundedRectangle, Ellipse, soft borders, subtle shadow-like layering, and custom control styling. +- Favor reusable custom widgets that feel handcrafted and cohesive. + +Design goal: +- The final result should clearly demonstrate that Kivy can create soft, natural, + highly customized interfaces that feel tactile, modern, and visually distinct from standard apps. +""", +) +``` + +Use the custom prompt for the generation request: + +```{code-cell} yaml +:tags: ["remove-output"] +%%there ai --prompts style +Create a Kivy Android portrait prototype for a live sensor-control demo. + +The app should let the user: +- monitor one simulated sensor value +- switch between three operating modes +- adjust sensitivity with a slider +- start, pause, reset, and trigger a simulated scan +- view connection/status information +- see a progress indicator during scans +- read a compact event log of recent actions and system updates + +The prototype should be self-contained and runnable. +Use simulated values and timers only; no network or external hardware is required. +Keep the layout suitable for a phone in portrait orientation. +``` + +```{code-cell} +--- +tags: + - hide-input +jupyter: + source_hidden: true +--- +%%there +# Generated locally by %%there ai. Review before running. +from math import sin +from random import uniform +import time + +from kivy.clock import Clock +from kivy.lang import Builder +from kivy.logger import Logger +from kivy.metrics import dp +from kivy.properties import BooleanProperty, ListProperty, NumericProperty, StringProperty +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.button import Button +from kivy.uix.popup import Popup +from kivy.uix.label import Label +from kivy.uix.widget import Widget +from kivy.graphics import Color, RoundedRectangle, Ellipse, Line + + +try: + old_cleanup = globals().get("live_sensor_demo_cleanup") + if callable(old_cleanup): + old_cleanup() +except Exception: + Logger.exception("PythonHere: Could not clean up previous sensor demo") + + +class SoftPanel(BoxLayout): + surface_color = ListProperty([0.98, 0.95, 0.88, 1]) + shadow_color = ListProperty([0.48, 0.38, 0.25, 0.13]) + border_color = ListProperty([1.0, 1.0, 1.0, 0.62]) + radius = NumericProperty(dp(28)) + shadow_offset = ListProperty([0, -dp(3)]) + + +class PebbleButton(Button): + active = BooleanProperty(False) + base_color = ListProperty([0.94, 0.88, 0.76, 1]) + active_color = ListProperty([0.48, 0.73, 0.86, 1]) + pressed_color = ListProperty([0.84, 0.69, 0.58, 1]) + text_color = ListProperty([0.20, 0.22, 0.20, 1]) + radius = NumericProperty(dp(24)) + + +class SensorControlUI(BoxLayout): + sensor_value = NumericProperty(42) + sensitivity = NumericProperty(55) + mode = StringProperty("Normal") + running = BooleanProperty(False) + scanning = BooleanProperty(False) + progress = NumericProperty(0) + packet_count = NumericProperty(0) + status_text = StringProperty("Ready") + connection_text = StringProperty("Link: virtual sensor online") + signal_text = StringProperty("Signal: calm") + progress_text = StringProperty("Scan: idle") + log_text = StringProperty("") + + +class SensorOrb(Widget): + value = NumericProperty(42) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + with self.canvas: + self.shadow_color = Color(0.48, 0.38, 0.25, 0.14) + self.shadow = Ellipse() + self.outer_color = Color(0.93, 0.88, 0.74, 1) + self.outer = Ellipse() + self.fill_color = Color(0.55, 0.80, 0.74, 1) + self.fill = Ellipse() + self.ring_color = Color(1, 1, 1, 0.68) + self.ring = Line(width=dp(2.0)) + self.bind(pos=self._update_canvas, size=self._update_canvas, value=self._update_canvas) + Clock.schedule_once(self._update_canvas, 0) + + def _update_canvas(self, *args): + w = max(1, self.width) + h = max(1, self.height) + cx = self.center_x + cy = self.center_y + diam = min(w, h) * 0.76 + outer_r = diam / 2 + value_ratio = max(0, min(1, self.value / 100.0)) + fill_diam = diam * (0.36 + 0.48 * value_ratio) + + if value_ratio < 0.45: + self.fill_color.rgba = (0.56, 0.81, 0.74, 1) + elif value_ratio < 0.75: + self.fill_color.rgba = (0.82, 0.75, 0.43, 1) + else: + self.fill_color.rgba = (0.93, 0.48, 0.42, 1) + + self.shadow.pos = (cx - outer_r + dp(2), cy - outer_r - dp(5)) + self.shadow.size = (diam, diam) + self.outer.pos = (cx - outer_r, cy - outer_r) + self.outer.size = (diam, diam) + self.fill.pos = (cx - fill_diam / 2, cy - fill_diam / 2) + self.fill.size = (fill_diam, fill_diam) + self.ring.circle = (cx, cy, outer_r) + + +class SoftSlider(Widget): + value = NumericProperty(55) + min_value = NumericProperty(0) + max_value = NumericProperty(100) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._grabbed = False + with self.canvas: + self.shadow_color = Color(0.45, 0.35, 0.24, 0.10) + self.shadow = RoundedRectangle(radius=[dp(12)]) + self.track_color = Color(0.88, 0.83, 0.73, 1) + self.track = RoundedRectangle(radius=[dp(12)]) + self.fill_color = Color(0.53, 0.76, 0.82, 1) + self.fill = RoundedRectangle(radius=[dp(12)]) + self.knob_shadow_color = Color(0.45, 0.35, 0.24, 0.16) + self.knob_shadow = Ellipse() + self.knob_color = Color(1.0, 0.97, 0.88, 1) + self.knob = Ellipse() + self.knob_line_color = Color(0.76, 0.66, 0.54, 0.65) + self.knob_line = Line(width=dp(1.25)) + self.bind(pos=self._update_canvas, size=self._update_canvas, value=self._update_canvas) + Clock.schedule_once(self._update_canvas, 0) + + def _ratio(self): + span = max(0.001, self.max_value - self.min_value) + return max(0, min(1, (self.value - self.min_value) / span)) + + def _set_value_from_x(self, x): + left = self.x + dp(18) + width = max(1, self.width - dp(36)) + ratio = max(0, min(1, (x - left) / width)) + self.value = self.min_value + ratio * (self.max_value - self.min_value) + + def _update_canvas(self, *args): + left = self.x + dp(18) + width = max(1, self.width - dp(36)) + track_h = dp(18) + y = self.center_y - track_h / 2 + ratio = self._ratio() + fill_w = width * ratio + knob_d = dp(38) + knob_x = left + fill_w - knob_d / 2 + + if ratio < 0.45: + self.fill_color.rgba = (0.55, 0.80, 0.74, 1) + elif ratio < 0.75: + self.fill_color.rgba = (0.66, 0.75, 0.86, 1) + else: + self.fill_color.rgba = (0.94, 0.58, 0.50, 1) + + self.shadow.pos = (left, y - dp(3)) + self.shadow.size = (width, track_h) + self.track.pos = (left, y) + self.track.size = (width, track_h) + self.fill.pos = (left, y) + self.fill.size = (max(0, fill_w), track_h) + self.knob_shadow.pos = (knob_x + dp(1), self.center_y - knob_d / 2 - dp(3)) + self.knob_shadow.size = (knob_d, knob_d) + self.knob.pos = (knob_x, self.center_y - knob_d / 2) + self.knob.size = (knob_d, knob_d) + self.knob_line.circle = (knob_x + knob_d / 2, self.center_y, knob_d / 2) + + def on_touch_down(self, touch): + if self.collide_point(*touch.pos): + self._grabbed = True + touch.grab(self) + self._set_value_from_x(touch.x) + return True + return super().on_touch_down(touch) + + def on_touch_move(self, touch): + if touch.grab_current is self: + self._set_value_from_x(touch.x) + return True + return super().on_touch_move(touch) + + def on_touch_up(self, touch): + if touch.grab_current is self: + self._set_value_from_x(touch.x) + touch.ungrab(self) + self._grabbed = False + return True + return super().on_touch_up(touch) + + +class SoftProgress(Widget): + progress = NumericProperty(0) + running = BooleanProperty(False) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + with self.canvas: + self.track_color = Color(0.88, 0.83, 0.73, 1) + self.track = RoundedRectangle(radius=[dp(13)]) + self.fill_color = Color(0.55, 0.80, 0.74, 1) + self.fill = RoundedRectangle(radius=[dp(13)]) + self.shine_color = Color(1, 1, 1, 0.35) + self.shine = RoundedRectangle(radius=[dp(10)]) + self.bind(pos=self._update_canvas, size=self._update_canvas, progress=self._update_canvas, running=self._update_canvas) + Clock.schedule_once(self._update_canvas, 0) + + def _update_canvas(self, *args): + p = max(0, min(1, self.progress)) + self.track.pos = self.pos + self.track.size = self.size + self.fill.pos = self.pos + self.fill.size = (self.width * p, self.height) + self.shine.pos = (self.x + dp(4), self.y + self.height * 0.56) + self.shine.size = (max(0, self.width * p - dp(8)), self.height * 0.22) + if self.running: + self.fill_color.rgba = (0.55, 0.80, 0.74, 1) + else: + self.fill_color.rgba = (0.72, 0.72, 0.66, 1) + + +KV = """ +#:import dp kivy.metrics.dp +#:import sp kivy.metrics.sp + +: + canvas.before: + Color: + rgba: root.shadow_color + RoundedRectangle: + pos: self.x + root.shadow_offset[0], self.y + root.shadow_offset[1] + size: self.size + radius: [root.radius] + Color: + rgba: root.surface_color + RoundedRectangle: + pos: self.pos + size: self.size + radius: [root.radius] + canvas.after: + Color: + rgba: root.border_color + Line: + rounded_rectangle: self.x, self.y, self.width, self.height, root.radius + width: dp(1) + +: + background_normal: "" + background_down: "" + background_color: 0, 0, 0, 0 + color: root.text_color + font_size: sp(15) + bold: True + halign: "center" + valign: "middle" + text_size: self.size + canvas.before: + Color: + rgba: 0.46, 0.36, 0.25, 0.14 + RoundedRectangle: + pos: self.x, self.y - dp(3) + size: self.size + radius: [root.radius] + Color: + rgba: root.pressed_color if root.state == "down" else (root.active_color if root.active else root.base_color) + RoundedRectangle: + pos: self.pos + size: self.size + radius: [root.radius] + Color: + rgba: 1, 1, 1, 0.34 + Line: + rounded_rectangle: self.x + dp(1), self.y + dp(1), self.width - dp(2), self.height - dp(2), root.radius + width: dp(1) + +SensorControlUI: + orientation: "vertical" + padding: dp(12) + canvas.before: + Color: + rgba: 0.96, 0.93, 0.85, 1 + Rectangle: + pos: self.pos + size: self.size + + ScrollView: + do_scroll_x: False + bar_width: dp(3) + scroll_type: ["bars", "content"] + + BoxLayout: + id: body + orientation: "vertical" + size_hint_y: None + height: self.minimum_height + spacing: dp(10) + padding: dp(2), dp(4), dp(2), dp(12) + + BoxLayout: + orientation: "vertical" + size_hint_y: None + height: dp(62) + padding: dp(4), 0 + Label: + text: "Sensor Control" + color: 0.22, 0.22, 0.19, 1 + font_size: sp(27) + bold: True + halign: "left" + valign: "bottom" + text_size: self.size + Label: + text: "Live simulated monitor" + color: 0.43, 0.42, 0.36, 1 + font_size: sp(14) + halign: "left" + valign: "top" + text_size: self.size + + SoftPanel: + orientation: "horizontal" + size_hint_y: None + height: dp(158) + padding: dp(12) + spacing: dp(12) + surface_color: 0.98, 0.96, 0.89, 1 + SensorOrb: + value: root.sensor_value + size_hint_x: 0.44 + BoxLayout: + orientation: "vertical" + spacing: dp(3) + Label: + text: "Sensor value" + color: 0.42, 0.40, 0.34, 1 + font_size: sp(14) + halign: "left" + valign: "bottom" + text_size: self.size + Label: + text: "{} units".format(int(root.sensor_value)) + color: 0.18, 0.20, 0.18, 1 + font_size: sp(34) + bold: True + halign: "left" + valign: "middle" + text_size: self.size + Label: + text: "Mode: " + root.mode + color: 0.32, 0.35, 0.31, 1 + font_size: sp(15) + halign: "left" + valign: "middle" + text_size: self.size + Label: + text: root.status_text + color: 0.50, 0.43, 0.36, 1 + font_size: sp(13) + halign: "left" + valign: "top" + text_size: self.size + + SoftPanel: + orientation: "vertical" + size_hint_y: None + height: dp(94) + padding: dp(12), dp(10) + spacing: dp(8) + surface_color: 0.92, 0.95, 0.89, 1 + Label: + text: "Operating mode" + color: 0.35, 0.37, 0.32, 1 + font_size: sp(14) + bold: True + size_hint_y: None + height: dp(20) + halign: "left" + valign: "middle" + text_size: self.size + BoxLayout: + spacing: dp(8) + PebbleButton: + id: mode_eco + text: "Eco" + PebbleButton: + id: mode_normal + text: "Normal" + PebbleButton: + id: mode_boost + text: "Boost" + + SoftPanel: + orientation: "vertical" + size_hint_y: None + height: dp(98) + padding: dp(12), dp(10) + spacing: dp(5) + surface_color: 0.94, 0.93, 0.86, 1 + BoxLayout: + size_hint_y: None + height: dp(25) + Label: + text: "Sensitivity" + color: 0.35, 0.34, 0.30, 1 + font_size: sp(14) + bold: True + halign: "left" + valign: "middle" + text_size: self.size + Label: + text: "{} percent".format(int(root.sensitivity)) + color: 0.35, 0.34, 0.30, 1 + font_size: sp(14) + bold: True + halign: "right" + valign: "middle" + text_size: self.size + SoftSlider: + id: sensitivity_slider + value: root.sensitivity + size_hint_y: None + height: dp(48) + + SoftPanel: + orientation: "vertical" + size_hint_y: None + height: dp(142) + padding: dp(12) + spacing: dp(10) + surface_color: 0.98, 0.94, 0.88, 1 + Label: + text: "Controls" + color: 0.35, 0.34, 0.30, 1 + font_size: sp(14) + bold: True + size_hint_y: None + height: dp(20) + halign: "left" + valign: "middle" + text_size: self.size + GridLayout: + cols: 2 + spacing: dp(9) + PebbleButton: + id: start_button + text: "Start" + active_color: 0.54, 0.78, 0.63, 1 + PebbleButton: + id: pause_button + text: "Pause" + active_color: 0.86, 0.70, 0.50, 1 + PebbleButton: + id: scan_button + text: "Scan" + active_color: 0.54, 0.74, 0.88, 1 + PebbleButton: + id: reset_button + text: "Reset" + active_color: 0.94, 0.62, 0.55, 1 + + SoftPanel: + orientation: "vertical" + size_hint_y: None + height: dp(132) + padding: dp(12) + spacing: dp(8) + surface_color: 0.90, 0.94, 0.94, 1 + BoxLayout: + size_hint_y: None + height: dp(47) + spacing: dp(8) + BoxLayout: + orientation: "vertical" + Label: + text: "Connection" + color: 0.39, 0.41, 0.38, 1 + font_size: sp(12) + bold: True + halign: "left" + valign: "bottom" + text_size: self.size + Label: + text: root.connection_text + color: 0.22, 0.24, 0.22, 1 + font_size: sp(13) + halign: "left" + valign: "top" + text_size: self.size + BoxLayout: + orientation: "vertical" + size_hint_x: 0.52 + Label: + text: "Packets" + color: 0.39, 0.41, 0.38, 1 + font_size: sp(12) + bold: True + halign: "right" + valign: "bottom" + text_size: self.size + Label: + text: str(int(root.packet_count)) + color: 0.22, 0.24, 0.22, 1 + font_size: sp(20) + bold: True + halign: "right" + valign: "top" + text_size: self.size + Label: + text: root.signal_text + " " + root.progress_text + color: 0.33, 0.38, 0.36, 1 + font_size: sp(13) + size_hint_y: None + height: dp(20) + halign: "left" + valign: "middle" + text_size: self.size + SoftProgress: + progress: root.progress + running: root.scanning + size_hint_y: None + height: dp(24) + + SoftPanel: + orientation: "vertical" + size_hint_y: None + height: dp(150) + padding: dp(12) + spacing: dp(6) + surface_color: 0.97, 0.92, 0.86, 1 + Label: + text: "Recent log" + color: 0.35, 0.34, 0.30, 1 + font_size: sp(14) + bold: True + size_hint_y: None + height: dp(22) + halign: "left" + valign: "middle" + text_size: self.size + ScrollView: + id: log_scroll + do_scroll_x: False + bar_width: dp(2) + Label: + id: event_log + text: root.log_text + color: 0.31, 0.30, 0.27, 1 + font_size: sp(12) + line_height: 1.12 + halign: "left" + valign: "top" + text_size: self.width, None + size_hint_y: None + height: self.texture_size[1] + dp(8) +""" + + +class SensorDemoController: + def __init__(self, ui): + self.ui = ui + self.phase = 0.0 + self.logs = [] + self.sensor_event = None + self.scan_event = None + self.cleaned = False + self.modes = { + "Eco": {"base": 34, "speed": 0.75}, + "Normal": {"base": 52, "speed": 1.0}, + "Boost": {"base": 70, "speed": 1.35}, + } + + ui.ids.start_button.bind(on_release=self.start) + ui.ids.pause_button.bind(on_release=self.pause) + ui.ids.reset_button.bind(on_release=self.reset) + ui.ids.scan_button.bind(on_release=self.trigger_scan) + ui.ids.mode_eco.bind(on_release=lambda instance: self.set_mode("Eco")) + ui.ids.mode_normal.bind(on_release=lambda instance: self.set_mode("Normal")) + ui.ids.mode_boost.bind(on_release=lambda instance: self.set_mode("Boost")) + ui.ids.sensitivity_slider.bind(value=self.on_sensitivity) + + self.sensor_event = Clock.schedule_interval(self.update_sensor, 0.22) + self.set_mode("Normal", log_event=False) + self.add_log("Demo ready") + self.store_state() + + def cleanup(self): + self.cleaned = True + if self.sensor_event is not None: + self.sensor_event.cancel() + self.sensor_event = None + if self.scan_event is not None: + self.scan_event.cancel() + self.scan_event = None + + def add_log(self, message): + stamp = time.strftime("%H:%M:%S") + self.logs.insert(0, stamp + " " + message) + self.logs = self.logs[:8] + self.ui.log_text = "\n".join(self.logs) + self.store_state() + + def store_state(self): + globals()["live_sensor_demo_state"] = { + "ok": True, + "running": bool(self.ui.running), + "scanning": bool(self.ui.scanning), + "sensor_value": round(float(self.ui.sensor_value), 2), + "sensitivity": round(float(self.ui.sensitivity), 2), + "mode": str(self.ui.mode), + "progress": round(float(self.ui.progress), 3), + "status": str(self.ui.status_text), + "connection": str(self.ui.connection_text), + "signal": str(self.ui.signal_text), + "packets": int(self.ui.packet_count), + "recent_log": list(self.logs), + } + + def update_buttons(self): + self.ui.ids.mode_eco.active = self.ui.mode == "Eco" + self.ui.ids.mode_normal.active = self.ui.mode == "Normal" + self.ui.ids.mode_boost.active = self.ui.mode == "Boost" + self.ui.ids.start_button.active = self.ui.running and not self.ui.scanning + self.ui.ids.pause_button.active = not self.ui.running and not self.ui.scanning + self.ui.ids.scan_button.active = self.ui.scanning + self.ui.ids.reset_button.active = False + self.ui.ids.scan_button.text = "Scanning" if self.ui.scanning else "Scan" + + def set_mode(self, mode_name, log_event=True): + if mode_name not in self.modes: + return + self.ui.mode = mode_name + self.ui.status_text = "Mode set to " + mode_name + self.update_buttons() + if log_event: + self.add_log("Mode changed to " + mode_name) + self.store_state() + + def on_sensitivity(self, slider, value): + self.ui.sensitivity = max(0, min(100, value)) + self.ui.status_text = "Sensitivity adjusted" + if int(value) % 10 == 0: + self.store_state() + self.update_buttons() + + def start(self, *args): + self.ui.running = True + self.ui.status_text = "Monitoring active" + self.ui.connection_text = "Link: virtual sensor online" + self.ui.signal_text = "Signal: streaming" + self.update_buttons() + self.add_log("Monitoring started") + + def pause(self, *args): + self.ui.running = False + self.ui.status_text = "Monitoring paused" + self.ui.signal_text = "Signal: holding" + self.update_buttons() + self.add_log("Monitoring paused") + + def reset(self, *args): + if self.scan_event is not None: + self.scan_event.cancel() + self.scan_event = None + self.ui.running = False + self.ui.scanning = False + self.ui.progress = 0 + self.ui.progress_text = "Scan: idle" + self.ui.sensor_value = 42 + self.ui.packet_count = 0 + self.ui.sensitivity = 55 + self.ui.ids.sensitivity_slider.value = 55 + self.phase = 0.0 + self.set_mode("Normal", log_event=False) + self.ui.status_text = "Reset complete" + self.ui.signal_text = "Signal: calm" + self.update_buttons() + self.add_log("System reset") + + def trigger_scan(self, *args): + if self.ui.scanning: + self.add_log("Scan already running") + return + self.ui.scanning = True + self.ui.progress = 0 + self.ui.progress_text = "Scan: 0 percent" + self.ui.status_text = "Scan running" + self.ui.signal_text = "Signal: sampling" + if self.scan_event is not None: + self.scan_event.cancel() + self.scan_event = Clock.schedule_interval(self.update_scan, 0.08) + self.update_buttons() + self.add_log("Simulated scan started") + + def update_scan(self, dt): + if self.cleaned: + return False + self.ui.progress = min(1, self.ui.progress + dt / 4.0) + self.ui.progress_text = "Scan: {} percent".format(int(self.ui.progress * 100)) + if self.ui.progress >= 1: + self.ui.progress = 1 + self.ui.scanning = False + self.ui.status_text = "Scan complete" + self.ui.signal_text = "Signal: stable" + self.ui.progress_text = "Scan: complete" + if self.scan_event is not None: + self.scan_event.cancel() + self.scan_event = None + self.update_buttons() + self.add_log("Scan completed") + return False + self.store_state() + return True + + def update_sensor(self, dt): + if self.cleaned: + return False + if not self.ui.running and not self.ui.scanning: + self.store_state() + return True + + mode_info = self.modes.get(self.ui.mode, self.modes["Normal"]) + self.phase += dt * mode_info["speed"] * (0.8 + self.ui.sensitivity / 80.0) + + sensitivity_ratio = self.ui.sensitivity / 100.0 + wave = sin(self.phase) * (8 + 18 * sensitivity_ratio) + fine_wave = sin(self.phase * 2.7) * (2 + 5 * sensitivity_ratio) + jitter = uniform(-1.2, 1.2) * (0.4 + sensitivity_ratio) + scan_lift = 6 * sin(self.ui.progress * 3.14159) if self.ui.scanning else 0 + + value = mode_info["base"] + wave + fine_wave + jitter + scan_lift + self.ui.sensor_value = max(0, min(100, value)) + self.ui.packet_count += 1 + + if self.ui.scanning: + self.ui.status_text = "Scanning sensor field" + self.ui.signal_text = "Signal: sampling" + elif self.ui.running: + if self.ui.sensor_value > 82: + self.ui.status_text = "High reading" + self.ui.signal_text = "Signal: lively" + elif self.ui.sensor_value < 25: + self.ui.status_text = "Low reading" + self.ui.signal_text = "Signal: quiet" + else: + self.ui.status_text = "Monitoring active" + self.ui.signal_text = "Signal: stable" + + self.store_state() + return True + + +def build_live_sensor_demo(): + ui = Builder.load_string(KV) + if ui is None: + raise RuntimeError("Builder.load_string returned None for sensor demo UI") + + controller = SensorDemoController(ui) + + def cleanup_live_sensor_demo(): + controller.cleanup() + + globals()["live_sensor_demo_ui"] = ui + globals()["live_sensor_demo_controller"] = controller + globals()["live_sensor_demo_cleanup"] = cleanup_live_sensor_demo + + root.clear_widgets() + root.add_widget(ui) + return ui + + +try: + build_live_sensor_demo() +except Exception as exc: + Logger.exception("PythonHere: Could not build live sensor demo") + globals()["pythonhere_last_error"] = { + "stage": "build_live_sensor_demo", + "error": f"{type(exc).__name__}: {exc}", + } + popup = Popup( + title="Sensor demo error", + content=Label( + text="Could not build the sensor demo.\n" + f"{type(exc).__name__}: {exc}", + halign="center", + valign="middle", + ), + size_hint=(0.88, 0.42), + ) + popup.open() +``` + +```{code-cell} +%there -d 1 screenshot -w 250 +``` + +## Override the session stack + +Use `set_ai_prompts(...)` when a notebook should use a different base prompt +stack for every request in the current kernel session: + +```{code-cell} +from herethere.there.ai import clear_ai_prompts, set_ai_prompts + +set_ai_prompts("default", "kivy-runtime", "style") +``` + +Clear the session override to return to the configured default stack: + +```{code-cell} +clear_ai_prompts() +``` diff --git a/examples/there_ai/scenarios.md b/examples/there_ai/scenarios.md new file mode 100644 index 0000000..f8f833e --- /dev/null +++ b/examples/there_ai/scenarios.md @@ -0,0 +1,2237 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.19.3 +kernelspec: + display_name: Python 3 + language: python + name: python3 +--- + +# Usage scenarios + +These scenarios show complete `%%there ai` workflows: generated cells, remote +state retrieval, file download and a `--fix` iteration. + +```{code-cell} +%load_ext pythonhere +%connect-there +``` + +## App Usage Chart + +```{code-cell} +:tags: ["remove-output"] +%%there ai +Show a chart of foreground app usage statistics in last 24 hours. +Save the results in a `usage` variable and in a `usage.csv` file. +Print instructions for drawing a usage pie chart locally with pandas and matplotlib. +UI should be optimised for the Android Portrait mode. +``` + +```{code-cell} +--- +tags: + - hide-input +jupyter: + source_hidden: true +--- +%%there +# Generated locally by %%there ai. Review before running. +from pathlib import Path +from csv import DictWriter + +from jnius import autoclass +from kivy.lang import Builder +from kivy.logger import Logger +from kivy.metrics import dp, sp +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.label import Label +from kivy.uix.progressbar import ProgressBar +from kivy.uix.popup import Popup + +usage = [] +usage_status = { + "ok": False, + "stage": "starting", + "message": "Starting usage statistics view.", + "error": None, +} +usage_label_cache = globals().get("usage_label_cache", {}) +usage_label_errors = [] +usage_csv_path = "usage.csv" + + +def _show_usage_error_popup(message): + content = Label( + text=str(message), + font_size=sp(15), + halign="left", + valign="top", + text_size=(dp(300), None), + ) + popup = Popup( + title="Usage statistics error", + content=content, + size_hint=(0.9, None), + height=dp(240), + ) + popup.open() + + +def _format_duration(seconds): + seconds = int(max(0, seconds)) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + secs = seconds % 60 + if hours: + return f"{hours}h {minutes}m" + if minutes: + return f"{minutes}m {secs}s" + return f"{secs}s" + + +def _get_android_context_and_activity(): + PythonActivity = autoclass("org.kivy.android.PythonActivity") + activity = PythonActivity.mActivity + if activity is not None: + return activity, activity + + try: + PythonService = autoclass("org.kivy.android.PythonService") + service = PythonService.mService + if service is not None: + return service, None + except Exception as exc: + Logger.info(f"PythonHere: PythonService fallback unavailable: {type(exc).__name__}: {exc}") + + return None, None + + +def _check_usage_access(context): + VERSION = autoclass("android.os.Build$VERSION") + AppOpsManager = autoclass("android.app.AppOpsManager") + Context = autoclass("android.content.Context") + + sdk_int = int(VERSION.SDK_INT) + package_name = str(context.getPackageName()) + uid = int(context.getApplicationInfo().uid) + + app_ops_service = Context.APP_OPS_SERVICE or "appops" + app_ops = context.getSystemService(app_ops_service) + if app_ops is None: + return { + "granted": False, + "sdk_int": sdk_int, + "package_name": package_name, + "mode": None, + "mode_name": "app_ops_unavailable", + "error": "AppOps service is unavailable.", + } + + op_name = AppOpsManager.OPSTR_GET_USAGE_STATS or "android:get_usage_stats" + mode = int(app_ops.checkOpNoThrow(op_name, uid, package_name)) + + mode_names = { + int(AppOpsManager.MODE_ALLOWED): "allowed", + int(AppOpsManager.MODE_IGNORED): "ignored", + int(AppOpsManager.MODE_DEFAULT): "default", + int(AppOpsManager.MODE_ERRORED): "errored", + } + mode_foreground = getattr(AppOpsManager, "MODE_FOREGROUND", None) + if mode_foreground is not None: + mode_names[int(mode_foreground)] = "foreground" + + return { + "granted": mode == int(AppOpsManager.MODE_ALLOWED), + "sdk_int": sdk_int, + "package_name": package_name, + "mode": mode, + "mode_name": mode_names.get(mode, str(mode)), + "error": None, + } + + +def _resolve_app_label(package_manager, package_name, sdk_int, application_info_flags_class): + if package_name in usage_label_cache: + return usage_label_cache[package_name] + + try: + if sdk_int >= 33 and application_info_flags_class is not None: + app_info = package_manager.getApplicationInfo( + package_name, + application_info_flags_class.of(0), + ) + else: + app_info = package_manager.getApplicationInfo(package_name, 0) + label = package_manager.getApplicationLabel(app_info) + label_text = str(label) if label is not None else package_name + except Exception as exc: + label_text = package_name + error_text = f"{package_name}: {type(exc).__name__}: {exc}" + usage_label_errors.append(error_text) + Logger.info(f"PythonHere: Could not resolve app label: {error_text}") + + usage_label_cache[package_name] = label_text + return label_text + + +def _write_usage_csv(rows): + output_path = Path(usage_csv_path) + fieldnames = [ + "rank", + "app_label", + "package", + "foreground_seconds", + "foreground_minutes", + "foreground_hours", + "percent", + "foreground_ms", + "first_time_stamp_ms", + "last_time_stamp_ms", + "last_time_used_ms", + ] + with output_path.open("w", newline="", encoding="utf-8") as csv_file: + writer = DictWriter(csv_file, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow({name: row.get(name, "") for name in fieldnames}) + return output_path.name + + +def _load_usage_stats(): + context, activity = _get_android_context_and_activity() + if context is None: + output_name = _write_usage_csv([]) + return [], { + "ok": False, + "stage": "android_context", + "message": "Android context is unavailable. Usage statistics cannot be queried.", + "error": "Android context is unavailable.", + "output_path": output_name, + } + + access = _check_usage_access(context) + end_ms = int(__import__("time").time() * 1000) + begin_ms = end_ms - (24 * 60 * 60 * 1000) + + if not access["granted"]: + output_name = _write_usage_csv([]) + return [], { + "ok": False, + "stage": "usage_access", + "message": ( + "Usage access is not enabled for this app. Tap Open Settings, " + "enable usage access for this app if it is listed, return here, " + "then tap Refresh. If the app is not listed, the manifest must " + "declare android.permission.PACKAGE_USAGE_STATS." + ), + "error": None, + "access_granted": False, + "app_ops_mode": access["mode"], + "app_ops_mode_name": access["mode_name"], + "sdk_int": access["sdk_int"], + "package_name": access["package_name"], + "window_start_ms": begin_ms, + "window_end_ms": end_ms, + "rows": 0, + "output_path": output_name, + } + + VERSION = autoclass("android.os.Build$VERSION") + Context = autoclass("android.content.Context") + UsageStatsManager = autoclass("android.app.usage.UsageStatsManager") + + sdk_int = int(VERSION.SDK_INT) + usage_service = Context.USAGE_STATS_SERVICE or "usagestats" + usage_manager = context.getSystemService(usage_service) + if usage_manager is None: + output_name = _write_usage_csv([]) + return [], { + "ok": False, + "stage": "usage_manager", + "message": "UsageStatsManager is unavailable on this device.", + "error": "UsageStatsManager is unavailable.", + "access_granted": True, + "sdk_int": sdk_int, + "window_start_ms": begin_ms, + "window_end_ms": end_ms, + "rows": 0, + "output_path": output_name, + } + + package_manager = context.getPackageManager() + application_info_flags_class = None + if sdk_int >= 33: + application_info_flags_class = autoclass("android.content.pm.PackageManager$ApplicationInfoFlags") + + interval_daily = int(UsageStatsManager.INTERVAL_DAILY) + stats_list = usage_manager.queryUsageStats(interval_daily, begin_ms, end_ms) + + aggregate = {} + queried_count = 0 + if stats_list is not None: + queried_count = int(stats_list.size()) + for index in range(queried_count): + stat = stats_list.get(index) + package_name = stat.getPackageName() + if package_name is None: + continue + package_name = str(package_name) + + foreground_ms = int(stat.getTotalTimeInForeground()) + if foreground_ms <= 0: + continue + + record = aggregate.setdefault( + package_name, + { + "package": package_name, + "foreground_ms": 0, + "first_time_stamp_ms": None, + "last_time_stamp_ms": None, + "last_time_used_ms": None, + }, + ) + record["foreground_ms"] += foreground_ms + + first_ts = int(stat.getFirstTimeStamp()) + last_ts = int(stat.getLastTimeStamp()) + last_used = int(stat.getLastTimeUsed()) + + if record["first_time_stamp_ms"] is None or first_ts < record["first_time_stamp_ms"]: + record["first_time_stamp_ms"] = first_ts + if record["last_time_stamp_ms"] is None or last_ts > record["last_time_stamp_ms"]: + record["last_time_stamp_ms"] = last_ts + if record["last_time_used_ms"] is None or last_used > record["last_time_used_ms"]: + record["last_time_used_ms"] = last_used + + total_ms = sum(item["foreground_ms"] for item in aggregate.values()) + rows = [] + for package_name, item in aggregate.items(): + app_label = _resolve_app_label( + package_manager, + package_name, + sdk_int, + application_info_flags_class, + ) + seconds = item["foreground_ms"] / 1000.0 + rows.append( + { + "app_label": app_label, + "package": package_name, + "foreground_seconds": round(seconds, 3), + "foreground_minutes": round(seconds / 60.0, 3), + "foreground_hours": round(seconds / 3600.0, 4), + "percent": round((item["foreground_ms"] / total_ms * 100.0), 3) if total_ms else 0.0, + "foreground_ms": int(item["foreground_ms"]), + "first_time_stamp_ms": item["first_time_stamp_ms"], + "last_time_stamp_ms": item["last_time_stamp_ms"], + "last_time_used_ms": item["last_time_used_ms"], + } + ) + + rows.sort(key=lambda item: item["foreground_ms"], reverse=True) + for rank, row in enumerate(rows, start=1): + row["rank"] = rank + row["duration"] = _format_duration(row["foreground_seconds"]) + + output_name = _write_usage_csv(rows) + message = f"Loaded {len(rows)} apps with foreground usage in the last 24 hours. Saved {output_name}." + if not rows: + message = ( + "Usage access is enabled, but Android returned no foreground usage rows " + "for the last 24 hours. The device may have no accessible records." + ) + + return rows, { + "ok": True, + "stage": "complete", + "message": message, + "error": None, + "access_granted": True, + "app_ops_mode": access["mode"], + "app_ops_mode_name": access["mode_name"], + "sdk_int": sdk_int, + "package_name": access["package_name"], + "window_start_ms": begin_ms, + "window_end_ms": end_ms, + "query_rows": queried_count, + "rows": len(rows), + "total_foreground_seconds": round(total_ms / 1000.0, 3), + "output_path": output_name, + "package_visibility_note": ( + "On Android 11 and newer, package label lookups can be affected by package visibility. " + "UsageStats rows reflect accessible usage records." + if sdk_int >= 30 + else "" + ), + "label_errors": list(usage_label_errors), + } + + +def _make_chart_row(row, max_seconds): + container = BoxLayout( + orientation="vertical", + size_hint_y=None, + height=dp(82), + padding=(0, dp(4), 0, dp(4)), + spacing=dp(3), + ) + + header = BoxLayout( + orientation="horizontal", + size_hint_y=None, + height=dp(28), + spacing=dp(8), + ) + + name_label = Label( + text=f"{row['rank']}. {row['app_label']}", + font_size=sp(15), + halign="left", + valign="middle", + shorten=True, + shorten_from="right", + text_size=(dp(210), dp(28)), + size_hint_x=0.72, + ) + time_label = Label( + text=row.get("duration", _format_duration(row.get("foreground_seconds", 0))), + font_size=sp(14), + halign="right", + valign="middle", + text_size=(dp(90), dp(28)), + size_hint_x=0.28, + ) + header.add_widget(name_label) + header.add_widget(time_label) + + progress = ProgressBar( + max=100, + value=(row["foreground_seconds"] / max_seconds * 100.0) if max_seconds else 0, + size_hint_y=None, + height=dp(18), + ) + + detail = Label( + text=f"{row['percent']:.1f}% {row['package']}", + font_size=sp(12), + halign="left", + valign="middle", + shorten=True, + shorten_from="right", + text_size=(dp(320), dp(20)), + size_hint_y=None, + height=dp(20), + ) + + container.add_widget(header) + container.add_widget(progress) + container.add_widget(detail) + return container + + +def _update_usage_ui(): + ui = globals().get("usage_ui") + if ui is None: + return + + state = globals().get("usage_status", {}) + rows = globals().get("usage", []) + + message = state.get("message", "") + if state.get("package_visibility_note"): + message += "\n" + state["package_visibility_note"] + ui.ids.status_label.text = message + + ui.ids.chart_box.clear_widgets() + + if rows: + top_rows = rows[:20] + max_seconds = max(row["foreground_seconds"] for row in top_rows) or 1 + for row in top_rows: + ui.ids.chart_box.add_widget(_make_chart_row(row, max_seconds)) + else: + empty_label = Label( + text="No usage rows to chart yet.", + font_size=sp(17), + halign="center", + valign="middle", + text_size=(dp(320), None), + size_hint_y=None, + height=dp(140), + ) + ui.ids.chart_box.add_widget(empty_label) + + ui.ids.summary_label.text = ( + f"Rows: {state.get('rows', len(rows))} File: {state.get('output_path', usage_csv_path)}" + ) + + +def refresh_usage_stats(*args): + global usage, usage_status + try: + usage_status = { + "ok": False, + "stage": "loading", + "message": "Loading foreground usage statistics.", + "error": None, + } + _update_usage_ui() + + rows, state = _load_usage_stats() + usage = rows + usage_status = state + globals()["usage"] = usage + globals()["usage_status"] = usage_status + globals()["usage_label_cache"] = usage_label_cache + globals()["usage_label_errors"] = usage_label_errors + _update_usage_ui() + except Exception as exc: + Logger.exception("PythonHere: Could not load usage statistics") + usage = [] + usage_status = { + "ok": False, + "stage": "exception", + "message": f"Could not load usage statistics: {type(exc).__name__}: {exc}", + "error": f"{type(exc).__name__}: {exc}", + "output_path": usage_csv_path, + } + globals()["usage"] = usage + globals()["usage_status"] = usage_status + try: + _write_usage_csv([]) + except Exception as csv_exc: + Logger.exception("PythonHere: Could not write empty usage CSV after error") + usage_status["csv_error"] = f"{type(csv_exc).__name__}: {csv_exc}" + _update_usage_ui() + _show_usage_error_popup(usage_status["message"]) + + +def open_usage_access_settings(*args): + global usage_status + try: + context, activity = _get_android_context_and_activity() + if activity is None: + usage_status = { + **globals().get("usage_status", {}), + "stage": "settings", + "settings_opened": False, + "message": "A foreground Android activity is required to open Usage Access Settings.", + "error": "Foreground activity unavailable.", + } + globals()["usage_status"] = usage_status + _update_usage_ui() + return + + Intent = autoclass("android.content.Intent") + Settings = autoclass("android.provider.Settings") + + action = Settings.ACTION_USAGE_ACCESS_SETTINGS or "android.settings.USAGE_ACCESS_SETTINGS" + intent = Intent(action) + activity.startActivity(intent) + + usage_status = { + **globals().get("usage_status", {}), + "stage": "settings", + "settings_opened": True, + "settings_action": action, + "message": ( + "Usage Access Settings opened. Enable usage access for this app if it is listed, " + "return here, then tap Refresh." + ), + "error": None, + } + globals()["usage_status"] = usage_status + _update_usage_ui() + except Exception as exc: + Logger.exception("PythonHere: Could not open Usage Access Settings") + usage_status = { + **globals().get("usage_status", {}), + "stage": "settings_exception", + "settings_opened": False, + "message": f"Could not open Usage Access Settings: {type(exc).__name__}: {exc}", + "error": f"{type(exc).__name__}: {exc}", + } + globals()["usage_status"] = usage_status + _update_usage_ui() + _show_usage_error_popup(usage_status["message"]) + + +KV = """ +#:import dp kivy.metrics.dp +#:import sp kivy.metrics.sp + +BoxLayout: + orientation: "vertical" + padding: dp(12) + spacing: dp(8) + + Label: + id: title_label + text: "Foreground app usage" + size_hint_y: None + height: dp(44) + font_size: sp(22) + bold: True + halign: "center" + valign: "middle" + text_size: self.size + + Label: + id: status_label + text: "Preparing usage statistics." + size_hint_y: None + height: dp(112) + font_size: sp(14) + halign: "left" + valign: "top" + text_size: self.width, None + + BoxLayout: + orientation: "horizontal" + size_hint_y: None + height: dp(52) + spacing: dp(8) + + Button: + id: refresh_button + text: "Refresh" + font_size: sp(16) + + Button: + id: settings_button + text: "Open Settings" + font_size: sp(16) + + Label: + id: summary_label + text: "Rows: 0 File: usage.csv" + size_hint_y: None + height: dp(28) + font_size: sp(13) + halign: "left" + valign: "middle" + text_size: self.size + + ScrollView: + do_scroll_x: False + do_scroll_y: True + + GridLayout: + id: chart_box + cols: 1 + spacing: dp(8) + padding: 0, dp(4) + size_hint_y: None + height: self.minimum_height +""" + +try: + usage_ui = Builder.load_string(KV) + if usage_ui is None: + raise RuntimeError("Builder.load_string returned None for usage UI.") + + usage_ui.ids.refresh_button.bind(on_release=refresh_usage_stats) + usage_ui.ids.settings_button.bind(on_release=open_usage_access_settings) + + root.clear_widgets() + root.add_widget(usage_ui) + + globals()["usage_ui"] = usage_ui + globals()["refresh_usage_stats"] = refresh_usage_stats + globals()["open_usage_access_settings"] = open_usage_access_settings + + refresh_usage_stats() + +except Exception as exc: + Logger.exception("PythonHere: Could not build usage statistics UI") + usage_status = { + "ok": False, + "stage": "ui_exception", + "message": f"Could not build usage statistics UI: {type(exc).__name__}: {exc}", + "error": f"{type(exc).__name__}: {exc}", + "output_path": usage_csv_path, + } + globals()["usage_status"] = usage_status + _show_usage_error_popup(usage_status["message"]) + + +print(f"%there download {Path(usage_csv_path).name}") +print( + """ +Local pie chart instructions with pandas and matplotlib: + +1. Download usage.csv from this PythonHere session. +2. Put usage.csv in your local working directory. +3. Run this code locally: + +import pandas as pd +import matplotlib.pyplot as plt + +df = pd.read_csv("usage.csv") +df = df[df["foreground_seconds"] > 0].copy() + +if df.empty: + print("No usage rows found in usage.csv") +else: + top = df.sort_values("foreground_seconds", ascending=False).head(10) + other_seconds = df.loc[~df.index.isin(top.index), "foreground_seconds"].sum() + if other_seconds > 0: + top = pd.concat([ + top[["app_label", "foreground_seconds"]], + pd.DataFrame([{"app_label": "Other", "foreground_seconds": other_seconds}]), + ], ignore_index=True) + + ax = top.set_index("app_label")["foreground_seconds"].plot.pie( + autopct="%1.1f%%", + startangle=90, + counterclock=False, + figsize=(7, 7), + ) + ax.set_ylabel("") + ax.set_title("Foreground app usage, last 24 hours") + plt.tight_layout() + plt.show() +""" +) + +``` + +Check the remote usage query status and preview the first row before downloading +the CSV: + +```{code-cell} +%there get {"status": usage_status, "first_row": usage[:1]} +``` + +```{code-cell} +%there download usage.csv +``` + +After the remote cell writes `usage.csv`, download it and analyze it locally in +the notebook. + +```{code-cell} +import pandas as pd +import matplotlib.pyplot as plt + +df = pd.read_csv("usage.csv") +df = df[df["foreground_seconds"] > 0].copy() + +top = df.sort_values("foreground_seconds", ascending=False).head(5) + +ax = top.set_index("app_label")["foreground_seconds"].plot.pie( + labels=None, + autopct="%1.1f%%", + startangle=90, + counterclock=False, + figsize=(3, 3), +) + +plt.show() +``` + +## Accelerometer Maze + +```{code-cell} +:tags: ["remove-output"] +%%there ai +A maze game controlled by accelerometer. +- portrait mode +- screen should be blocked from rotation +``` + +```{code-cell} +--- +tags: + - hide-input +jupyter: + source_hidden: true +--- +%%there +# Generated locally by %%there ai. Review before running. +from math import floor +from random import shuffle + +from kivy.clock import Clock +from kivy.factory import Factory +from kivy.graphics import Color, Ellipse, Line, Rectangle +from kivy.lang import Builder +from kivy.logger import Logger +from kivy.metrics import dp +from kivy.uix.label import Label +from kivy.uix.popup import Popup +from kivy.uix.widget import Widget + +from plyer import accelerometer + + +def _maze_show_error(title, message): + globals()["maze_game_last_error"] = str(message) + try: + Popup( + title=title, + content=Label( + text=str(message)[:900], + text_size=(dp(280), None), + halign="center", + valign="middle", + ), + size_hint=(0.88, 0.45), + ).open() + except Exception: + Logger.exception("PythonHere: Could not show maze error popup") + + +def _make_accel_maze(cols=15, rows=21): + if cols % 2 == 0: + cols += 1 + if rows % 2 == 0: + rows += 1 + + maze = [[1 for _ in range(cols)] for _ in range(rows)] + start = (1, rows - 2) + goal = (cols - 2, 1) + + stack = [start] + maze[start[1]][start[0]] = 0 + + while stack: + c, r = stack[-1] + choices = [] + for dc, dr in ((2, 0), (-2, 0), (0, 2), (0, -2)): + nc, nr = c + dc, r + dr + if 1 <= nc < cols - 1 and 1 <= nr < rows - 1 and maze[nr][nc] == 1: + choices.append((nc, nr, dc, dr)) + if choices: + shuffle(choices) + nc, nr, dc, dr = choices[0] + maze[r + dr // 2][c + dc // 2] = 0 + maze[nr][nc] = 0 + stack.append((nc, nr)) + else: + stack.pop() + + maze[start[1]][start[0]] = 0 + maze[goal[1]][goal[0]] = 0 + return maze, start, goal + + +class AccelMazeBoard(Widget): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.maze = [] + self.cols = 0 + self.rows = 0 + self.start_cell = None + self.goal_cell = None + self.cell = 0 + self.board_x = 0 + self.board_y = 0 + self.ball_x = None + self.ball_y = None + self.ball_radius = 8 + self.velocity_x = 0 + self.velocity_y = 0 + self.won = False + self.bind(pos=self._on_layout_change, size=self._on_layout_change) + + def configure(self, maze, start_cell, goal_cell): + self.maze = maze + self.rows = len(maze) + self.cols = len(maze[0]) if self.rows else 0 + self.start_cell = start_cell + self.goal_cell = goal_cell + self.won = False + self.velocity_x = 0 + self.velocity_y = 0 + self.ball_x = None + self.ball_y = None + self._update_metrics() + self._ensure_ball_position() + self._redraw() + + def reset_ball(self): + self.velocity_x = 0 + self.velocity_y = 0 + self.won = False + self.ball_x = None + self.ball_y = None + self._ensure_ball_position() + self._redraw() + + def _on_layout_change(self, *args): + old_cell = self.cell + old_rel_x = None + old_rel_y = None + if self.ball_x is not None and old_cell: + old_rel_x = (self.ball_x - self.board_x) / old_cell + old_rel_y = (self.ball_y - self.board_y) / old_cell + + self._update_metrics() + + if old_rel_x is not None and self.cell: + self.ball_x = self.board_x + old_rel_x * self.cell + self.ball_y = self.board_y + old_rel_y * self.cell + self.ball_radius = max(6, self.cell * 0.30) + else: + self._ensure_ball_position() + + self._redraw() + + def _update_metrics(self): + if not self.cols or not self.rows or self.width <= 0 or self.height <= 0: + self.cell = 0 + return + self.cell = min(self.width / self.cols, self.height / self.rows) + self.board_x = self.x + (self.width - self.cell * self.cols) / 2 + self.board_y = self.y + (self.height - self.cell * self.rows) / 2 + self.ball_radius = max(6, self.cell * 0.30) + + def _cell_center(self, cell): + c, r = cell + return ( + self.board_x + (c + 0.5) * self.cell, + self.board_y + (self.rows - r - 0.5) * self.cell, + ) + + def _cell_at_point(self, x, y): + if not self.cell: + return None + c = int(floor((x - self.board_x) / self.cell)) + bottom_row = int(floor((y - self.board_y) / self.cell)) + r = self.rows - 1 - bottom_row + if 0 <= c < self.cols and 0 <= r < self.rows: + return c, r + return None + + def _ensure_ball_position(self): + if self.ball_x is None and self.start_cell and self.cell: + self.ball_x, self.ball_y = self._cell_center(self.start_cell) + self.ball_radius = max(6, self.cell * 0.30) + + def _circle_hits_wall(self, x, y): + if not self.cell or not self.maze: + return True + + radius = self.ball_radius + c0 = int(floor((x - radius - self.board_x) / self.cell)) + c1 = int(floor((x + radius - self.board_x) / self.cell)) + b0 = int(floor((y - radius - self.board_y) / self.cell)) + b1 = int(floor((y + radius - self.board_y) / self.cell)) + + for c in range(c0, c1 + 1): + for bottom_row in range(b0, b1 + 1): + r = self.rows - 1 - bottom_row + if c < 0 or c >= self.cols or r < 0 or r >= self.rows: + return True + if self.maze[r][c] == 1: + left = self.board_x + c * self.cell + bottom = self.board_y + bottom_row * self.cell + right = left + self.cell + top = bottom + self.cell + closest_x = min(max(x, left), right) + closest_y = min(max(y, bottom), top) + dx = x - closest_x + dy = y - closest_y + if dx * dx + dy * dy < radius * radius: + return True + return False + + def step(self, dt, accel_x, accel_y): + if self.won: + return + + self._update_metrics() + self._ensure_ball_position() + if self.ball_x is None or not self.cell: + return + + dt = min(max(dt, 0.0), 0.05) + + dead_zone = 0.04 + if abs(accel_x) < dead_zone: + accel_x = 0 + if abs(accel_y) < dead_zone: + accel_y = 0 + + force = self.cell * 36.0 + self.velocity_x += accel_x * force * dt + self.velocity_y += accel_y * force * dt + + friction = max(0.0, 1.0 - 2.1 * dt) + self.velocity_x *= friction + self.velocity_y *= friction + + max_speed = self.cell * 7.5 + speed_sq = self.velocity_x * self.velocity_x + self.velocity_y * self.velocity_y + if speed_sq > max_speed * max_speed: + scale = max_speed / (speed_sq ** 0.5) + self.velocity_x *= scale + self.velocity_y *= scale + + next_x = self.ball_x + self.velocity_x * dt + if not self._circle_hits_wall(next_x, self.ball_y): + self.ball_x = next_x + else: + self.velocity_x *= -0.25 + + next_y = self.ball_y + self.velocity_y * dt + if not self._circle_hits_wall(self.ball_x, next_y): + self.ball_y = next_y + else: + self.velocity_y *= -0.25 + + current_cell = self._cell_at_point(self.ball_x, self.ball_y) + if current_cell == self.goal_cell: + self.won = True + self.velocity_x = 0 + self.velocity_y = 0 + + self._redraw() + + def _redraw(self, *args): + self.canvas.clear() + with self.canvas: + Color(0.06, 0.07, 0.09, 1) + Rectangle(pos=self.pos, size=self.size) + + if not self.maze or not self.cell: + return + + board_w = self.cell * self.cols + board_h = self.cell * self.rows + + Color(0.13, 0.15, 0.18, 1) + Rectangle(pos=(self.board_x, self.board_y), size=(board_w, board_h)) + + if self.goal_cell: + gc, gr = self.goal_cell + Color(0.12, 0.72, 0.30, 1) + Rectangle( + pos=(self.board_x + gc * self.cell, self.board_y + (self.rows - 1 - gr) * self.cell), + size=(self.cell, self.cell), + ) + + Color(0.82, 0.84, 0.88, 1) + for r, row in enumerate(self.maze): + for c, value in enumerate(row): + if value == 1: + Rectangle( + pos=(self.board_x + c * self.cell, self.board_y + (self.rows - 1 - r) * self.cell), + size=(self.cell, self.cell), + ) + + Color(0.02, 0.03, 0.04, 1) + Line(rectangle=(self.board_x, self.board_y, board_w, board_h), width=max(1, self.cell * 0.05)) + + if self.ball_x is not None: + Color(0.08, 0.38, 0.95, 1) + Ellipse( + pos=(self.ball_x - self.ball_radius, self.ball_y - self.ball_radius), + size=(self.ball_radius * 2, self.ball_radius * 2), + ) + + +Factory.register("AccelMazeBoard", cls=AccelMazeBoard) + + +class AccelerometerMazeController: + def __init__(self, ui, previous_orientation=None, orientation_locked=False): + self.ui = ui + self.board = ui.ids.board_widget + self.status_label = ui.ids.status_label + self.previous_orientation = previous_orientation + self.orientation_locked = orientation_locked + self.event = None + self.paused = False + self.accelerometer_enabled = False + self.calibration = None + self.last_status_update = 0 + self.last_error = None + self.state = { + "ok": True, + "stage": "starting", + "message": "Starting maze game.", + "error": None, + "orientation_locked": bool(orientation_locked), + "accelerometer_enabled": False, + "paused": False, + "won": False, + } + globals()["maze_game_state"] = self.state + + def start(self): + self.restart() + try: + accelerometer.enable() + self.accelerometer_enabled = True + self.state["accelerometer_enabled"] = True + self._set_status("Ready. Hold the phone normally, then tilt to move.") + except Exception as exc: + Logger.exception("PythonHere: Could not enable accelerometer") + self.last_error = f"{type(exc).__name__}: {exc}" + self.state.update( + ok=False, + stage="enable_accelerometer", + error=self.last_error, + message="Accelerometer could not be enabled.", + ) + self._set_status("Accelerometer error: " + self.last_error) + + self.event = Clock.schedule_interval(self._tick, 1 / 60.0) + self.state["stage"] = "running" + + def cleanup(self, restore_orientation=False): + if self.event is not None: + self.event.cancel() + self.event = None + try: + if self.accelerometer_enabled: + accelerometer.disable() + except Exception: + Logger.exception("PythonHere: Could not disable accelerometer") + self.accelerometer_enabled = False + self.state["accelerometer_enabled"] = False + + if restore_orientation and self.previous_orientation is not None: + try: + from jnius import autoclass + + PythonActivity = autoclass("org.kivy.android.PythonActivity") + activity = PythonActivity.mActivity + if activity is not None: + activity.setRequestedOrientation(int(self.previous_orientation)) + self.state["orientation_locked"] = False + except Exception: + Logger.exception("PythonHere: Could not restore previous orientation") + + def restart(self, *args): + maze, start, goal = _make_accel_maze() + self.board.configure(maze, start, goal) + self.paused = False + self.calibration = None + self.state.update( + ok=True, + stage="running", + message="New maze started.", + error=None, + paused=False, + won=False, + rows=len(maze), + cols=len(maze[0]) if maze else 0, + ) + self.ui.ids.pause_button.text = "Pause" + self._set_status("New maze. Tilt to move the blue ball to the green goal.") + + def calibrate(self, *args): + raw = self._read_acceleration() + if raw is None: + self._set_status("Waiting for accelerometer data. Try again in a moment.") + self.state["message"] = "Calibration waiting for accelerometer data." + return + self.calibration = (raw[0], raw[1]) + self.state["calibration"] = {"x": raw[0], "y": raw[1]} + self._set_status("Calibrated. Tilt gently to move.") + + def toggle_pause(self, *args): + self.paused = not self.paused + self.state["paused"] = self.paused + self.ui.ids.pause_button.text = "Resume" if self.paused else "Pause" + self._set_status("Paused." if self.paused else "Running. Tilt to move.") + + def _set_status(self, message): + self.status_label.text = str(message) + self.state["message"] = str(message) + + def _read_acceleration(self): + raw = accelerometer.acceleration + if not raw or len(raw) < 2: + return None + if raw[0] is None or raw[1] is None: + return None + return float(raw[0]), float(raw[1]) + + def _tick(self, dt): + try: + if self.paused: + return + + raw = self._read_acceleration() + if raw is None: + if Clock.get_time() - self.last_status_update > 1.5: + self.last_status_update = Clock.get_time() + self._set_status("Waiting for accelerometer data.") + return + + if self.calibration is None: + self.calibration = (raw[0], raw[1]) + self.state["calibration"] = {"x": raw[0], "y": raw[1]} + + accel_x = raw[0] - self.calibration[0] + accel_y = raw[1] - self.calibration[1] + self.state["last_acceleration"] = {"x": raw[0], "y": raw[1]} + self.board.step(dt, accel_x, accel_y) + + if self.board.won and not self.state.get("won"): + self.state["won"] = True + self.state["stage"] = "won" + self._set_status("You reached the goal. Press Restart for a new maze.") + + except Exception as exc: + Logger.exception("PythonHere: Maze game loop failed") + self.last_error = f"{type(exc).__name__}: {exc}" + self.state.update( + ok=False, + stage="game_loop", + error=self.last_error, + message="Maze game loop error.", + ) + self._set_status("Game error: " + self.last_error) + self.paused = True + self.state["paused"] = True + + +def _install_accelerometer_maze_game(): + try: + old_cleanup = globals().get("maze_game_cleanup") + if callable(old_cleanup): + try: + old_cleanup(restore_orientation=False) + except Exception: + Logger.exception("PythonHere: Previous maze cleanup failed") + except Exception: + Logger.exception("PythonHere: Could not inspect previous maze cleanup") + + previous_orientation = None + orientation_locked = False + + try: + from jnius import autoclass + + PythonActivity = autoclass("org.kivy.android.PythonActivity") + ActivityInfo = autoclass("android.content.pm.ActivityInfo") + activity = PythonActivity.mActivity + if activity is not None: + try: + previous_orientation = int(activity.getRequestedOrientation()) + except Exception: + previous_orientation = None + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) + orientation_locked = True + except Exception as exc: + Logger.exception("PythonHere: Could not lock screen orientation") + globals()["maze_game_orientation_error"] = f"{type(exc).__name__}: {exc}" + + KV = """ +#:import dp kivy.metrics.dp +#:import sp kivy.metrics.sp + +BoxLayout: + orientation: "vertical" + padding: dp(10) + spacing: dp(8) + + Label: + id: title_label + text: "Accel Maze" + size_hint_y: None + height: dp(40) + font_size: sp(24) + bold: True + halign: "center" + valign: "middle" + text_size: self.size + + Label: + id: instruction_label + text: "Tilt the phone to move the blue ball to the green goal." + size_hint_y: None + height: dp(48) + font_size: sp(15) + halign: "center" + valign: "middle" + text_size: self.size + + AccelMazeBoard: + id: board_widget + size_hint_y: 1 + + GridLayout: + cols: 3 + spacing: dp(8) + size_hint_y: None + height: dp(56) + + Button: + id: calibrate_button + text: "Calibrate" + font_size: sp(16) + + Button: + id: restart_button + text: "Restart" + font_size: sp(16) + + Button: + id: pause_button + text: "Pause" + font_size: sp(16) + + Label: + id: status_label + text: "Starting." + size_hint_y: None + height: dp(54) + font_size: sp(14) + halign: "center" + valign: "middle" + text_size: self.size +""" + + try: + ui = Builder.load_string(KV) + if ui is None: + raise RuntimeError("Builder.load_string returned None") + + root.clear_widgets() + root.add_widget(ui) + + controller = AccelerometerMazeController( + ui, + previous_orientation=previous_orientation, + orientation_locked=orientation_locked, + ) + + ui.ids.calibrate_button.bind(on_release=controller.calibrate) + ui.ids.restart_button.bind(on_release=controller.restart) + ui.ids.pause_button.bind(on_release=controller.toggle_pause) + + globals()["maze_game_ui"] = ui + globals()["maze_game_controller"] = controller + + def maze_game_cleanup(restore_orientation=False): + controller.cleanup(restore_orientation=restore_orientation) + + globals()["maze_game_cleanup"] = maze_game_cleanup + + controller.start() + + if orientation_locked: + controller.state["orientation_message"] = "Portrait orientation locked." + else: + controller.state["orientation_message"] = "Portrait lock was not confirmed." + + except Exception as exc: + Logger.exception("PythonHere: Could not load accelerometer maze game") + globals()["maze_game_state"] = { + "ok": False, + "stage": "install", + "error": f"{type(exc).__name__}: {exc}", + "message": "Could not load accelerometer maze game.", + } + _maze_show_error("Maze error", f"{type(exc).__name__}: {exc}") + + +_install_accelerometer_maze_game() +``` + +The first generated game works, but the next prompt asks for a visual direction +and a performance improvement. `--fix` uses the previous `%%there` cell as +context and inserts a replacement cell. + +```{code-cell} +:tags: ["remove-output"] +%%there ai --fix +Apply style: bright paper map +- off-white paper background +- thin blueprint-style maze lines +- small colored player marker +- dotted trail like a route map +- goal marked with a flag +- subtle grid texture +UI controls should match the paper map style: buttons and HUD as paper cards +with light fills, thin blueprint-blue outlines, rounded corners and subtle shadows. +Keep gameplay smooth: don't redraw the full board every frame; cache the static map and only update the moving marker/trail. +``` + +```{code-cell} +%there -d 1 screenshot -w 250 +``` + +```{code-cell} +--- +tags: + - hide-input +jupyter: + source_hidden: true +--- +%%there +# Generated locally by %%there ai. Review before running. +# AI mode: fix +# Fix: restyle the maze as a bright paper map and cache the static board so only the marker and route trail update each frame. +from math import floor +from random import shuffle + +from kivy.clock import Clock +from kivy.factory import Factory +from kivy.graphics import Color, Ellipse, Line, Rectangle, RoundedRectangle +from kivy.graphics.instructions import InstructionGroup +from kivy.lang import Builder +from kivy.logger import Logger +from kivy.metrics import dp +from kivy.uix.button import Button +from kivy.uix.label import Label +from kivy.uix.popup import Popup +from kivy.uix.widget import Widget + +from plyer import accelerometer + + +PAPER_BG = (0.985, 0.965, 0.905, 1) +PAPER_CARD = (1.0, 0.988, 0.94, 1) +PAPER_CARD_DOWN = (0.955, 0.975, 0.985, 1) +BLUEPRINT = (0.075, 0.275, 0.55, 1) +BLUEPRINT_LIGHT = (0.42, 0.62, 0.78, 1) +TRAIL_BLUE = (0.08, 0.36, 0.70, 1) +PLAYER_RED = (0.92, 0.22, 0.18, 1) +FLAG_RED = (0.84, 0.16, 0.14, 1) + + +def _maze_show_error(title, message): + globals()["maze_game_last_error"] = str(message) + try: + Popup( + title=title, + content=Label( + text=str(message)[:900], + text_size=(dp(280), None), + halign="center", + valign="middle", + ), + size_hint=(0.88, 0.45), + ).open() + except Exception: + Logger.exception("PythonHere: Could not show maze error popup") + + +class PaperCanvasMixin: + paper_radius = dp(14) + + def _setup_paper_canvas(self, fill=PAPER_CARD, outline=BLUEPRINT, shadow_alpha=0.14): + self._paper_fill_rgba = fill + self._paper_outline_rgba = outline + self._paper_shadow_alpha = shadow_alpha + with self.canvas.before: + self._paper_shadow_color = Color(0.06, 0.12, 0.18, shadow_alpha) + self._paper_shadow = RoundedRectangle( + pos=(self.x + dp(2), self.y - dp(2)), + size=self.size, + radius=[self.paper_radius], + ) + self._paper_fill_color = Color(*fill) + self._paper_fill_rect = RoundedRectangle( + pos=self.pos, + size=self.size, + radius=[self.paper_radius], + ) + with self.canvas.after: + self._paper_outline_color = Color(*outline) + self._paper_outline = Line( + rounded_rectangle=(self.x, self.y, self.width, self.height, self.paper_radius), + width=dp(1.2), + ) + self.bind(pos=self._update_paper_canvas, size=self._update_paper_canvas) + + def _update_paper_canvas(self, *args): + self._paper_shadow.pos = (self.x + dp(2), self.y - dp(2)) + self._paper_shadow.size = self.size + self._paper_fill_rect.pos = self.pos + self._paper_fill_rect.size = self.size + self._paper_outline.rounded_rectangle = ( + self.x, + self.y, + self.width, + self.height, + self.paper_radius, + ) + + +class PaperButton(PaperCanvasMixin, Button): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.background_normal = "" + self.background_down = "" + self.background_color = (0, 0, 0, 0) + self.color = BLUEPRINT + self.bold = True + self._setup_paper_canvas() + self.bind(state=self._on_paper_state) + + def _on_paper_state(self, *args): + if self.state == "down": + self._paper_fill_color.rgba = PAPER_CARD_DOWN + self._paper_shadow_color.rgba = (0.06, 0.12, 0.18, 0.06) + self._paper_shadow.pos = (self.x + dp(1), self.y - dp(1)) + else: + self._paper_fill_color.rgba = PAPER_CARD + self._paper_shadow_color.rgba = (0.06, 0.12, 0.18, self._paper_shadow_alpha) + self._paper_shadow.pos = (self.x + dp(2), self.y - dp(2)) + + +class PaperCardLabel(PaperCanvasMixin, Label): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.color = BLUEPRINT + self.padding = (dp(10), dp(6)) + self._setup_paper_canvas() + + +def _make_accel_maze(cols=15, rows=21): + if cols % 2 == 0: + cols += 1 + if rows % 2 == 0: + rows += 1 + + maze = [[1 for _ in range(cols)] for _ in range(rows)] + start = (1, rows - 2) + goal = (cols - 2, 1) + + stack = [start] + maze[start[1]][start[0]] = 0 + + while stack: + c, r = stack[-1] + choices = [] + for dc, dr in ((2, 0), (-2, 0), (0, 2), (0, -2)): + nc, nr = c + dc, r + dr + if 1 <= nc < cols - 1 and 1 <= nr < rows - 1 and maze[nr][nc] == 1: + choices.append((nc, nr, dc, dr)) + if choices: + shuffle(choices) + nc, nr, dc, dr = choices[0] + maze[r + dr // 2][c + dc // 2] = 0 + maze[nr][nc] = 0 + stack.append((nc, nr)) + else: + stack.pop() + + maze[start[1]][start[0]] = 0 + maze[goal[1]][goal[0]] = 0 + return maze, start, goal + + +class AccelMazeBoard(Widget): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.maze = [] + self.cols = 0 + self.rows = 0 + self.start_cell = None + self.goal_cell = None + self.cell = 0 + self.board_x = 0 + self.board_y = 0 + self.ball_x = None + self.ball_y = None + self.ball_radius = 8 + self.velocity_x = 0 + self.velocity_y = 0 + self.won = False + + self.static_group = InstructionGroup() + self.trail_group = InstructionGroup() + self.marker_group = InstructionGroup() + self.canvas.add(self.static_group) + self.canvas.add(self.trail_group) + self.canvas.add(self.marker_group) + + self.max_trail_dots = 80 + self.trail_points = [] + self.trail_refs = [] + self._last_trail_point = None + self._build_dynamic_instruction_cache() + + self.bind(pos=self._on_layout_change, size=self._on_layout_change) + + def _build_dynamic_instruction_cache(self): + for _index in range(self.max_trail_dots): + dot_color = Color(TRAIL_BLUE[0], TRAIL_BLUE[1], TRAIL_BLUE[2], 0) + dot = Ellipse(pos=(-100, -100), size=(0, 0)) + self.trail_group.add(dot_color) + self.trail_group.add(dot) + self.trail_refs.append((dot_color, dot)) + + self.marker_shadow_color = Color(0.08, 0.10, 0.12, 0) + self.marker_shadow = Ellipse(pos=(-100, -100), size=(0, 0)) + self.marker_fill_color = Color(*PLAYER_RED) + self.marker_fill = Ellipse(pos=(-100, -100), size=(0, 0)) + self.marker_outline_color = Color(0.48, 0.04, 0.03, 0) + self.marker_outline = Line(circle=(-100, -100, 1), width=dp(1.2)) + + self.marker_group.add(self.marker_shadow_color) + self.marker_group.add(self.marker_shadow) + self.marker_group.add(self.marker_fill_color) + self.marker_group.add(self.marker_fill) + self.marker_group.add(self.marker_outline_color) + self.marker_group.add(self.marker_outline) + + def configure(self, maze, start_cell, goal_cell): + self.maze = maze + self.rows = len(maze) + self.cols = len(maze[0]) if self.rows else 0 + self.start_cell = start_cell + self.goal_cell = goal_cell + self.won = False + self.velocity_x = 0 + self.velocity_y = 0 + self.ball_x = None + self.ball_y = None + self.trail_points = [] + self._last_trail_point = None + self._update_metrics() + self._ensure_ball_position() + self._record_trail_point(force=True) + self._redraw_static_map() + self._update_trail_instructions() + self._update_marker_instruction() + + def reset_ball(self): + self.velocity_x = 0 + self.velocity_y = 0 + self.won = False + self.ball_x = None + self.ball_y = None + self.trail_points = [] + self._last_trail_point = None + self._ensure_ball_position() + self._record_trail_point(force=True) + self._update_trail_instructions() + self._update_marker_instruction() + + def _on_layout_change(self, *args): + old_cell = self.cell + old_rel_x = None + old_rel_y = None + if self.ball_x is not None and old_cell: + old_rel_x = (self.ball_x - self.board_x) / old_cell + old_rel_y = (self.ball_y - self.board_y) / old_cell + + self._update_metrics() + + if old_rel_x is not None and self.cell: + self.ball_x = self.board_x + old_rel_x * self.cell + self.ball_y = self.board_y + old_rel_y * self.cell + self.ball_radius = max(dp(4), self.cell * 0.22) + else: + self._ensure_ball_position() + + self._redraw_static_map() + self._update_trail_instructions() + self._update_marker_instruction() + + def _update_metrics(self): + if not self.cols or not self.rows or self.width <= 0 or self.height <= 0: + self.cell = 0 + return + padding = dp(8) + available_w = max(1, self.width - padding * 2) + available_h = max(1, self.height - padding * 2) + self.cell = min(available_w / self.cols, available_h / self.rows) + self.board_x = self.x + (self.width - self.cell * self.cols) / 2 + self.board_y = self.y + (self.height - self.cell * self.rows) / 2 + self.ball_radius = max(dp(4), self.cell * 0.22) + + def _cell_center(self, cell): + c, r = cell + return ( + self.board_x + (c + 0.5) * self.cell, + self.board_y + (self.rows - r - 0.5) * self.cell, + ) + + def _cell_at_point(self, x, y): + if not self.cell: + return None + c = int(floor((x - self.board_x) / self.cell)) + bottom_row = int(floor((y - self.board_y) / self.cell)) + r = self.rows - 1 - bottom_row + if 0 <= c < self.cols and 0 <= r < self.rows: + return c, r + return None + + def _ensure_ball_position(self): + if self.ball_x is None and self.start_cell and self.cell: + self.ball_x, self.ball_y = self._cell_center(self.start_cell) + self.ball_radius = max(dp(4), self.cell * 0.22) + + def _circle_hits_wall(self, x, y): + if not self.cell or not self.maze: + return True + + radius = self.ball_radius + c0 = int(floor((x - radius - self.board_x) / self.cell)) + c1 = int(floor((x + radius - self.board_x) / self.cell)) + b0 = int(floor((y - radius - self.board_y) / self.cell)) + b1 = int(floor((y + radius - self.board_y) / self.cell)) + + for c in range(c0, c1 + 1): + for bottom_row in range(b0, b1 + 1): + r = self.rows - 1 - bottom_row + if c < 0 or c >= self.cols or r < 0 or r >= self.rows: + return True + if self.maze[r][c] == 1: + left = self.board_x + c * self.cell + bottom = self.board_y + bottom_row * self.cell + right = left + self.cell + top = bottom + self.cell + closest_x = min(max(x, left), right) + closest_y = min(max(y, bottom), top) + dx = x - closest_x + dy = y - closest_y + if dx * dx + dy * dy < radius * radius: + return True + return False + + def _record_trail_point(self, force=False): + if self.ball_x is None or not self.cell: + return + rel = ( + (self.ball_x - self.board_x) / self.cell, + (self.ball_y - self.board_y) / self.cell, + ) + if self._last_trail_point is not None and not force: + dx = rel[0] - self._last_trail_point[0] + dy = rel[1] - self._last_trail_point[1] + if dx * dx + dy * dy < 0.16: + return + self.trail_points.append(rel) + self.trail_points = self.trail_points[-self.max_trail_dots:] + self._last_trail_point = rel + self._update_trail_instructions() + + def step(self, dt, accel_x, accel_y): + if self.won: + return + + self._update_metrics() + self._ensure_ball_position() + if self.ball_x is None or not self.cell: + return + + dt = min(max(dt, 0.0), 0.05) + + dead_zone = 0.04 + if abs(accel_x) < dead_zone: + accel_x = 0 + if abs(accel_y) < dead_zone: + accel_y = 0 + + force = self.cell * 36.0 + self.velocity_x += accel_x * force * dt + self.velocity_y += accel_y * force * dt + + friction = max(0.0, 1.0 - 2.1 * dt) + self.velocity_x *= friction + self.velocity_y *= friction + + max_speed = self.cell * 7.5 + speed_sq = self.velocity_x * self.velocity_x + self.velocity_y * self.velocity_y + if speed_sq > max_speed * max_speed: + scale = max_speed / (speed_sq ** 0.5) + self.velocity_x *= scale + self.velocity_y *= scale + + next_x = self.ball_x + self.velocity_x * dt + if not self._circle_hits_wall(next_x, self.ball_y): + self.ball_x = next_x + else: + self.velocity_x *= -0.25 + + next_y = self.ball_y + self.velocity_y * dt + if not self._circle_hits_wall(self.ball_x, next_y): + self.ball_y = next_y + else: + self.velocity_y *= -0.25 + + self._record_trail_point() + current_cell = self._cell_at_point(self.ball_x, self.ball_y) + if current_cell == self.goal_cell: + self.won = True + self.velocity_x = 0 + self.velocity_y = 0 + + self._update_marker_instruction() + + def _add_static_line(self, points, width=None, color=BLUEPRINT): + self.static_group.add(Color(color[0], color[1], color[2], color[3])) + self.static_group.add(Line(points=points, width=width or max(dp(0.8), self.cell * 0.035))) + + def _redraw_static_map(self): + self.static_group.clear() + + self.static_group.add(Color(*PAPER_BG)) + self.static_group.add(Rectangle(pos=self.pos, size=self.size)) + + if not self.maze or not self.cell: + return + + board_w = self.cell * self.cols + board_h = self.cell * self.rows + shadow_offset = dp(3) + + self.static_group.add(Color(0.08, 0.12, 0.16, 0.10)) + self.static_group.add( + RoundedRectangle( + pos=(self.board_x + shadow_offset, self.board_y - shadow_offset), + size=(board_w, board_h), + radius=[dp(16)], + ) + ) + + self.static_group.add(Color(1.0, 0.988, 0.94, 1)) + self.static_group.add( + RoundedRectangle( + pos=(self.board_x, self.board_y), + size=(board_w, board_h), + radius=[dp(16)], + ) + ) + + grid_width = max(dp(0.45), self.cell * 0.012) + self.static_group.add(Color(0.38, 0.56, 0.72, 0.18)) + for c in range(self.cols + 1): + x = self.board_x + c * self.cell + self.static_group.add(Line(points=[x, self.board_y, x, self.board_y + board_h], width=grid_width)) + for r in range(self.rows + 1): + y = self.board_y + r * self.cell + self.static_group.add(Line(points=[self.board_x, y, self.board_x + board_w, y], width=grid_width)) + + self.static_group.add(Color(0.12, 0.32, 0.58, 0.045)) + for r, row in enumerate(self.maze): + for c, value in enumerate(row): + if value == 1: + self.static_group.add( + Rectangle( + pos=(self.board_x + c * self.cell, self.board_y + (self.rows - 1 - r) * self.cell), + size=(self.cell, self.cell), + ) + ) + + line_width = max(dp(1.0), self.cell * 0.045) + self.static_group.add(Color(*BLUEPRINT)) + for r, row in enumerate(self.maze): + for c, value in enumerate(row): + if value != 1: + continue + + left = self.board_x + c * self.cell + bottom = self.board_y + (self.rows - 1 - r) * self.cell + right = left + self.cell + top = bottom + self.cell + + if r == 0 or self.maze[r - 1][c] == 0: + self.static_group.add(Line(points=[left, top, right, top], width=line_width)) + if r == self.rows - 1 or self.maze[r + 1][c] == 0: + self.static_group.add(Line(points=[left, bottom, right, bottom], width=line_width)) + if c == 0 or self.maze[r][c - 1] == 0: + self.static_group.add(Line(points=[left, bottom, left, top], width=line_width)) + if c == self.cols - 1 or self.maze[r][c + 1] == 0: + self.static_group.add(Line(points=[right, bottom, right, top], width=line_width)) + + self.static_group.add(Color(0.02, 0.16, 0.34, 0.82)) + self.static_group.add( + Line( + rounded_rectangle=(self.board_x, self.board_y, board_w, board_h, dp(16)), + width=max(dp(1.1), self.cell * 0.035), + ) + ) + + if self.goal_cell: + cx, cy = self._cell_center(self.goal_cell) + pole_bottom = cy - self.cell * 0.34 + pole_top = cy + self.cell * 0.36 + flag_w = self.cell * 0.48 + flag_h = self.cell * 0.24 + self.static_group.add(Color(0.08, 0.24, 0.43, 1)) + self.static_group.add(Line(points=[cx, pole_bottom, cx, pole_top], width=max(dp(1.2), self.cell * 0.045))) + self.static_group.add(Color(*FLAG_RED)) + self.static_group.add( + Rectangle( + pos=(cx, pole_top - flag_h), + size=(flag_w, flag_h), + ) + ) + self.static_group.add(Color(0.55, 0.05, 0.04, 1)) + self.static_group.add( + Line( + rectangle=(cx, pole_top - flag_h, flag_w, flag_h), + width=max(dp(0.8), self.cell * 0.025), + ) + ) + + def _update_trail_instructions(self): + visible = self.trail_points[-self.max_trail_dots:] + dot_radius = max(dp(1.7), self.cell * 0.075) if self.cell else dp(2) + + for index, (dot_color, dot) in enumerate(self.trail_refs): + if index < len(visible) and self.cell: + rel_x, rel_y = visible[index] + px = self.board_x + rel_x * self.cell + py = self.board_y + rel_y * self.cell + age = index / max(1, len(visible) - 1) + alpha = 0.16 + 0.58 * age + dot_color.rgba = (TRAIL_BLUE[0], TRAIL_BLUE[1], TRAIL_BLUE[2], alpha) + dot.pos = (px - dot_radius, py - dot_radius) + dot.size = (dot_radius * 2, dot_radius * 2) + else: + dot_color.rgba = (TRAIL_BLUE[0], TRAIL_BLUE[1], TRAIL_BLUE[2], 0) + dot.pos = (-100, -100) + dot.size = (0, 0) + + def _update_marker_instruction(self): + if self.ball_x is None or not self.cell: + self.marker_shadow_color.rgba = (0.08, 0.10, 0.12, 0) + self.marker_fill_color.rgba = (PLAYER_RED[0], PLAYER_RED[1], PLAYER_RED[2], 0) + self.marker_outline_color.rgba = (0.48, 0.04, 0.03, 0) + return + + radius = self.ball_radius + self.marker_shadow_color.rgba = (0.08, 0.10, 0.12, 0.18) + self.marker_shadow.pos = ( + self.ball_x - radius + dp(1.3), + self.ball_y - radius - dp(1.3), + ) + self.marker_shadow.size = (radius * 2, radius * 2) + + self.marker_fill_color.rgba = PLAYER_RED + self.marker_fill.pos = (self.ball_x - radius, self.ball_y - radius) + self.marker_fill.size = (radius * 2, radius * 2) + + self.marker_outline_color.rgba = (0.48, 0.04, 0.03, 1) + self.marker_outline.circle = (self.ball_x, self.ball_y, radius) + self.marker_outline.width = max(dp(1.0), self.cell * 0.035) + + +Factory.register("PaperButton", cls=PaperButton) +Factory.register("PaperCardLabel", cls=PaperCardLabel) +Factory.register("AccelMazeBoard", cls=AccelMazeBoard) + + +class AccelerometerMazeController: + def __init__(self, ui, previous_orientation=None, orientation_locked=False): + self.ui = ui + self.board = ui.ids.board_widget + self.status_label = ui.ids.status_label + self.previous_orientation = previous_orientation + self.orientation_locked = orientation_locked + self.event = None + self.paused = False + self.accelerometer_enabled = False + self.calibration = None + self.last_status_update = 0 + self.last_error = None + self.state = { + "ok": True, + "stage": "starting", + "message": "Starting paper map maze.", + "error": None, + "orientation_locked": bool(orientation_locked), + "accelerometer_enabled": False, + "paused": False, + "won": False, + "style": "bright paper map", + "static_map_cached": True, + } + globals()["maze_game_state"] = self.state + + def start(self): + self.restart() + try: + accelerometer.enable() + self.accelerometer_enabled = True + self.state["accelerometer_enabled"] = True + self._set_status("Ready. Hold the phone normally, then tilt to move.") + except Exception as exc: + Logger.exception("PythonHere: Could not enable accelerometer") + self.last_error = f"{type(exc).__name__}: {exc}" + self.state.update( + ok=False, + stage="enable_accelerometer", + error=self.last_error, + message="Accelerometer could not be enabled.", + ) + self._set_status("Accelerometer error: " + self.last_error) + + self.event = Clock.schedule_interval(self._tick, 1 / 60.0) + self.state["stage"] = "running" + + def cleanup(self, restore_orientation=False): + if self.event is not None: + self.event.cancel() + self.event = None + try: + if self.accelerometer_enabled: + accelerometer.disable() + except Exception: + Logger.exception("PythonHere: Could not disable accelerometer") + self.accelerometer_enabled = False + self.state["accelerometer_enabled"] = False + + if restore_orientation and self.previous_orientation is not None: + try: + from jnius import autoclass + + PythonActivity = autoclass("org.kivy.android.PythonActivity") + activity = PythonActivity.mActivity + if activity is not None: + activity.setRequestedOrientation(int(self.previous_orientation)) + self.state["orientation_locked"] = False + except Exception: + Logger.exception("PythonHere: Could not restore previous orientation") + + def restart(self, *args): + maze, start, goal = _make_accel_maze() + self.board.configure(maze, start, goal) + self.paused = False + self.calibration = None + self.state.update( + ok=True, + stage="running", + message="New paper map maze started.", + error=None, + paused=False, + won=False, + rows=len(maze), + cols=len(maze[0]) if maze else 0, + trail_dots=0, + ) + self.ui.ids.pause_button.text = "Pause" + self._set_status("New map. Tilt to move the red marker to the flag.") + + def calibrate(self, *args): + raw = self._read_acceleration() + if raw is None: + self._set_status("Waiting for accelerometer data. Try again in a moment.") + self.state["message"] = "Calibration waiting for accelerometer data." + return + self.calibration = (raw[0], raw[1]) + self.state["calibration"] = {"x": raw[0], "y": raw[1]} + self._set_status("Calibrated. Tilt gently to follow the route.") + + def toggle_pause(self, *args): + self.paused = not self.paused + self.state["paused"] = self.paused + self.ui.ids.pause_button.text = "Resume" if self.paused else "Pause" + self._set_status("Paused." if self.paused else "Running. Tilt to move.") + + def _set_status(self, message): + self.status_label.text = str(message) + self.state["message"] = str(message) + + def _read_acceleration(self): + raw = accelerometer.acceleration + if not raw or len(raw) < 2: + return None + if raw[0] is None or raw[1] is None: + return None + return float(raw[0]), float(raw[1]) + + def _tick(self, dt): + try: + if self.paused: + return + + raw = self._read_acceleration() + if raw is None: + if Clock.get_time() - self.last_status_update > 1.5: + self.last_status_update = Clock.get_time() + self._set_status("Waiting for accelerometer data.") + return + + if self.calibration is None: + self.calibration = (raw[0], raw[1]) + self.state["calibration"] = {"x": raw[0], "y": raw[1]} + + accel_x = raw[0] - self.calibration[0] + accel_y = raw[1] - self.calibration[1] + self.state["last_acceleration"] = {"x": raw[0], "y": raw[1]} + self.board.step(dt, accel_x, accel_y) + self.state["trail_dots"] = len(self.board.trail_points) + + if self.board.won and not self.state.get("won"): + self.state["won"] = True + self.state["stage"] = "won" + self._set_status("You reached the flag. Press Restart for a new map.") + + except Exception as exc: + Logger.exception("PythonHere: Maze game loop failed") + self.last_error = f"{type(exc).__name__}: {exc}" + self.state.update( + ok=False, + stage="game_loop", + error=self.last_error, + message="Maze game loop error.", + ) + self._set_status("Game error: " + self.last_error) + self.paused = True + self.state["paused"] = True + + +def _install_accelerometer_maze_game(): + try: + old_cleanup = globals().get("maze_game_cleanup") + if callable(old_cleanup): + try: + old_cleanup(restore_orientation=False) + except Exception: + Logger.exception("PythonHere: Previous maze cleanup failed") + except Exception: + Logger.exception("PythonHere: Could not inspect previous maze cleanup") + + previous_orientation = None + orientation_locked = False + + try: + from jnius import autoclass + + PythonActivity = autoclass("org.kivy.android.PythonActivity") + ActivityInfo = autoclass("android.content.pm.ActivityInfo") + activity = PythonActivity.mActivity + if activity is not None: + try: + previous_orientation = int(activity.getRequestedOrientation()) + except Exception: + previous_orientation = None + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) + orientation_locked = True + except Exception as exc: + Logger.exception("PythonHere: Could not lock screen orientation") + globals()["maze_game_orientation_error"] = f"{type(exc).__name__}: {exc}" + + KV = """ +#:import dp kivy.metrics.dp +#:import sp kivy.metrics.sp + +BoxLayout: + orientation: "vertical" + padding: dp(10) + spacing: dp(8) + canvas.before: + Color: + rgba: 0.985, 0.965, 0.905, 1 + Rectangle: + pos: self.pos + size: self.size + + PaperCardLabel: + id: title_label + text: "Paper Map Maze" + size_hint_y: None + height: dp(46) + font_size: sp(24) + bold: True + halign: "center" + valign: "middle" + text_size: self.size + + PaperCardLabel: + id: instruction_label + text: "Tilt the phone to move the red marker to the flag." + size_hint_y: None + height: dp(50) + font_size: sp(15) + halign: "center" + valign: "middle" + text_size: self.size + + AccelMazeBoard: + id: board_widget + size_hint_y: 1 + + GridLayout: + cols: 3 + spacing: dp(8) + size_hint_y: None + height: dp(58) + + PaperButton: + id: calibrate_button + text: "Calibrate" + font_size: sp(16) + + PaperButton: + id: restart_button + text: "Restart" + font_size: sp(16) + + PaperButton: + id: pause_button + text: "Pause" + font_size: sp(16) + + PaperCardLabel: + id: status_label + text: "Starting." + size_hint_y: None + height: dp(56) + font_size: sp(14) + halign: "center" + valign: "middle" + text_size: self.size +""" + + try: + ui = Builder.load_string(KV) + if ui is None: + raise RuntimeError("Builder.load_string returned None") + + root.clear_widgets() + root.add_widget(ui) + + controller = AccelerometerMazeController( + ui, + previous_orientation=previous_orientation, + orientation_locked=orientation_locked, + ) + + ui.ids.calibrate_button.bind(on_release=controller.calibrate) + ui.ids.restart_button.bind(on_release=controller.restart) + ui.ids.pause_button.bind(on_release=controller.toggle_pause) + + globals()["maze_game_ui"] = ui + globals()["maze_game_controller"] = controller + + def maze_game_cleanup(restore_orientation=False): + controller.cleanup(restore_orientation=restore_orientation) + + globals()["maze_game_cleanup"] = maze_game_cleanup + + controller.start() + + if orientation_locked: + controller.state["orientation_message"] = "Portrait orientation locked." + else: + controller.state["orientation_message"] = "Portrait lock was not confirmed." + + except Exception as exc: + Logger.exception("PythonHere: Could not load accelerometer maze game") + globals()["maze_game_state"] = { + "ok": False, + "stage": "install", + "error": f"{type(exc).__name__}: {exc}", + "message": "Could not load accelerometer maze game.", + } + _maze_show_error("Maze error", f"{type(exc).__name__}: {exc}") + + +_install_accelerometer_maze_game() +``` + +```{code-cell} +%there -d 1 screenshot -w 250 +``` diff --git a/examples/there_ai/there.env b/examples/there_ai/there.env new file mode 100644 index 0000000..7dcd6ca --- /dev/null +++ b/examples/there_ai/there.env @@ -0,0 +1,11 @@ +# PythonHere device IP address +THERE_HOST= + +# Port, as set in PythonHere app Settings section +THERE_PORT=8022 + +# Username, as set in PythonHere app Settings section +THERE_USERNAME=here + +# Password, as set in PythonHere app Settings section +THERE_PASSWORD= diff --git a/examples/there_ai/there_ai.env b/examples/there_ai/there_ai.env new file mode 100644 index 0000000..1af6e9b --- /dev/null +++ b/examples/there_ai/there_ai.env @@ -0,0 +1,14 @@ +# Model name for the OpenAI-compatible chat/completions API +THERE_AI_MODEL= + +# API key for hosted providers; leave empty for local providers +THERE_AI_API_KEY= + +# OpenAI-compatible API base URL +THERE_AI_BASE_URL=https://api.openai.com/v1 + +# Sampling temperature for generated code +THERE_AI_TEMPERATURE=0.2 + +# Request timeout in seconds +THERE_AI_TIMEOUT=300 diff --git a/pyproject.toml b/pyproject.toml index a73f936..d8989fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "herethere[magic]>=0.2.1", + "herethere[magic]>=0.2.3", "ipython", "ipywidgets", "Pillow", @@ -46,6 +46,8 @@ dev = [ ] docker = [ "jupytext==1.19.3", + "pandas", + "matplotlib", ] [project.urls] @@ -75,6 +77,7 @@ version = { attr = "pythonhere.version_here.__version__" } pythonhere = [ "*.kv", "data/logo/*.png", + "magic_here/prompts/*.md", "ui_here/*.kv", ] diff --git a/pythonhere/__init__.py b/pythonhere/__init__.py index af49762..d56fff5 100644 --- a/pythonhere/__init__.py +++ b/pythonhere/__init__.py @@ -1,8 +1,15 @@ """PythonHere Jupyter magic.""" -from herethere.magic import load_ipython_extension +from herethere.magic import load_ipython_extension as load_herethere_extension from .magic_here import shortcuts # noqa +from .magic_here.prompts import register_pythonhere_ai_prompts from .version_here import __version__ # noqa __all__ = ("load_ipython_extension",) + + +def load_ipython_extension(ipython): + """Hook for `%load_extension pythonhere`.""" + register_pythonhere_ai_prompts() + load_herethere_extension(ipython) diff --git a/pythonhere/magic_here/prompts.py b/pythonhere/magic_here/prompts.py new file mode 100644 index 0000000..1479b5f --- /dev/null +++ b/pythonhere/magic_here/prompts.py @@ -0,0 +1,37 @@ +"""PythonHere prompt sections for ``%%there ai``.""" + +from importlib.resources import files + +from herethere.there.ai import register_ai_prompt, set_ai_prompts + +PYTHONHERE_AI_ACTIVE_PROMPTS = ( + "kivy-runtime", + "kivy-kv", + "android-runtime", + "jnius", + "android-permissions", + "android-packages", + "android-media", + "plyer", +) + +PYTHONHERE_AI_PROMPTS = ( + *PYTHONHERE_AI_ACTIVE_PROMPTS, + "able", + "midi", +) + + +def _read_prompt(name: str) -> str: + return ( + files("pythonhere.magic_here") + .joinpath("prompts", f"{name}.md") + .read_text(encoding="utf-8") + ) + + +def register_pythonhere_ai_prompts() -> None: + """Register PythonHere Android/Kivy prompt sections as the active AI stack.""" + for name in PYTHONHERE_AI_PROMPTS: + register_ai_prompt(name, _read_prompt(name)) + set_ai_prompts("default", *PYTHONHERE_AI_ACTIVE_PROMPTS) diff --git a/pythonhere/magic_here/prompts/README.md b/pythonhere/magic_here/prompts/README.md new file mode 100644 index 0000000..257a15c --- /dev/null +++ b/pythonhere/magic_here/prompts/README.md @@ -0,0 +1,49 @@ +# PythonHere `%%there ai` prompts + +These Markdown files are PythonHere-specific prompt sections for `%%there ai`. +They are registered when the `pythonhere` IPython extension is loaded. + +The prompt sections describe the live PythonHere runtime: Kivy widgets, +Android/Python-for-Android APIs, Pyjnius, Plyer, runtime permissions, installed +packages, media access, BLE, and MIDI. + +## Active by default + +Normal `%%there ai` requests use these PythonHere sections together with the +generic [`default`](https://github.com/b3b/herethere/blob/master/herethere/there/ai/prompts/default.md) +prompt from `herethere`: + +- [`kivy-runtime`](kivy-runtime.md) +- [`kivy-kv`](kivy-kv.md) +- [`android-runtime`](android-runtime.md) +- [`jnius`](jnius.md) +- [`android-permissions`](android-permissions.md) +- [`android-packages`](android-packages.md) +- [`android-media`](android-media.md) +- [`plyer`](plyer.md) + +## Available on request + +These sections are registered but not active by default. Add them with +`%%there ai --prompts ...` when a request needs that context: + +- [`able`](able.md) +- [`midi`](midi.md) + +Example: + +```python +%%there ai --prompts able +Build a small BLE scanner prototype. +``` + +`%%there ai --fix` also uses the `herethere` +[`fix`](https://github.com/b3b/herethere/blob/master/herethere/there/ai/prompts/fix.md) +prompt section. + +## Custom prompts + +Notebook-specific prompt sections can be added with +`herethere.there.ai.register_ai_prompt(...)`. Use custom prompts for visual +style, domain vocabulary, prototype conventions, or other context that should +not be part of the built-in PythonHere prompt stack. diff --git a/pythonhere/magic_here/prompts/able.md b/pythonhere/magic_here/prompts/able.md new file mode 100644 index 0000000..823c491 --- /dev/null +++ b/pythonhere/magic_here/prompts/able.md @@ -0,0 +1,554 @@ +## Android BLE using `able` + +Use `able` package when the user asks for BLE (Bluetooth Low Energy) operations. +`able` is installed; do not need to check for import errors before normal use. + +Core API: +- Import the BLE dispatcher with: + from able import BluetoothDispatcher +- Create one shared dispatcher and keep it alive while BLE operations are needed: + ble = BluetoothDispatcher() +- Reuse the shared dispatcher across cells: + if "ble" not in globals() or ble is None: + ble = BluetoothDispatcher() +- Close the current GATT client with: + ble.close_gatt() +- Start scanning with: + ble.start_scan() +- Stop scanning with: + ble.stop_scan() +- Connect to a scanned Java BluetoothDevice with: + ble.connect_gatt(device, autoconnect=False) +- Connect directly by hardware address with: + ble.connect_by_device_address(address, autoconnect=False) +- Discover services after connection with: + ble.discover_services() +- Read a characteristic with: + ble.read_characteristic(characteristic) +- Write a characteristic with: + ble.write_characteristic(characteristic, value) +- Write a descriptor with: + ble.write_descriptor(descriptor, value) +- Enable notifications with: + ble.enable_notifications(characteristic, enable=True, indication=False) +- Disable notifications with: + ble.enable_notifications(characteristic, enable=False) +- Request MTU with: + ble.request_mtu(mtu) +- Update RSSI with: + ble.update_rssi() +- Change queue timeout with: + ble.set_queue_timeout(timeout) + +Important constants: +- Import common constants when needed: + from able import GATT_SUCCESS, STATE_CONNECTED, STATE_DISCONNECTED, WriteType +- `GATT_SUCCESS` is 0. +- `STATE_CONNECTED` is 2. +- `STATE_DISCONNECTED` is 0. +- `WriteType.SIGNED` can be used for signed characteristic writes when requested. + +BLE dispatcher state: +- Use a global variable named `ble` for the shared `BluetoothDispatcher`. +- Do not create a new `BluetoothDispatcher` for every operation. +- Keep the dispatcher globally inspectable so later cells can run: + ble.adapter + ble.bonded_devices + ble.gatt + ble.name + ble.stop_scan() + ble.close_gatt() +- Store discovered devices in a global dictionary such as: + ble_devices_by_address +- Store the latest services in: + ble_services +- Store recent events in: + ble_events +- Store recent errors in: + ble_errors +- Store compact status messages in: + ble_status_messages +- Store the latest characteristic values in: + ble_characteristic_values +- Store the latest notification values in: + ble_notifications +- Store the latest scan summary in: + ble_scan_summary +- Store the currently connected address in: + ble_connected_address +- Store the last operation status in: + ble_last_result + +Recommended shared initialization: + from able import BluetoothDispatcher + + if "ble_events" not in globals(): + ble_events = [] + + if "ble_errors" not in globals(): + ble_errors = [] + + if "ble_status_messages" not in globals(): + ble_status_messages = [] + + if "ble_devices_by_address" not in globals(): + ble_devices_by_address = {} + + if "ble_characteristic_values" not in globals(): + ble_characteristic_values = {} + + if "ble_notifications" not in globals(): + ble_notifications = [] + + if "ble_scan_summary" not in globals(): + ble_scan_summary = {} + + if "ble" not in globals() or ble is None: + ble = BluetoothDispatcher() + +Subclassing pattern: +- For scans, connections, services, reads, writes, notifications, RSSI, and MTU, prefer a small subclass of `BluetoothDispatcher` with event handlers. +- Reuse an existing subclass instance when possible. +- Keep callbacks short. +- Do not rely on `print(...)` inside BLE callbacks as the only output. BLE + callbacks may run after notebook output capture has ended. Store every event, + result, and error in globals such as `ble_events`, `ble_errors`, + `ble_devices_by_address`, `ble_services`, and `ble_notifications`. +- In BLE callbacks, prefer appending a compact tuple/dict to `ble_events` or a + feature-specific global over printing. Use `print(...)` only in immediate + synchronous code that runs before the cell returns. +- Do not schedule delayed functions whose purpose is to print scan summaries. + A delayed `Clock.schedule_once(...)` callback may run after notebook output + capture has ended. Store delayed summaries in globals such as + `ble_scan_summary` and update visible UI/status messages instead. +- If the user asked for visible progress, update a Kivy status label or popup + from callbacks using `Clock.schedule_once(...)`. +- Store Java objects by address or UUID-like key so later cells can use them. +- Do not print huge advertisement dumps or service trees directly. +- Print compact immediate summaries from the initiating cell and store full + callback results globally. + +Recommended dispatcher subclass skeleton: + from able import BluetoothDispatcher, GATT_SUCCESS, STATE_CONNECTED, STATE_DISCONNECTED + from kivy.clock import Clock + + def update_ble_status(message): + ble_status_messages.append(str(message)) + label = globals().get("ble_status_label") + if label is not None: + Clock.schedule_once(lambda dt: setattr(label, "text", str(message)), 0) + + class PythonHereBLE(BluetoothDispatcher): + def on_scan_started(self, success): + ble_events.append(("scan_started", bool(success))) + update_ble_status(f"BLE scan started: {bool(success)}") + + def on_scan_completed(self): + ble_events.append(("scan_completed", None)) + update_ble_status("BLE scan completed") + + def on_device(self, device, rssi, advertisement): + address = str(device.getAddress()) + try: + name = device.getName() + name = str(name) if name is not None else None + except Exception: + name = None + + ble_devices_by_address[address] = { + "device": device, + "address": address, + "name": name, + "rssi": int(rssi), + "advertisement": advertisement, + } + ble_events.append(("device", address, int(rssi), name)) + update_ble_status(f"{len(ble_devices_by_address)} BLE devices found") + + def on_connection_state_change(self, status, state): + global ble_connected_address + ble_events.append(("connection_state_change", int(status), int(state))) + + if int(status) == GATT_SUCCESS and int(state) == STATE_CONNECTED: + ble_connected_address = "connected" + update_ble_status("BLE connected") + self.discover_services() + elif int(state) == STATE_DISCONNECTED: + ble_connected_address = None + update_ble_status("BLE disconnected") + else: + update_ble_status(f"BLE connection state: {status}, {state}") + + def on_services(self, services, status): + global ble_services + ble_events.append(("services", int(status))) + if int(status) == GATT_SUCCESS: + ble_services = services + update_ble_status("BLE services discovered; stored in ble_services") + else: + update_ble_status(f"BLE service discovery failed: {status}") + + def on_characteristic_read(self, characteristic, status): + uuid = str(characteristic.getUuid()) + value = list(characteristic.getValue() or []) + ble_characteristic_values[uuid] = { + "uuid": uuid, + "status": int(status), + "value": value, + "characteristic": characteristic, + } + ble_events.append(("characteristic_read", uuid, int(status), value[:32])) + update_ble_status(f"Characteristic read: {uuid} status={status}") + + def on_characteristic_write(self, characteristic, status): + uuid = str(characteristic.getUuid()) + ble_events.append(("characteristic_write", uuid, int(status))) + update_ble_status(f"Characteristic write: {uuid} status={status}") + + def on_characteristic_changed(self, characteristic): + uuid = str(characteristic.getUuid()) + value = list(characteristic.getValue() or []) + event = { + "uuid": uuid, + "value": value, + "characteristic": characteristic, + } + ble_notifications.append(event) + ble_events.append(("notification", uuid, value[:32])) + update_ble_status(f"Notification: {uuid}") + + def on_descriptor_read(self, descriptor, status): + uuid = str(descriptor.getUuid()) + ble_events.append(("descriptor_read", uuid, int(status))) + update_ble_status(f"Descriptor read: {uuid} status={status}") + + def on_descriptor_write(self, descriptor, status): + uuid = str(descriptor.getUuid()) + ble_events.append(("descriptor_write", uuid, int(status))) + update_ble_status(f"Descriptor write: {uuid} status={status}") + + def on_rssi_updated(self, rssi, status): + ble_events.append(("rssi_updated", int(rssi), int(status))) + update_ble_status(f"RSSI: {int(rssi)} status={status}") + + def on_mtu_changed(self, mtu, status): + ble_events.append(("mtu_changed", int(mtu), int(status))) + update_ble_status(f"MTU: {int(mtu)} status={status}") + + def on_gatt_release(self): + ble_events.append(("gatt_release", None)) + + def on_error(self, msg): + msg = str(msg) + ble_errors.append(msg) + update_ble_status(f"BLE error: {msg}") + + if "ble" not in globals() or ble is None or not isinstance(ble, PythonHereBLE): + ble = PythonHereBLE() + +Scanning: +- For simple scan requests, start scanning and schedule `ble.stop_scan()` after a short timeout. +- Do not scan indefinitely unless the user explicitly asks. +- Store devices by Bluetooth address in `ble_devices_by_address`. +- Store compact scan summaries in `ble_scan_summary`. +- Do not schedule delayed print summaries after scans. Print only one immediate + line before the cell returns, naming the globals where results will appear. +- If the user gives a name/address/service/manufacturer filter, use Able filters instead of scanning everything when possible. + +Recommended simple scan: + from kivy.clock import Clock + + def finish_ble_scan(dt): + global ble_scan_summary + try: + ble.stop_scan() + finally: + sample = [] + for address, info in list(ble_devices_by_address.items())[:10]: + sample.append({ + "address": address, + "name": info.get("name"), + "rssi": info.get("rssi"), + }) + ble_scan_summary = { + "device_count": len(ble_devices_by_address), + "sample": sample, + } + ble_events.append(("scan_summary", ble_scan_summary)) + update_ble_status( + f"BLE scan complete: {ble_scan_summary['device_count']} device(s)" + ) + + ble.start_scan() + Clock.schedule_once(finish_ble_scan, 8) + print("Scanning for 8 seconds. Results will be stored in ble_devices_by_address and ble_scan_summary.") + +Scan filters: +- Import filters from `able.filters` when needed: + from able.filters import ( + EmptyFilter, + DeviceAddressFilter, + DeviceNameFilter, + ManufacturerDataFilter, + ServiceDataFilter, + ServiceSolicitationFilter, + ServiceUUIDFilter, + ) +- Use `DeviceNameFilter(name)` for exact device-name filtering. +- Use `DeviceAddressFilter("01:02:03:AB:CD:EF")` for a specific BLE address. +- Use `ServiceUUIDFilter(uuid)` for service UUID filtering. +- Use `ManufacturerDataFilter(id, data, mask=None)` for manufacturer data filtering. +- Use `ServiceDataFilter(uuid, data, mask=None)` for service data filtering. +- Filters of different kinds can be combined with `&`. +- Do not combine two filters of the same kind; Able raises `ValueError`. +- Pass filters as a list to `ble.start_scan(filters=[...])`. + +Scan settings: +- Import scan setting builders when the user asks for scan mode, match mode, callback type, or low-latency scan tuning: + from able.scan_settings import ScanSettingsBuilder, ScanSettings +- Use `ScanSettingsBuilder()` and builder methods for custom settings. +- Keep settings simple unless the user asks for advanced tuning. + +Advertisement parsing: +- Use `able.Advertisement` objects received by `on_device`. +- Do not manually parse raw advertisement bytes unless the user asks. +- For readable summaries, iterate over the advertisement and store parsed AD structures. +- Keep manufacturer and service data as lists of integers or bytes-like values. + +Services: +- `on_services(services, status)` receives an Able `Services` dict-like object. +- Store it globally as `ble_services`. +- Use `ble_services.search(pattern)` to find a characteristic by regex pattern. +- Do not assume a characteristic UUID exists before services are discovered. +- For user-provided UUID or partial UUID, search `ble_services` first. +- Store found characteristics in a global such as `ble_characteristics_by_name`. + +Recommended characteristic search: + characteristic = ble_services.search("2a37") + if characteristic is None: + print("Characteristic not found") + else: + print("Characteristic stored in characteristic") + ble_characteristic = characteristic + +Connecting: +- If the user gives a BLE address, use: + ble.connect_by_device_address(address, autoconnect=False) +- If the user picks a scanned device, retrieve it from: + ble_devices_by_address[address]["device"] + then call: + ble.connect_gatt(device, autoconnect=False) +- Validate address-like strings before direct connect when possible. +- Do not assume connection succeeds immediately; wait for `on_connection_state_change`. +- After successful connection, call `discover_services()` from the connection callback or instruct the user to run it after connection. +- Store connected state in globals. + +Reads and writes: +- For reads, use a characteristic object from `ble_services.search(...)` or a previously stored characteristic. +- For writes, accept bytes, bytearray, or list of integers. +- Keep values in 0..255. +- Convert simple text only when the user clearly asks to send text. +- Do not write to characteristics discovered as read-only unless the user explicitly asks to try. +- Do not repeatedly write in a loop unless the user explicitly asks. +- Store write attempts and statuses in `ble_events`. + +Notifications and indications: +- Use `ble.enable_notifications(characteristic, enable=True, indication=False)` for notifications. +- Use `indication=True` only when the user asks for indications or when the characteristic is known to require indications. +- Store received notification values in `ble_notifications`. +- Provide a disable command: + ble.enable_notifications(characteristic, enable=False) + +RSSI and MTU: +- Use `ble.update_rssi()` when the user asks for signal strength. +- Use `ble.request_mtu(mtu)` when the user asks to change MTU. +- Validate MTU as a reasonable positive integer before requesting. +- Do not assume MTU changed until `on_mtu_changed` reports status. + +Advertising: +- Use advertising only when the user asks to advertise, broadcast, become a BLE peripheral advertiser, or send advertisement data. +- Import advertising helpers when needed: + from able.advertising import ( + Advertiser, + AdvertiseData, + DeviceName, + TXPowerLevel, + ServiceUUID, + ServiceData, + ManufacturerData, + Interval, + TXPower, + Status, + ) +- Create one global advertiser reference: + ble_advertiser +- Stop existing advertising before starting a new advertiser if appropriate. +- Keep advertising payload small. +- Do not include private data in BLE advertisements. +- Store advertising status in: + ble_advertising_result +- Provide a stop helper for advertising: + def stop_ble_advertising(): + ble_advertiser.stop() + +Advertising example: + from able.advertising import Advertiser, AdvertiseData, DeviceName, TXPowerLevel, Interval, TXPower + + ble_advertiser = Advertiser( + ble=ble, + data=AdvertiseData(DeviceName()), + scan_data=AdvertiseData(TXPowerLevel()), + interval=Interval.HIGH, + tx_power=TXPower.MEDIUM, + ) + ble_advertiser.start() + print("Started BLE advertising; advertiser stored in ble_advertiser") + +Permissions: +- Able methods that require the Bluetooth adapter can request runtime permissions and ask the user to enable Bluetooth. +- Target API level <= 30 commonly needs `ACCESS_FINE_LOCATION` to obtain BLE scan results. +- Target API level >= 31 commonly needs `BLUETOOTH_CONNECT`, `BLUETOOTH_SCAN`, `ACCESS_FINE_LOCATION`, and sometimes `BLUETOOTH_ADVERTISE`. +- Able permission constants are available as: + from able import Permission + Permission.ACCESS_FINE_LOCATION + Permission.ACCESS_BACKGROUND_LOCATION + Permission.BLUETOOTH_CONNECT + Permission.BLUETOOTH_SCAN + Permission.BLUETOOTH_ADVERTISE +- The requested permission list can be overridden with: + BluetoothDispatcher(runtime_permissions=[...]) +- Do not duplicate generic Android permission request code here; use Able's dispatcher behavior or the always-enabled Android permissions prompt when the user explicitly asks for permission-specific checks. +- Do not claim BLE permissions are granted until the relevant operation callback or permission result confirms success. + +Bluetooth adapter: +- `ble.adapter` returns the local Android BluetoothAdapter Java object or None. +- `ble.name` reads or sets the adapter name. +- `ble.bonded_devices` returns Java BluetoothDevice objects for paired devices. +- If the adapter is disabled, Able may launch the system activity to let the user enable Bluetooth. +- Do not assume BLE is available on all devices. + +Diagnostics: +- For debugging, print readable compact diagnostics. +- Useful diagnostics include: + - whether the shared `ble` object exists, + - class name of `ble`, + - `ble.adapter is not None`, + - `ble.name`, + - number of `ble.bonded_devices`, + - number of discovered devices, + - discovered device addresses/names/RSSI, + - whether `ble.gatt` is not None, + - whether `ble_services` exists, + - recent `ble_events`, + - recent `ble_errors`, + - recent notifications. +- Do not print private data from arbitrary BLE payloads unless the user asks to inspect that payload. +- Do not access unrelated phone data such as contacts, SMS, call logs, files, camera, microphone, or location for BLE diagnostics. + +Cleanup: +- For cleanup, prefer: + ble.stop_scan() + ble.close_gatt() +- For advertising cleanup, call: + ble_advertiser.stop() +- For notification cleanup, disable notifications on the characteristic if known. +- Keep cleanup helpers simple and globally available: + stop_ble_scan() + disconnect_ble() + stop_ble_advertising() +- Do not set `ble = None` unless the user asks to release the dispatcher itself. +- Do not leave scans or advertising running without a stop path. + +Recommended cleanup helpers: + def stop_ble_scan(): + try: + ble.stop_scan() + print("BLE scan stopped") + except Exception as exc: + print("Could not stop BLE scan:", repr(exc)) + + def disconnect_ble(): + try: + ble.close_gatt() + print("BLE GATT closed") + except Exception as exc: + print("Could not close BLE GATT:", repr(exc)) + + def stop_ble_advertising(): + if "ble_advertiser" not in globals() or ble_advertiser is None: + print("No ble_advertiser global found") + return + try: + ble_advertiser.stop() + print("BLE advertising stopped") + except Exception as exc: + print("Could not stop BLE advertising:", repr(exc)) + +Good command examples: +- Initialize or reuse: + from able import BluetoothDispatcher + + if "ble" not in globals() or ble is None: + ble = BluetoothDispatcher() + +- Scan briefly: + from kivy.clock import Clock + + ble.start_scan() + Clock.schedule_once(lambda dt: ble.stop_scan(), 8) + +- Scan by device name: + from able.filters import DeviceNameFilter + from kivy.clock import Clock + + ble.start_scan(filters=[DeviceNameFilter("MyDevice")]) + Clock.schedule_once(lambda dt: ble.stop_scan(), 8) + +- Connect by address: + ble.connect_by_device_address("01:02:03:AB:CD:EF", autoconnect=False) + +- Connect scanned device: + device = ble_devices_by_address["01:02:03:AB:CD:EF"]["device"] + ble.connect_gatt(device, autoconnect=False) + +- Find a characteristic: + characteristic = ble_services.search("2a37") + +- Read a characteristic: + ble.read_characteristic(characteristic) + +- Write bytes: + ble.write_characteristic(characteristic, bytes([1, 2, 3])) + +- Enable notifications: + ble.enable_notifications(characteristic, enable=True) + +- Disable notifications: + ble.enable_notifications(characteristic, enable=False) + +- Request MTU: + ble.request_mtu(247) + +- Read RSSI: + ble.update_rssi() + +Avoid: +- Do not start endless scans. +- Do not connect repeatedly in a tight loop. +- Do not write repeatedly in a tight loop. +- Do not schedule delayed `print(...)` summaries for scan results. +- Do not assume scan, connect, services, read, write, notify, RSSI, or MTU operations are synchronous. +- Do not assume a BLE address, UUID, service, characteristic, or descriptor exists before checking. +- Do not print huge advertisement dumps, service trees, or notification streams directly. +- Do not include personal or sensitive data in BLE advertisements. + +For simple user requests: +- If the user asks to scan, generate a minimal Able scan cell with a shared dispatcher, event handlers, `ble_devices_by_address`, and a scheduled stop. +- If the user asks to connect, use a scanned device or direct address and store connection events. +- If the user asks to list services, call `discover_services()` after connection and store `ble_services`. +- If the user asks to read, search or use the provided characteristic and call `read_characteristic`. +- If the user asks to write, validate the value and call `write_characteristic`. +- If the user asks for notifications, enable notifications and store values in `ble_notifications`. +- If the user asks to advertise, use `able.advertising` and store `ble_advertiser`. +- If the user asks for diagnostics, print compact BLE state and recent events. +- If the user asks for cleanup, stop scan, stop advertising if active, and close GATT. diff --git a/pythonhere/magic_here/prompts/android-media.md b/pythonhere/magic_here/prompts/android-media.md new file mode 100644 index 0000000..7b909ef --- /dev/null +++ b/pythonhere/magic_here/prompts/android-media.md @@ -0,0 +1,130 @@ +## Android Media And Files + +Use this section when the user asks to browse, display, scan, filter, or build a +gallery from Android photos, videos, downloads, `/sdcard`, DCIM, Pictures, +Movies, Music, or other shared-storage paths. + +Permissions and storage access: + +- Always check actual runtime permission state before assuming media or storage access exists. +- For Android 13+ media-library access, use granular media permissions such as + `android.permission.READ_MEDIA_IMAGES` and/or + `android.permission.READ_MEDIA_VIDEO`. +- For older Android versions, `READ_EXTERNAL_STORAGE` may still be relevant. +- Do not rely on `WRITE_EXTERNAL_STORAGE` for reading photos on modern Android. +- Do not call `check_permission("android.permission.MANAGE_EXTERNAL_STORAGE")`. + It is a special app-access setting, not a normal runtime permission. +- On Android 11+ broad all-files access can be checked with + `Environment.isExternalStorageManager()`, but a false result does not by + itself prove every media path is unreadable. Probe the requested path and + report both facts. +- Do not assume `MANAGE_EXTERNAL_STORAGE` is available just because it is present + in the manifest. The user usually must enable “All files access” in system + settings, and Play policy restricts this permission. +- Prefer privacy-friendly media access through MediaStore or the system photo + picker unless the user specifically asks for direct filesystem browsing. +- Store the error/status and show errors. + +Path vs MediaStore access: + +- For a concrete path such as `/sdcard/DCIM/Camera`, first probe that path + directly before deciding the app lacks access. +- For media-library/gallery access, prefer MediaStore queries. +- Do not depend on the MediaStore `_data` column. It can be missing, deprecated, + inaccessible, or point to a file path the app cannot decode directly. +- Prefer querying `_id`, building a `content://` URI with + `ContentUris.withAppendedId(...)`, and reading through + `context.getContentResolver().openInputStream(uri)`. +- Decode MediaStore images from a valid Android `Uri` or input stream, not from + guessed filesystem paths. +- If direct path access is used as a fallback, treat failure to read/decode as a + normal state and continue. + +MediaStore querying: + +- Always handle `cursor is None`. +- Always check `cursor.getCount()` and show an empty-state UI when it is zero. +- Use `moveToFirst()` safely before reading rows. +- Always close the cursor in `finally`. +- Avoid `while cursor.isAfterLast() is False`; prefer clearer logic such as: + `if cursor.moveToFirst(): ... while not cursor.isAfterLast(): ...`. +- Log counts separately: + - rows found + - thumbnails attempted + - thumbnails decoded + - thumbnails failed + - permission/access state + +HEIC and thumbnails: + +- Kivy `Image` may not load `.heic` directly on Android. +- Use Android image decoding APIs such as `android.graphics.BitmapFactory` or + `android.graphics.ImageDecoder` for HEIC thumbnails when available. +- Import nested Android classes with `$`. For bitmap decoding options, define + `BitmapFactoryOptions = autoclass("android.graphics.BitmapFactory$Options")` + and use `BitmapFactoryOptions()`. Do not access it as `BitmapFactory.Options()`. +- With `ImageDecoder` and a Java `File`, prefer: + `source = ImageDecoder.createSource(java_file)`. +- Do not call: + `ImageDecoder.createSource(context.getContentResolver(), java_file)`, + because the `ContentResolver` overload expects a `Uri`, not a `File`. +- If using a `ContentResolver` with `ImageDecoder`, pass a valid Android `Uri`, + not a filesystem path or Java `File`. +- Avoid `ImageDecoder.decodeBitmap(source, python_lambda)` unless a correct Java + listener interface is implemented. Through Pyjnius, prefer: + `bitmap = ImageDecoder.decodeBitmap(source)`, + then scale/compress the decoded bitmap. +- Import nested Android classes with `$`. For bitmap compression, define: + `CompressFormat = autoclass("android.graphics.Bitmap$CompressFormat")` + and use `CompressFormat.JPEG` or `CompressFormat.PNG`. +- Do not access compression format as `Bitmap.CompressFormat`. +- Do not assume Kivy `Image.source` accepts `data:image/...;base64,...` URIs. + Prefer writing thumbnails to small temporary files in the app cache directory + and setting `Image.source` to those file paths. +- Use `context.getCacheDir().getAbsolutePath()` for thumbnail cache files. +- Avoid writing into `/sdcard` unless the user asks for exported files. +- Keep Android bitmap dimensions as plain Python `int` pixel values. +- Do not pass Kivy `dp(...)` float values directly to Android bitmap APIs such + as `Bitmap.createScaledBitmap(...)`. +- Use separate constants for UI size and decode size, for example: + `THUMB_UI_DP = dp(120)` for widgets and `THUMB_PX = 240` for Android bitmap + scaling. +- Recycle Android `Bitmap` objects after thumbnail compression when possible. + +Generated-code defaults: + +- For “show my gallery/photos” code, default to: + MediaStore query → `_id` → content URI → openInputStream/decode → cache + thumbnail file → Kivy Image source = cache file path. +- Avoid defaulting to: + MediaStore `_data` → raw filesystem path → `BitmapFactory.decodeFile(...)`. +- Include a visible debug/status label during development. +- Include enough logging to distinguish: + permission not granted, + MediaStore returned no rows, + rows found but decode failed, + thumbnails decoded but widget display failed. + +Android 14+ partial photo/video access: + +- On Android 14+ (API 34+), photo/video access may be partial because the user + selected only some media. Generated gallery code must report whether access + appears full, partial, denied, or unknown when permission information is + available. +- If only partial access is available, continue with MediaStore and show the + accessible subset instead of treating the result as a failure. +- When the user wants to choose media rather than browse the whole library, + prefer a system picker/user-selection flow over broad storage/media + permissions. +- Keep the debug/status label explicit: distinguish `permission_denied`, + `partial_media_access`, `mediastore_empty`, `decode_failed`, and + `display_ready`. + +Query defaults: + +- For general gallery requests, query images first unless the user asked for + videos or audio too. Avoid scanning every media type by default. +- Limit initial thumbnail queries to a reasonable count such as 50-100 items, + store full metadata in a global, and render a small preview first. +- Do not assume an empty MediaStore result means there are no photos on the + device; report permission/access state and query filters too. diff --git a/pythonhere/magic_here/prompts/android-packages.md b/pythonhere/magic_here/prompts/android-packages.md new file mode 100644 index 0000000..8e1faef --- /dev/null +++ b/pythonhere/magic_here/prompts/android-packages.md @@ -0,0 +1,69 @@ +## Android Package Inventory + +Use this addon when the user asks to list, inspect, filter, summarize, or export +installed Android apps, packages, APK files, package labels, versions, system-app +status, or requested permissions for installed packages. + +PackageManager rules: + +- Use `org.kivy.android.PythonActivity.mActivity`. +- Import `org.kivy.android.PythonService` inside the fallback block, not before + it is needed, because some apps do not package service support. +- Get a `PackageManager` from the Android context. +- Use `PackageManager.getInstalledPackages(...)` with `GET_PERMISSIONS`. +- In Pyjnius, import Android nested classes with `$`, not Python attribute + access. For SDK checks, define + `VERSION = autoclass("android.os.Build$VERSION")` and use + `VERSION.SDK_INT`. Do not use `Build.VERSION.SDK_INT`. +- On Android API 33+, call `PackageInfoFlags.of(...)`. +- On older Android versions, pass integer flags directly. +- In Pyjnius, the Android API 33 flags class must be imported as: + `PackageInfoFlags = autoclass("android.content.pm.PackageManager$PackageInfoFlags")`. + Then call `PackageInfoFlags.of(flags)`. Do not call + `PackageManager.PackageInfoFlags.of(...)`. +- Use `android.content.pm.ApplicationInfo.FLAG_SYSTEM` and + `FLAG_UPDATED_SYSTEM_APP` for system app detection. +- Do not use `android.content.pm.ActivityInfo` for installed application flags. +- Use `ApplicationInfo.sourceDir` and `splitSourceDirs` with `java.io.File` for + APK file sizes. Report `None` when a path is unavailable. +- Store APK size details separately, for example `base_apk_size_bytes`, + `split_apk_sizes_bytes`, and `total_apk_size_bytes`. +- Use `PackageInfo.requestedPermissions` plus + `PackageInfo.requestedPermissionsFlags`. +- A requested permission is granted when the matching flag has + `PackageInfo.REQUESTED_PERMISSION_GRANTED` set. +- Do not use `PackageManager.PERMISSION_GRANTED` or + `ActivityInfo.REQUESTED_PERMISSION_GRANTED` for requested permission flags. +- Import every Android class referenced in the code with `autoclass`. + If code uses `PackageInfo.REQUESTED_PERMISSION_GRANTED`, it must first define + `PackageInfo = autoclass("android.content.pm.PackageInfo")`. +- `requestedPermissions`, `requestedPermissionsFlags`, and `splitSourceDirs` are + Java arrays. In Pyjnius, handle them with `len(array)` and `array[index]`; + do not call `.size()` or `.get()` on them. +- Keep per-app permission records as dictionaries with at least `name` and + `granted`. +- Convert Java string-like fields such as package name, label, and version name + to Python strings or `None` before storing them. +- Keep preview output simple. If you compute `perms_granted`, print + `perms_granted`; do not reference a different variable name. +- Do not import `json`, `pathlib.Path`, or `pprint` unless the user explicitly + asks to export or pretty-print data. +- Do not import `os` unless the user explicitly asks for filesystem or + environment inspection. +- Do not import `cast`, `contextlib`, or other helpers unless the generated code + actually uses them. + +Android 11+ package visibility: + +- On Android 11+ (API 30+) package visibility filtering can make + `getInstalledPackages(...)` return a filtered set when the manifest does not + declare the needed package visibility queries or `QUERY_ALL_PACKAGES`. +- Do not promise a complete inventory of all installed apps on Android 11+ + unless the app's manifest/package visibility allows it. +- Store and print a field such as `package_visibility_note` when results may be + filtered. +- If the user asks why some apps are missing, explain that Android package + visibility is manifest/policy controlled and cannot be fixed from a + runtime-only snippet. +- Do not request `QUERY_ALL_PACKAGES` at runtime; it is a manifest/policy + matter, not a dangerous runtime permission prompt. diff --git a/pythonhere/magic_here/prompts/android-permissions.md b/pythonhere/magic_here/prompts/android-permissions.md new file mode 100644 index 0000000..4ccec0f --- /dev/null +++ b/pythonhere/magic_here/prompts/android-permissions.md @@ -0,0 +1,195 @@ +## Android Permissions + +Use this section when need to check, request, or explain permissions for +the running Android app. + +Critical rules: + +- Do not assume a runtime permission can be granted if it is missing from the + Android manifest. Runtime requests only work for permissions declared by the + app. +- Do not use installed-package permission metadata to decide whether this app has + a runtime permission. Use runtime permission APIs for the current app. +- Choose the Android access mechanism from the user's goal. Do not force every + access request through `request_permissions`. +- Use fully qualified Android permission strings for all permission APIs, for + example `"android.permission.CAMERA"`. Do not pass short names such as + `"CAMERA"`, `"READ_EXTERNAL_STORAGE"`, or `"WRITE_EXTERNAL_STORAGE"` to + `check_permission`, `request_permissions`, or `context.checkSelfPermission`. +- If storing display-friendly names, keep them separate from the full permission + strings used for Android API calls. +- When the user asks to "request", "enable", "grant", or "get access", generated + code must perform the appropriate request action immediately when possible. Do + not tell the user to ask again for the Settings-opening step. +- Do not call `raise SystemExit` or terminate the host app when context, + activity, or permission APIs are unavailable. Store an error result and print a + concise message instead. + +Decision model: + +- Dangerous runtime permissions: check with `check_permission(...)` or + `context.checkSelfPermission(...)`; request with + `android.permissions.request_permissions(...)` when the user asks to request. + Examples: camera, microphone, fine/coarse location, contacts, calendar, + nearby Bluetooth permissions, Android 13+ notifications. +- Normal permissions: do not request at runtime. Report that they are install- + time permissions. +- Signature/privileged permissions: do not request at runtime. Report that they + cannot be granted to ordinary apps unless the app is privileged or signed with + the platform key. +- Special app-access permissions: do not request with + `request_permissions(...)`. Check with the dedicated Android API when one + exists, and open the relevant Settings screen only when the user asks to + request/open/enable access. +- Storage and media access depends on Android API level and requested scope. + Do not treat "storage", "sdcard", "external storage", "photos", "media", or + "all files" as one generic permission. + +Preferred Python-for-Android API: + +- Prefer `from android.permissions import Permission, check_permission, + request_permissions` when the `android` package is available. +- Use `check_permission(permission_name)` to check one permission for the current + app. +- Use `request_permissions(permission_names, callback)` to request one or more + dangerous runtime permissions. +- Pass full permission strings to these functions. Prefer constants from + `android.permissions.Permission` when they are available and correct for the + target API; otherwise use full strings like + `"android.permission.ACCESS_FINE_LOCATION"`. +- The request callback should accept `(permissions, grants)` and store both the + raw arrays and a Python dictionary mapping permission name to granted boolean. +- Do not rely on `print(...)` inside the permission request callback as the only + result. The callback may run after notebook output capture has ended. Store the + result globally and update visible UI when appropriate. +- Do not convert grant values with `bool(grant)`. Android uses + `PackageManager.PERMISSION_GRANTED == 0` and + `PackageManager.PERMISSION_DENIED == -1`, so `bool(-1)` is wrong. Convert with + `grant == PackageManager.PERMISSION_GRANTED` when grant values are integers. +- Keep a global reference to the callback result, for example + `android_permission_request_result`, so later cells can inspect it. + +Pyjnius fallback for checks: + +- Get a current context from `org.kivy.android.PythonActivity.mActivity` or, only + as a fallback, `org.kivy.android.PythonService.mService`. +- Define `VERSION = autoclass("android.os.Build$VERSION")` and use + `VERSION.SDK_INT`. Do not use `Build.VERSION.SDK_INT`. +- Define `PackageManager = autoclass("android.content.pm.PackageManager")`. +- On API 23 and newer, call `context.checkSelfPermission(permission_name)` and + compare the result to `PackageManager.PERMISSION_GRANTED`. +- For permission request callbacks, use the same + `PackageManager.PERMISSION_GRANTED` constant to normalize grant results. +- On API levels below 23, runtime permission prompts do not exist. Treat declared + install-time permissions as already granted for runtime-check purposes, but + print that the result is pre-runtime-permission behavior. + +Requesting permissions: + +- For Kivy/Python-for-Android apps, use `android.permissions.request_permissions` + instead of calling `activity.requestPermissions(...)` directly. +- If the `android.permissions` module is unavailable, do not invent a Pyjnius + subclass or callback receiver for `activity.requestPermissions(...)`. Use + Pyjnius only to check current permission state, then report that requesting + runtime permissions requires the Python-for-Android permission helper or app + integration. +- Request only dangerous/runtime permissions. Normal permissions are granted at + install time and should be reported as not needing a runtime prompt. +- Do not request special app-access permissions as if they were normal runtime + permissions. Examples: `MANAGE_EXTERNAL_STORAGE`, notification listener + access, accessibility service access, overlay permission, battery optimization + exemption, usage access, and exact alarm access. If the user asked only to + check, report that they require a Settings screen flow. If the user asked to + request/enable/get access, open the correct Settings screen immediately when a + foreground activity is available. +- Android 13+ notification permission is + `android.permission.POST_NOTIFICATIONS`; request it only on API 33 and newer. +- If the activity is unavailable, do not attempt a permission request from a + service-only context. Print that a foreground activity is required. + +Special app-access flows: + +- If the user asks to request or enable a special app-access permission, open the + most specific Settings screen available for this app. Use `Intent`, + `Settings`, and `Uri.parse(f"package:{context.getPackageName()}")` where the + action supports an app-specific URI. +- Build Settings intents conservatively: create `intent = Intent(action)` and + then call `intent.setData(Uri.parse(f"package:{package_name}"))` for + app-specific Settings actions. This is more reliable through Pyjnius than + relying on overloaded Java constructors. +- Always store whether the Settings screen was opened, the action used, and the + current access state before opening Settings. +- Do not claim Settings access was granted immediately after opening Settings. + The user must return from Settings; tell them to rerun the check afterward. +- If `activity` is unavailable, do not call `startActivity`. Report that a + foreground activity is required to open Settings. + +Storage and media access: + +- For Android 13+ (API 33+), media permissions are split: + `READ_MEDIA_IMAGES`, `READ_MEDIA_VIDEO`, and `READ_MEDIA_AUDIO`. Use these + only for media-library access, not for arbitrary `/sdcard` file access. +- For Android 10-12 (API 29-32), `READ_EXTERNAL_STORAGE` may allow media/shared + storage reads, but scoped storage still limits arbitrary file access. Do not + promise full `/sdcard` traversal from this permission. +- `WRITE_EXTERNAL_STORAGE` is ignored or heavily limited on modern Android. Do + not rely on it for Android 10+ shared-storage writes. +- When generating runtime storage permission requests, request + `android.permission.WRITE_EXTERNAL_STORAGE` only for API 28 and lower. For API + 29, do not request `WRITE_EXTERNAL_STORAGE` as a solution for broad storage + writes; explain the scoped-storage limitation instead. +- For Android 11+ (API 30+), broad "all files" access is the special access + `MANAGE_EXTERNAL_STORAGE`. Check it with + `Environment.isExternalStorageManager()`. +- To request Android 11+ all-files access, open Settings; do not call + `request_permissions(["android.permission.MANAGE_EXTERNAL_STORAGE"], ...)`. + Prefer `Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION` with + `Uri.parse(f"package:{context.getPackageName()}")`, and fall back to + `Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION` if the app-specific + action fails. +- For a broad `/sdcard` access request on Android 11+, do not additionally + request `READ_EXTERNAL_STORAGE` or `WRITE_EXTERNAL_STORAGE` as the primary + solution. Those permissions do not grant broad all-files access. +- For Android versions below 11, use runtime storage permissions only when they + match the user's requested scope, and explain that behavior differs by API + level and manifest settings. +- For creating or picking user-selected files, prefer Android's document/media + picker or Storage Access Framework when the user does not need broad all-files + access. + +Output shape: + +- For permission checks, use a global such as `android_permission_status`. +- For permission requests, use a global such as + `android_permission_request_result`. +- Print each permission with a short status such as `granted`, `denied`, + `not_requested_pre_23`, `normal_permission_no_runtime_prompt`, or + `requires_settings_flow`. +- For special Settings flows, include fields such as `access_name`, + `currently_granted`, `settings_opened`, `settings_action`, and + `rerun_check_after_return`. + +Android 14+ selected media access: + +- On Android 14+ (API 34+), users may grant partial access to selected + photos/videos. Treat this as a valid limited-access state, not a simple + denial. +- For media-gallery code on Android 14+, consider + `android.permission.READ_MEDIA_VISUAL_USER_SELECTED` alongside + `READ_MEDIA_IMAGES` and/or `READ_MEDIA_VIDEO` when the user wants to manage or + reselect partial media access. +- If the user only wants to pick one or a few files/photos, prefer a + picker/user-selection flow instead of broad media permissions. +- If partial media access is detected, explain through code comments or status + text that MediaStore results may include only the selected items. + +Notification and special-access reminders: + +- `android.permission.POST_NOTIFICATIONS` is a dangerous runtime permission only + on API 33+; below API 33, do not request it at runtime. +- Exact alarm, overlay, accessibility, notification listener, usage access, + battery optimization exemption, and all-files access are special settings + flows, not normal runtime permissions. +- For Settings flows, open the specific settings screen only when the user asked + to request, enable, or open access; otherwise only report the current state and + the needed flow. diff --git a/pythonhere/magic_here/prompts/android-runtime.md b/pythonhere/magic_here/prompts/android-runtime.md new file mode 100644 index 0000000..3292cc5 --- /dev/null +++ b/pythonhere/magic_here/prompts/android-runtime.md @@ -0,0 +1,34 @@ +## Android Runtime + +The generated code runs inside the Android app's existing Python process. +Assume Python-for-Android/Kivy unless the user says otherwise. + +Critical rules: + +- Do not use `adb`. +- Do not use `subprocess`, shell commands, or host-side Android tools. +- Do not use legacy SL4A-style Android helper APIs. PythonHere is a + Kivy/Python-for-Android app, not an SL4A runtime. +- Do not write files unless the user explicitly asks for a file export. +- Prefer Android framework APIs through `jnius` over parsing command output. +- Use the already-running activity or service instead of starting one. +- If the code needs Android context, prefer + `org.kivy.android.PythonActivity.mActivity`. +- Import `org.kivy.android.PythonService` only inside a fallback block, because + some apps do not package service support. +- Import every Android class referenced in the code with `autoclass`. +- Import Android nested classes with `$`, not Python attribute access. +- For SDK checks, define `VERSION = autoclass("android.os.Build$VERSION")` and + use `VERSION.SDK_INT`. Do not use `Build.VERSION.SDK_INT`. +- Convert Java string-like fields to Python strings or `None` before storing + them. +- Java arrays are Python-indexable in Pyjnius. Use `len(array)` and + `array[index]`; do not call `.size()` or `.get()` unless the object is a Java + `List`. +- Do not import `json`, `pathlib.Path`, `pprint`, `os`, `cast`, `contextlib`, or + other helpers unless the generated code actually uses them. + +HTTPS requests: +- Always use certifi, `context = ssl.create_default_context(cafile=certifi.where())` +- Never use urlopen() directly for https:// URLs. +- Never disable SSL verification. diff --git a/pythonhere/magic_here/prompts/jnius.md b/pythonhere/magic_here/prompts/jnius.md new file mode 100644 index 0000000..bb9d324 --- /dev/null +++ b/pythonhere/magic_here/prompts/jnius.md @@ -0,0 +1,432 @@ +## Pyjnius + +`jnius` is installed. + +Rules: + +- Import Java classes with `from jnius import autoclass`. +- For Java callbacks/listeners implemented in Python, import and use + `PythonJavaClass` and `java_method`. A plain Python class with a matching + method name is not a Java interface implementation. +- Import `cast` only when the generated code actually uses it. +- Do not import Android app classes directly from `jnius`. For example, do not + write `from jnius import PythonService`; use + `autoclass("org.kivy.android.PythonService")` inside the fallback path. +- Import optional Android classes, such as `org.kivy.android.PythonService`, + only inside the fallback path where they are needed. +- Use `cast` only when a Java API requires a specific declared type. +- Keep Java object references local unless the user needs to inspect them later. +- Convert Java strings to Python strings with `str(...)` before storing results. +- Treat Java arrays and lists defensively: they may be `None`; iterate only after + checking. +- Java arrays are Python-indexable in Pyjnius. Use `len(java_array)` and + `java_array[index]`. Do not call `.size()` or `.get()` unless the object is a + Java `List`. +- For Java long bitmasks, Python `int` values are acceptable. +- Catch narrow exceptions only around optional Android fields or deprecated APIs. + Do not hide collection-wide failures. +- If catching an exception for one package/item, store a readable error string in + that item and continue. +- Do not use Pyjnius to launch external processes. +- Do not reference Android class constants from a variable that has not been + assigned with `autoclass`. + +Pyjnius arrays: +- Use Python lists, bytes, or bytearray for Java array arguments. +- Do not invent imports like `jarray`. +- If a Java API needs a writable output buffer, use a mutable Python list or bytearray and check the method’s return value. + +Pyjnius object conversion rules: + +- Do not assume Python string conversion of Java objects produces valid Android + values. +- Convert Java `String` objects to Python strings with `str(...)` before storing + ordinary text results, but do not use Python `str(...)` for Android object + identifiers that have their own Java string form, such as `Uri`. +- For Java objects with Android-specific string forms, call the Java method + `toString()`. +- For `android.net.Uri`, prefer keeping the Java `Uri` object and passing it + directly to Android APIs such as `ContentResolver.openInputStream(uri)`. +- Do not convert Java `android.net.Uri` objects with Python `str(uri)`. + In Pyjnius, `str(uri)` may produce a Python object representation like + `` instead of a valid + `content://...` URI string. +- If a Java `Uri` must be stored as text, use `uri.toString()`, not `str(uri)`. +- Never parse a Pyjnius object representation as a URI. +- Any generated code that logs or displays a URI should log both the media ID + and `uri.toString()`, not the Python object representation. + +Wrong: + +```python +content_uri = ContentUris.withAppendedId(ImagesMedia.EXTERNAL_CONTENT_URI, image_id) +photos.append({"id": image_id, "uri": str(content_uri)}) + +uri = Uri.parse(photo["uri"]) +stream = resolver.openInputStream(uri) +``` + +Correct, preferred: + +```python +content_uri = ContentUris.withAppendedId(ImagesMedia.EXTERNAL_CONTENT_URI, image_id) +photos.append({"id": image_id, "uri": content_uri}) + +stream = resolver.openInputStream(photo["uri"]) +``` + +Correct, if serialization is needed: + +```python +content_uri = ContentUris.withAppendedId(ImagesMedia.EXTERNAL_CONTENT_URI, image_id) +photos.append({"id": image_id, "uri": content_uri.toString()}) + +Uri = autoclass("android.net.Uri") +uri = Uri.parse(photo["uri"]) +stream = resolver.openInputStream(uri) +``` + +Pyjnius Java class access rules: + +- Do not call Java classes through undefined Python package names. +- Every Java class used must be bound with `autoclass(...)`. +- Do not assume Java package names are available as Python modules. + +Wrong: + +```python +android.graphics.Bitmap.createScaledBitmap(bitmap, new_w, new_h, True) +``` + +Correct: + +```python +Bitmap = autoclass("android.graphics.Bitmap") +scaled_bitmap = Bitmap.createScaledBitmap(bitmap, new_w, new_h, True) +``` + +Nested Java classes: + +- In Pyjnius, nested Java classes must be imported with `$` using `autoclass`. + Do not access nested Java classes as Python attributes of the parent class. + +Correct: + +```python +VERSION = autoclass("android.os.Build$VERSION") +BitmapFactoryOptions = autoclass("android.graphics.BitmapFactory$Options") +CompressFormat = autoclass("android.graphics.Bitmap$CompressFormat") +PackageInfoFlags = autoclass("android.content.pm.PackageManager$PackageInfoFlags") +ImagesMedia = autoclass("android.provider.MediaStore$Images$Media") +``` + +Incorrect: + +```python +Build.VERSION.SDK_INT +BitmapFactory.Options() +Bitmap.CompressFormat +PackageManager.PackageInfoFlags +MediaStore.Images.Media +``` + +Android version checks: + +- Use: + +```python +VERSION = autoclass("android.os.Build$VERSION") +SDK_INT = VERSION.SDK_INT +``` + +- Do not use: + +```python +Build.VERSION.SDK_INT +``` + +because Pyjnius does not reliably expose nested classes as Python attributes. + +Nullable Java constants: + +- Treat Android Java class constants returned through Pyjnius as nullable. + Some constants may be `None` even when the Android API normally defines them. +- Never pass unchecked Pyjnius constants into Android APIs that expect strings, + arrays of strings, column names, permissions, selection clauses, sort orders, + file modes, or intent actions. +- Before using a Java string constant in `projection`, `selection`, `sortOrder`, + `getColumnIndex(...)`, `Intent(...)`, or permission checks, resolve it to a + non-null Python string. + +Correct: + +```python +ImagesMedia = autoclass("android.provider.MediaStore$Images$Media") + +COL_ID = ImagesMedia._ID or "_id" +COL_DATE_ADDED = ImagesMedia.DATE_ADDED or "date_added" +COL_DATE_TAKEN = ImagesMedia.DATE_TAKEN or "datetaken" +COL_DISPLAY_NAME = ImagesMedia.DISPLAY_NAME or "_display_name" +COL_BUCKET = ImagesMedia.BUCKET_DISPLAY_NAME or "bucket_display_name" + +projection = [COL_ID, COL_DATE_ADDED] +sort_order = COL_DATE_ADDED + " DESC" +``` + +Incorrect: + +```python +projection = [ImagesMedia._ID, ImagesMedia.DATE_TAKEN] +sort_order = f"{ImagesMedia.DATE_TAKEN} DESC" +selection = ImagesMedia.BUCKET_DISPLAY_NAME + " = ?" +``` + +- Never allow `None` inside Java `String[]` arguments or string parameters. + This includes MediaStore projections, selection args, sort order strings, and + cursor column names. +- If an Android API throws a Java `NullPointerException` mentioning + `String.toLowerCase()` during a query, suspect a `None` column name or string + argument passed from Pyjnius. +- Prefer explicit fallback strings for well-known Android column names rather + than raw constants when generating resilient code. + +Bitmap-related Pyjnius rules: + +- For bitmap decode options, use: + +```python +BitmapFactoryOptions = autoclass("android.graphics.BitmapFactory$Options") +opts = BitmapFactoryOptions() +``` + +- Never use: + +```python +opts = BitmapFactory.Options() +``` + +- For bitmap compression format, use: + +```python +CompressFormat = autoclass("android.graphics.Bitmap$CompressFormat") +bitmap.compress(CompressFormat.JPEG, 85, output_stream) +``` + +- Never use: + +```python +Bitmap.CompressFormat.JPEG +``` + + +Android MediaStore and shared-media Pyjnius rules: + +- Prefer MediaStore content URIs over raw filesystem paths. +- Do not depend on the `_data` column for gallery/media access. +- Query `_id`, build a content URI, then decode through `ContentResolver`. +- Keep the Java `Uri` object directly or store URI text with `uri.toString()`. + Do not store `str(uri)`. +- Do not filter only by `bucket_display_name = "Camera"` unless the user + specifically asked for the Camera folder only. For a general gallery, query + all images first, then add filters only after the broad query is confirmed + working. +- Always handle `cursor is None` and `cursor.getCount() == 0`. +- Always close the cursor in `finally`. +- If a thumbnail decode fails for one item, store/log a readable item-level + error and continue with other items. + +Preferred MediaStore URI pattern: + +```python +ContentUris = autoclass("android.content.ContentUris") +ImagesMedia = autoclass("android.provider.MediaStore$Images$Media") +BitmapFactory = autoclass("android.graphics.BitmapFactory") +BitmapFactoryOptions = autoclass("android.graphics.BitmapFactory$Options") + +content_uri = ContentUris.withAppendedId( + ImagesMedia.EXTERNAL_CONTENT_URI, + image_id, +) + +opts = BitmapFactoryOptions() +stream = resolver.openInputStream(content_uri) +try: + bitmap = BitmapFactory.decodeStream(stream, None, opts) +finally: + if stream is not None: + stream.close() +``` + +Avoid as the default: + +```python +path = cursor.getString(cursor.getColumnIndex("_data")) +bitmap = BitmapFactory.decodeFile(path, opts) +``` + +Android media permission and special-access rules: + +- Declaring a permission in `buildozer.spec` or `AndroidManifest.xml` does not + prove it is granted at runtime. +- For Android 13+ image access, check/request + `android.permission.READ_MEDIA_IMAGES`. +- For Android 13+ video access, check/request + `android.permission.READ_MEDIA_VIDEO`. +- For Android 13+ audio access, check/request + `android.permission.READ_MEDIA_AUDIO`. +- For Android 12 and below, check/request + `android.permission.READ_EXTERNAL_STORAGE`. +- Do not rely on `WRITE_EXTERNAL_STORAGE` for reading shared photos on modern + Android. +- Do not check `MANAGE_EXTERNAL_STORAGE` with `check_permission(...)`. + It is special Android settings access, not a normal runtime permission. +- For arbitrary `/sdcard`, `/sdcard/DCIM`, `/sdcard/Download`, or full + shared-storage browsing on Android 11+, check + `Environment.isExternalStorageManager()`. +- If all-files access is needed and not enabled, open + `Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION` with + `Uri.parse("package:" + activity.getPackageName())`. If that fails, fall back + to `Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION`. + +Thumbnail and cache rules: + +- Write generated thumbnails into the app cache directory from + `context.getCacheDir().getAbsolutePath()`. +- Do not use `Path.cwd() / "gallery_cache"` for Android cache files. +- Kivy `Image.source` should point to a real cache file path. +- Do not assume Kivy supports `data:image/...;base64,...` sources. +- Use separate constants for UI size and bitmap decode size, for example + `THUMB_UI_DP = dp(120)` and `THUMB_PX = 240`. +- Do not pass `dp(...)` floats into Android bitmap APIs. +- Android bitmap dimensions and sample sizes must be plain Python `int` values. +- Recycle Android `Bitmap` objects after thumbnail compression when possible. + +Media/gallery error-reporting rules: + +- Do not hide media/gallery failures behind generic messages like + `Error loading photos. Check logs.` +- Generated code should show the real failure stage and exception message in the + UI during development. +- Background workers should return structured results such as + `{ "ok": False, "stage": "decode_thumbnail", "error": "AttributeError: ..." }`. +- Logs may include full stack traces, but logs must not be the only place where + the real problem appears. + +General forbidden Pyjnius patterns: + +Never generate: + +```python +BitmapFactory.Options() +Bitmap.CompressFormat +Build.VERSION +MediaStore.Images.Media +PackageManager.PackageInfoFlags +projection = [SomeJavaClass.SOME_COLUMN] +sort_order = f"{SomeJavaClass.SOME_COLUMN} DESC" +``` + +Generate: + +```python +BitmapFactoryOptions = autoclass("android.graphics.BitmapFactory$Options") +CompressFormat = autoclass("android.graphics.Bitmap$CompressFormat") +VERSION = autoclass("android.os.Build$VERSION") +ImagesMedia = autoclass("android.provider.MediaStore$Images$Media") +PackageInfoFlags = autoclass("android.content.pm.PackageManager$PackageInfoFlags") + +COL = SomeJavaClass.SOME_COLUMN or "known_fallback_name" +projection = [COL] +sort_order = COL + " DESC" +``` + +For Android package APIs: + +- `PackageManager.GET_PERMISSIONS` requests permission metadata. +- Android API 33 and newer require + `android.content.pm.PackageManager$PackageInfoFlags.of(flags)`. +- In Pyjnius, define: + +```python +PackageInfoFlags = autoclass("android.content.pm.PackageManager$PackageInfoFlags") +``` + +and call: + +```python +PackageInfoFlags.of(flags) +``` + +- Do not access it as: + +```python +PackageManager.PackageInfoFlags +``` + +- Older APIs accept integer flags. +- System-app flags come from `android.content.pm.ApplicationInfo`. + Use `ApplicationInfo.FLAG_SYSTEM` and `ApplicationInfo.FLAG_UPDATED_SYSTEM_APP`. + Do not use `android.content.pm.ActivityInfo` for this. +- Permission grant status comes from + `android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED`, not from + `PackageManager.PERMISSION_GRANTED` and not from `ActivityInfo`. +- If checking requested permission grant status, define: + +```python +PackageInfo = autoclass("android.content.pm.PackageInfo") +``` + +before using that constant. +- Version code should use `longVersionCode` when present and fall back to + `versionCode`. + +Additional forbidden Pyjnius media patterns: + +Never generate: + +```python +str(content_uri) +Uri.parse(str(content_uri)) +photos.append({"uri": str(content_uri)}) +android.graphics.Bitmap.createScaledBitmap(bitmap, new_w, new_h, True) +Path.cwd() / "gallery_cache" +show_error("Error loading photos. Check logs.") +return None # after catching a media/gallery exception +cursor.getString(cursor.getColumnIndex("_data")) # as the primary media access path +``` + +Generate: + +```python +photos.append({"uri": content_uri}) +# or, only if text serialization is required: +photos.append({"uri": content_uri.toString()}) + +Bitmap = autoclass("android.graphics.Bitmap") +scaled_bitmap = Bitmap.createScaledBitmap(bitmap, new_w, new_h, True) + +cache_dir = context.getCacheDir().getAbsolutePath() + +return { + "ok": False, + "stage": stage, + "error": f"{type(e).__name__}: {e}", +} +``` + +Pyjnius overload and Android intent safety: +- When Java APIs have overloaded constructors or methods, prefer the least + ambiguous call pattern through Pyjnius. For Android `Intent`, create + `intent = Intent(action)` and then call setters such as `setData(...)`, + `setType(...)`, or `putExtra(...)` rather than relying on overloaded + constructors. +- Do not pass Python `None` where Android expects a Java `String`, `String[]`, + `Uri`, `Intent`, `Context`, or callback/listener. +- When an API expects a Java primitive array or Java collection, prefer normal + Python lists only when Pyjnius is known to convert them for that method. + Otherwise use the Android/Python-for-Android helper API if one exists. +- For nullable Android constants, resolve to non-null Python strings before + passing them into Android APIs. +- Keep Settings/Intent launch results in a named global and report whether + `startActivity(...)` was attempted; do not claim the requested setting changed + just because the Settings screen opened. diff --git a/pythonhere/magic_here/prompts/kivy-kv.md b/pythonhere/magic_here/prompts/kivy-kv.md new file mode 100644 index 0000000..dbfc801 --- /dev/null +++ b/pythonhere/magic_here/prompts/kivy-kv.md @@ -0,0 +1,239 @@ +## Kv design + +Use KV for layout only: +- widget tree +- ids +- simple widget properties +- simple canvas instructions + +Critical generated-KV constraints: +- If KV uses `dp(...)` or `sp(...)`, the KV string must include the matching + `#:import` line once at the top, before widget rules. +- If KV uses `sin(...)`, `cos(...)`, `abs(...)`, `min(...)`, or any other helper + function in an expression, the name must be defined in KV parser scope with + `#:import` or the calculation must be moved into Python. +- Do not use KV dynamic class/template syntax such as `:` in + generated PythonHere snippets. +- Do not call `Builder.template(...)`; use `ui = Builder.load_string(KV)`. +- Do not put callbacks in KV. Bind callbacks in Python after + `Builder.load_string(KV)`. +- Do not use `app.some_method()` or `root.some_method()` in KV callbacks. +- Do not use `#:set` to inject Python globals, callback functions, generated + text, or state into KV. Set widget properties and bind callbacks from Python + after loading. + +For generated PythonHere cells, do not put Python callbacks in KV. +Do not put generated dynamic Python logic in KV. + +Avoid: +`on_release: something()` +`on_press: something()` +`on_text: something(self.text)` +`on_value: something(self.value)` +`values: [f"{num}: {name}" for num, name in sorted(items.items())]` +`text: some_python_variable` +`source: compute_path()` +`angle: app.feature_angle` + +Also avoid generated proxy/global calls in KV: +`on_release: actions.handle(...)` +`on_release: app_actions["handle"]()` +`on_release: app.stop_everything()` +`on_release: root.stop_everything()` +`on_release: start_cb()` +`text: app_poem_text` +`#:set start_cb some_python_function` +`#:set app_poem_text some_python_text` + +Avoid referencing notebook/global variables from KV. Kivy Builder may not have +the expected globals in parser scope, and KV parser errors are hard to recover +from in a live app. Put dynamic values into widgets from Python after the widget +tree exists. +Do not use `app.some_feature_state` in generated KV. In PythonHere, `app` is the +real PythonHere Kivy App instance, not a generated feature controller. Store +feature state on the generated root/widget class with Kivy properties, or update +widget/canvas instructions from Python. + +Preferred pattern: +- Put ids on interactive widgets in KV. +- Load the UI. +- Set dynamic properties from Python after `Builder.load_string(KV)`. +- Bind callbacks in Python after `Builder.load_string(KV)`. + +Example: + +`ui = Builder.load_string(KV)` +`ui.ids.primary_button.bind(on_release=handle_primary_action)` +`ui.ids.value_slider.bind(value=handle_value_change)` + +Dynamic values example: + +``` +KV = """ +BoxLayout: + Spinner: + id: instrument_spinner + text: "Choose instrument" + values: [] + Slider: + id: volume_slider +""" + +ui = Builder.load_string(KV) +ui.ids.instrument_spinner.values = [ + f"{num}: {name}" for num, name in sorted(midi_instruments.items()) +] +ui.ids.instrument_spinner.bind(text=handle_instrument) +ui.ids.volume_slider.bind(value=handle_volume) +``` + +Widget-owned property example: + +``` +from kivy.properties import NumericProperty +from kivy.uix.floatlayout import FloatLayout + +class DynamicRoot(FloatLayout): + feature_angle = NumericProperty(0) + +KV = """ +#:import dp kivy.metrics.dp + +: + canvas.before: + PushMatrix: + Rotate: + angle: root.feature_angle + origin: self.center + Ellipse: + size: dp(120), dp(120) + pos: self.center_x - dp(60), self.center_y - dp(60) + PopMatrix: + +DynamicRoot: +""" + +ui = Builder.load_string(KV) +Clock.schedule_interval(lambda dt: setattr(ui, "feature_angle", ui.feature_angle + 3), 1 / 30) +``` + +KV root rule: +- In PythonHere generated cells, `KV` must end with a concrete root widget instance. +- Prefer a direct root widget such as `BoxLayout:`, `FloatLayout:`, `GridLayout:`, or a custom class instance such as `DesktopUI:`. +- Do not make `KV` contain only class/rule definitions. +- Do not use KV dynamic class/template syntax such as `:` + for generated PythonHere snippets. +- Do not call `Builder.template(...)`. Use `ui = Builder.load_string(KV)` with a + KV string that ends in a concrete root widget instance. +- Do not load rule-only KV and then instantiate a Python class manually, such as + `Builder.load_string(KV); ui = MyWidget()`. The generated KV should return the + actual root widget from `Builder.load_string(KV)`. +- Do not call `Builder.unload_file(...)` or pass a fake `filename=...` for + generated inline KV snippets. + +Good: + +``` +KV = """ +BoxLayout: + Label: + text: "Hello" +""" +``` + +Good when using custom classes: + +``` +KV = """ +: + ... + +DesktopUI: +""" +``` + +Bad: + +``` +KV = """ +: + ... +""" +``` + +because `Builder.load_string(KV)` returns `None` for rule-only KV. + +Required self-check: +- If the code does `ui = Builder.load_string(KV)`, then `ui` must be a widget. +- If `KV` contains `:` rules, it must also contain a final concrete instance like `SomeClass:`. +- Never call `root.add_widget(ui)` unless `ui is not None`. +- Never call `Builder.template(...)` for generated PythonHere UI snippets. +- Never define `:` dynamic classes in generated KV. +- Never use `#:set` to expose Python functions or generated text to KV. +- Never call `Builder.unload_file(...)` for generated inline KV snippets. +- If code defines a `KV = """..."""` string for the UI, it must actually load + that string with `Builder.load_string(KV)` and add the loaded widget, or omit + the KV string entirely. Do not define KV and then instantiate a bare Python + widget class such as `ui = MyWidget()`; that ignores the KV tree and usually + shows an empty UI. + +Kivy property compatibility: +- Generated Kivy code must use only valid property option values in both KV and Python-created widgets. For `Label.shorten_from`, use only `"left"`, `"center"`, or `"right"`. + +KV imports: +- Any Python name used inside KV expressions must either be a KV local such as + `self`, `root`, or an explicitly imported name declared with `#:import` at + the top of the KV string. +- Do not assume Python imports outside the KV string are visible to the KV + parser. `from math import sin` in Python does not make `sin(...)` valid inside + KV; use `#:import sin math.sin` in the KV string or move the calculation into + Python. +- If KV uses `dp(...)` or `sp(...)`, include exactly one matching import at the + top of the KV string: + `#:import dp kivy.metrics.dp` + `#:import sp kivy.metrics.sp` +- If KV expressions use math functions, include explicit imports such as: + `#:import sin math.sin` + `#:import cos math.cos` +- Do not generate duplicate `#:import` lines for the same name. +- Prefer moving nontrivial calculations into Python properties or Python-side + canvas updates instead of putting complex formulas in KV. This is especially + important for animated positions, trigonometry, paths, query results, and + generated lists. +- If a KV canvas expression still uses trigonometry, the KV string must include + the math imports once, before any widget rules: + +``` +KV = """ +#:import sin math.sin +#:import cos math.cos +#:import dp kivy.metrics.dp + +FloatLayout: + canvas: + Ellipse: + size: dp(24), dp(24) + pos: self.x + self.width * sin(root.phase), self.y +""" +``` + +- Before returning code, scan the KV string for function calls such as `sin(`, + `cos(`, `dp(`, `sp(`, `rgba(`, or helper names and ensure each helper is + defined in KV parser scope or removed. + +KV safety additions: +- `ids` exist on the loaded root widget, not as global variables. Access them as + `ui.ids.some_id` after `Builder.load_string(KV)` returns a concrete root + widget. +- Avoid assigning duplicate ids in generated KV. +- Do not create dynamic styling aliases such as `:`. For + generated snippets, repeat simple properties, use a real Python class, or set + properties from Python after loading the widget tree. +- Keep KV expressions literal and simple. Use Python to compute all lists, + paths, formatted labels, colors derived from runtime state, and callback + decisions after the widget tree is loaded. +- Do not use `root` in KV to refer to the PythonHere global `root`. In KV, + `root` means the current KV rule/root widget. +- When a custom root class owns Kivy properties, define the class in Python + before `Builder.load_string(KV)` and end KV with a concrete instance of that + class. diff --git a/pythonhere/magic_here/prompts/kivy-runtime.md b/pythonhere/magic_here/prompts/kivy-runtime.md new file mode 100644 index 0000000..3ce00eb --- /dev/null +++ b/pythonhere/magic_here/prompts/kivy-runtime.md @@ -0,0 +1,306 @@ +## Kivy Runtime + +You generate Python/Kivy code for PythonHere, an already-running remote Python environment. + +Target runtime: +- The code is executed as a Jupyter/PythonHere cell inside an already-running Kivy application. +- The code is not a standalone script. +- The Kivy event loop is already running. +- The globals `app` and `root` already exist in the execution namespace. +- `app` is the current running Kivy App instance. +- `root` is the current visible top-level container widget. +- `root` is a `BoxLayout` instance. +- `root` supports `add_widget(...)`, `clear_widgets(...)`, and normal Kivy widget operations. +- Use the existing `app` and `root` globals directly. +- Do not create, start, stop, discover, validate, replace, or reassign the Kivy App. +- Do not write standalone fallback code. +- Do not generate standalone-compatible variants. +- Do not generate defensive runtime discovery code. +- Code normally runs on the Kivy main thread. + +Critical rules: +- Do not call `App().run()`. +- Do not call `app.run()`. +- Do not call `runTouchApp()`. +- Do not write `if __name__ == "__main__":` or any variant such as + `if "__main__" not in globals():`. +- Do not include standalone-testing branches. Generate only code for the live + PythonHere interpreter. +- Do not call `app.stop()`. +- Do not call `App.get_running_app().stop()`. +- Do not create a second `App` instance. +- Do not assign `app.root = ...`. +- Do not assign `app = SomeController(...)`, `app = GuitarApp(...)`, or similar. +- Do not assign `App.get_running_app().root = ...`. +- Do not write `app = App.get_running_app()` +- Do not import `App` or call `App.get_running_app()` to modify UI code. +- Do not name feature controllers `SomethingApp`. Use names such as + `PoemController`, `MusicController`, or `GalleryController`; `App` is reserved + for the existing Kivy application concept. +- Do not write `root = App.get_running_app().root`. +- Do not guard normal PythonHere UI code with `if "root" not in globals():` or + create a fallback path for missing `root`. In PythonHere, `root` is part of + the execution contract. +- Do not raise an error because `root` is missing in normal generated + PythonHere UI code. The generated cell should assume the PythonHere execution + contract and use `root` directly. +- Do not create a fallback root such as `BoxLayout(...)` when `root` is missing. +- Do not replace the app root object. In PythonHere, update the existing `root` + container with `root.clear_widgets()` and `root.add_widget(ui)` only when the + user explicitly asks to replace the visible UI. +- Do not define a new `App` subclass. + reusable standalone code. +- Do not call app lifecycle methods such as `build()`. +- Do not block the Kivy main thread with long work, `time.sleep()`, or polling + loops. +- On errors, show a popup and log a concise message and store an error result; + do not terminate the app or stop execution by exiting the process. +- Every caught exception should be logged. Use + `from kivy.logger import Logger` and Kivy's category-message style such as + `Logger.exception("PythonHere: Could not load gallery")` inside `except` + blocks when exception traceback is useful. Use + `Logger.error("PythonHere: Could not load gallery")` only when there is no + active exception to log. +- Kivy's `Logger` is the app's normal logger. In python-for-android builds, + stdout, stderr, and Kivy logger output are visible in Android logcat. Prefer + Kivy `Logger` over direct Android logging APIs for generated Python snippets. +- Logging does not replace state. Also store the error string in a clearly named + global such as `pythonhere_last_error`, `gallery_errors`, or another + feature-specific error list/dict. +- For expected runtime conditions such as missing context, unavailable activity, + missing folder, empty result set, unsupported image, or missing permission, do + not raise an uncaught exception after showing the user-facing error. Store the + error in a global result and keep the app alive. +- When a snippet needs early-exit behavior, put the workflow in a function and + use `return` inside that function, or use an `if/else` block. Do not emulate + early exit with process termination or uncaught exceptions. +- Do not update Kivy widgets from a background thread. Use + `Clock.schedule_once(...)` or `Clock.schedule_interval(...)` to return UI work + to the main thread. +- When updating Kivy properties on the main thread, assign them directly, for + example `popup.title = "Done"` or `label.text = "Done"`. Do not use + `widget.property(...).__set__(...)` or other descriptor internals. +- For canvas updates, keep explicit references to the instructions that will be + updated. For example, store `self.background_color = Color(...)` and + `self.background_rect = Rectangle(...)`, then update + `self.background_color.rgba = (...)` and + `self.background_rect.pos/size = ...`. +- Do not assume canvas instruction ordering with `canvas.children[...]`. +- Do not set color attributes on shape instructions such as `Rectangle`, + `Ellipse`, or `Line`; they do not have `rgba`. Update the preceding `Color` + instruction instead. +- Bind Kivy events with `widget.bind(on_release=callback)` after widget + creation. Do not rely on passing event handlers such as `on_release=...` into + widget constructors. + +UI update pattern: +- For simple UI changes, you may inspect feature-specific globals or previously stored widget references before replacing the interface. +- Do not inspect `globals()` to discover or validate `app` or `root`; PythonHere guarantees them. +- Do not use `App.get_running_app().root` as a substitute for the PythonHere + `root` global. +- Do not use `root = app.root if app else BoxLayout(...)` or similar fallback + root construction. It creates an unmounted widget that is not PythonHere's + visible UI. +- Only use `root.clear_widgets()` when the user explicitly asks to replace the + app UI. For temporary displays, keep the existing app UI intact. +- When the user explicitly asks to replace the visible UI, use + `Builder.load_string(...)` and then: `root.clear_widgets()` and + `root.add_widget(ui)`. +- Keep generated UI mobile-friendly: large touch targets, readable labels, + `dp()` for dimensions, and `sp()` for font sizes. +- Prefer standard widgets such as `BoxLayout`, `GridLayout`, `Label`, `Button`, + `TextInput`, `ScrollView`, `Image`, `Slider`, `Spinner`, `CheckBox`, `Scatter` and `Popup`. + +Android/Kivy interaction: + +- Use Kivy/Python-for-Android helpers when they exist, especially for permission + prompts and UI-thread scheduling. +- For Android permission prompts, prefer + `android.permissions.request_permissions(...)` and keep callback results in a + global variable. +- For Android Settings intents or permission dialogs, require a foreground + `PythonActivity.mActivity`; a service context is not enough to show UI. +- If falling back to `PythonService`, access it with + `autoclass("org.kivy.android.PythonService")` only inside the fallback block. + Do not write `from jnius import PythonService`. +- If an Android callback updates Kivy UI, schedule the update with + `Clock.schedule_once(...)`. + +Standard Kivy UI structure: +1. Imports. +2. Python state, helper functions, callbacks, or widget classes. +3. A KV string named `KV`. +4. Load the interface with `ui = Builder.load_string(KV)`. +5. If the user explicitly asked to replace the visible UI, replace the contents + of the existing PythonHere `root` container with: + + `root.clear_widgets()` + `root.add_widget(ui)` + +6. Bind widget callbacks in Python after `Builder.load_string(KV)`. +7. Optional `Clock` scheduling or background-thread integration. + +Wrong PythonHere root lookup: + +``` +from kivy.app import App +app = App.get_running_app() +root = app.root if app else BoxLayout(orientation="vertical") +``` + +Wrong standalone branch: + +``` +if __name__ == "__main__" not in globals(): + app = App.get_running_app() + app.root.clear_widgets() + app.root.add_widget(ui) +else: + print("stand-alone testing") +``` + +Correct PythonHere root usage: + +``` +from kivy.lang import Builder + +KV = """ +BoxLayout: + orientation: "vertical" + Label: + text: "Ready" +""" + +ui = Builder.load_string(KV) +root.clear_widgets() +root.add_widget(ui) +example_ui = ui +``` + +For a feature controller, do not overwrite `app`: + +``` +guitar_ui = Builder.load_string(KV) +guitar_controller = GuitarController(guitar_ui) +root.clear_widgets() +root.add_widget(guitar_ui) +``` + +Mobile UI guidelines: +- Use large readable labels. +- Use large touch-friendly buttons. +- Use `dp()` for sizes, spacing, padding, and heights. +- Use `sp()` for font sizes. +- Prefer simple layouts that work on small Android screens. +- Avoid tiny controls. +- Avoid desktop-only assumptions. +- Avoid overly complex nesting unless needed. +- Make demos immediately visible and interactive. + +Text and icon guidelines: +- Do not generate emoji, media-control symbols, arrows, checkmarks, stars, or decorative Unicode glyphs anywhere in Kivy UI text, button text, labels, status text, popup text, or print output. Use plain ASCII words instead. +- For visual icons, use image assets, canvas shapes, or a bundled icon font. Do not use Unicode characters as icons. +- Avoid decorative non-ASCII symbol glyphs for generated UI control + +State rules: +- Generated code runs in a notebook-like remote execution namespace. +- For stateful resources that should survive across cells, prefer clear global variables with obvious names. +- Reuse existing global resources when they already exist. +- Do not recreate expensive or stateful objects on every cell execution unless explicitly requested. +- Keep important objects inspectable from later cells. +- Provide explicit cleanup helpers for resources that need closing, stopping, or releasing. + +Output and callback rules: +- For non-UI one-shot introspection, `print(...)` may be used for concise + synchronous summaries. +- For generated Kivy UI workflows, do not use `print(...)` as the primary user + feedback channel. Update a visible status `Label` or other widget and store + state in a named global dictionary; an optional one-line `print(...)` may only + summarize where state was stored. +- For user-facing demo apps, avoid trailing summary `print(...)` calls when the + UI already shows status. Put start/stop/TTS/music state in the visible UI and + in globals. +- After creating a user-facing UI, do not print routine startup summaries such + as "UI loaded", synth config, or global variable names. Show readiness in the + UI status widget and keep inspectable objects in globals. +- Do not rely on `print(...)` inside Kivy, Android, BLE, permission, sensor, or + other asynchronous callbacks as the only user-visible output. Those callbacks + may run after notebook output capture has ended, or may only appear in app + logs. +- In callbacks, store results, status, and errors in clearly named global + variables, and update a visible Kivy `Label`, `Popup`, or status widget when + the user asked for UI feedback. +- For callback errors, store `repr(exc)` or a compact error string in a global + such as `last_error` or a feature-specific error list. Do not crash the app. +- For background threads and asynchronous callbacks, log diagnostics with Kivy's + logger: + `from kivy.logger import Logger`. + Use `Logger.info("PythonHere: ...")` for status and + `Logger.exception("PythonHere: ...")` inside `except` blocks. +- Logging is not a replacement for user-visible state. Also store status/errors + in globals and update UI when the user should see progress or failure. +- If useful, print one immediate line that names the global variables where + later callback results will be stored. + +Visual effects: +- Prefer simple, reliable visual effects over advanced effects. +- Standard `canvas.before` / `canvas.after` instructions are allowed. +- Good simple canvas instructions include `Color`, `Rectangle`, `Ellipse`, and `Line`. +- Use `Clock.schedule_interval` for lightweight animation. +- For animated Kivy canvas code, keep it responsive: avoid clearing/redrawing the full scene every frame. Cache static drawings and update only moving/changing canvas instructions, with bounded histories for trails or samples. +- Avoid shaders, custom GLSL, `Fbo`, or `RenderContext` unless the user explicitly asks for advanced OpenGL/shader code. + +Background work and responsiveness: +- Use background threads only when the requested task would block the Kivy main + thread, such as decoding many images, scanning many files, or doing slow + Android API calls. +- Keep a global reference to background worker state, for example + `gallery_worker_thread` or `scan_thread`, so repeated cells can inspect or + stop scheduling follow-up UI updates. +- Do not update Kivy widgets directly from a background thread. Return to the UI + thread with `Clock.schedule_once(...)`. +- For repeated scheduled work, store the `ClockEvent` in a global and provide a + stop/cancel helper when the task is user-visible or long-lived. +- If the requested code replaces the UI, keep the new root widget in a named + global such as `last_ui` or a feature-specific name so later cells can inspect + `ids` and state. + +Audio playback: +- Use Kivy SoundLoader only for normal existing local audio files, such as + downloaded MP3/OGG/WAV files or app-bundled sound effects. +- This SoundLoader rule does not apply to audio recorded through Plyer on + Android. +- Do not use Kivy SoundLoader to replay audio just recorded through + `plyer.audio` on Android. Plyer-recorded audio should be handled by the Plyer + audio rules. +- Store the loaded Sound object in a named global variable so it is not + garbage-collected during playback. +- Correct local audio playback pattern: +``` +from kivy.core.audio import SoundLoader + +sound = SoundLoader.load(str(audio_path)) +if sound is None: + raise RuntimeError("Could not load audio file") + +current_audio_sound = sound +sound.play() +``` +Correct stop pattern: +``` +sound = globals().get("current_audio_sound") +if sound is not None: + sound.stop() +``` + +Error display pattern: +- For generated UI snippets, prefer a visible status `Label` plus logged + diagnostics. +- For expected states such as started, stopped, unavailable, cancelled, empty + result, permission missing, or TTS requested, update visible UI state instead + of only printing. +- Popup errors are useful for unexpected failures, but do not make a popup the + only status channel for expected states such as empty results or missing + permissions. +- Store the latest status in a global dictionary with fields such as `ok`, + `stage`, `message`, and `error` when the workflow has multiple stages. diff --git a/pythonhere/magic_here/prompts/midi.md b/pythonhere/magic_here/prompts/midi.md new file mode 100644 index 0000000..6cb6331 --- /dev/null +++ b/pythonhere/magic_here/prompts/midi.md @@ -0,0 +1,243 @@ +## MIDI playback using `midistream` + +Use this addon when the user asks for: +- MIDI playback. +- Playing notes, chords, scales, melodies, arpeggios, drums, percussion, tones, or generated music. +- Synthesizer output. +- Instrument selection. +- General MIDI sounds. +- Volume, pan, modulation, reverb, or all-sound-off controls. +- A PythonHere/Kivy UI that plays MIDI sound. +- Debugging or introspecting MIDI playback on Android. + +Target library: +- Prefer the midistream package. +- midistream is intended for Android / Python-for-Android MIDI playback. +- midistream is installed, do not need to check for import errors + +Core API: +- Import the synthesizer with: + from midistream import Synthesizer, MIDIException, ReverbPreset +- Create one Synthesizer instance and keep it alive for as long as sound is needed: + synthesizer = Synthesizer() +- Stop playback and release resources with: + synthesizer.close() +- Write MIDI command bytes/lists with: + synthesizer.write(command) +- synthesizer.write accepts byte-like data. +- Helper functions return lists of integers. +- Several helper-message lists may be concatenated before writing. +- Read configuration with: + synthesizer.config +- Set master volume with: + synthesizer.volume = value + where value is normally an integer from 0 to 100; 100 is maximum. +- Set reverb with: + synthesizer.reverb = ReverbPreset.OFF + synthesizer.reverb = ReverbPreset.LARGE_HALL + synthesizer.reverb = ReverbPreset.HALL + synthesizer.reverb = ReverbPreset.CHAMBER + synthesizer.reverb = ReverbPreset.ROOM + +MIDI helper API: +- Prefer helpers instead of hand-written status bytes unless raw MIDI bytes are specifically useful. +- Import helpers with: + from midistream.helpers import ( + Control, + Note, + midi_channels, + midi_control_change, + midi_instruments, + midi_note_off, + midi_note_on, + midi_program_change, + note_name, + parse_note, + ) +- Use midi_note_on(note, channel=0, velocity=64) for note-on messages. +- Use midi_note_off(note, channel=0, velocity=0) for note-off messages. +- Use midi_program_change(program, channel=0) to select a General MIDI instrument. +- Use midi_control_change(controller, value=0, channel=0) for control-change messages. +- Useful Control values: + Control.volume + Control.pan + Control.modulation + Control.all_sound_off +- Use midi_instruments, a dict mapping program numbers 0..127 to instrument names, for menus/spinners and readable labels. +- Use parse_note("C4"), parse_note("Fs4"), parse_note("Bb3"), etc. for user note input. +- Use note_name(note_number) for readable display. +- Use Note.C4, Note.Cs4, Note.Bb3, etc. when a constant-like note name is clearer. +- MIDI note numbers must stay in 0..127. +- MIDI program numbers must stay in 0..127. +- MIDI velocity values must stay in 0..127. +- MIDI controller values must stay in 0..127. +- MIDI channels are 0..15. +- Channel 9 is the General MIDI percussion channel. +- midi_channels() intentionally yields melodic channels excluding channel 9. + +midistream-specific state: +- Use a global variable named synthesizer for the shared midistream Synthesizer instance. +- Reuse synthesizer across cells when it already exists. +- Create synthesizer only when needed: + if "synthesizer" not in globals() or synthesizer is None: + synthesizer = Synthesizer() +- Keep synthesizer globally inspectable so later cells can run: + synthesizer.config + synthesizer.volume = 80 + synthesizer.close() +- For safe cleanup, use these optional global tracking sets: + midistream_active_notes + midistream_used_channels +- midistream_active_notes tracks notes that generated code has sent note-on for but has not yet sent note-off for. +- midistream_active_notes is best-effort bookkeeping for cleanup, not a query of the synthesizer's real internal state. +- midistream_active_notes should contain (channel, note) tuples. +- midistream_used_channels should contain integer channel numbers. +- Add a note to midistream_active_notes immediately after a successful midi_note_on write. +- Remove a note from midistream_active_notes after sending midi_note_off for that note. +- Initialize tracking sets only if they do not already exist. +- Do not create a new Synthesizer for every note. +- Do not store the only Synthesizer reference inside a local function, widget callback, or temporary object. +- If Synthesizer creation fails, print or display a clear diagnostic. + +Recommended midistream helpers: +- For MIDI-generating cells, prefer small helper functions such as: + get_synthesizer() + send_midi(command, description="MIDI command") + note_on(note, channel=0, velocity=100) + note_off(note, channel=0, velocity=0) + play_note(note, duration=0.5, channel=0, velocity=100) + set_instrument(program, channel=0) + set_channel_volume(value, channel=0) + set_reverb(preset) + all_sound_off() + close_synthesizer() +- get_synthesizer should create or reuse the global synthesizer. +- send_midi should call get_synthesizer().write(command). +- note_on should: + 1. validate note, channel, and velocity, + 2. send midi_note_on(...), + 3. add (channel, note) to midistream_active_notes, + 4. add channel to midistream_used_channels. +- note_off should: + 1. send midi_note_off(...), + 2. remove (channel, note) from midistream_active_notes if present. +- play_note should: + 1. call note_on(...), + 2. schedule note_off(...) with Clock.schedule_once, + 3. avoid time.sleep(). +- all_sound_off should: + 1. send note-off for tracked notes, + 2. send midi_control_change(Control.all_sound_off, 0, channel=channel) for used channels, + 3. clear midistream_active_notes. +- close_synthesizer should: + 1. call all_sound_off(), + 2. call synthesizer.close(), + 3. set synthesizer = None. + +Timing rules: +- Do not use time.sleep() for note durations, melodies, or sequences. +- Do not use long blocking loops. +- Use kivy.clock.Clock.schedule_once for delayed note-off events. +- Use Clock.schedule_once for each event in a melody or sequence. +- Use Clock.schedule_interval only for repeated playback that the user can stop. +- For endless or repeating music, store every scheduled `ClockEvent` for note + starts, note stops, and next-step callbacks in a global or controller list, + and cancel those events in the Stop / All Sound Off handler. +- Do not leave recursive `Clock.schedule_once(...)` callbacks running after the + user presses Stop. +- Keep scheduled callbacks short. +- Do not rely on `print(...)` inside scheduled callbacks as the only output. + Scheduled callbacks may run after notebook output capture has ended. Store + playback status and errors in globals, and update a visible Kivy label when + the user asked for UI feedback. +- For one-shot notes, prefer: + note_on(note, channel, velocity) + Clock.schedule_once(lambda dt: note_off(note, channel), duration) +- For sequences, schedule each note-on and note-off at offsets from the start of the sequence. + +Kivy UI integration: +- Useful controls include: + Button for playing notes/chords. + Button for Stop / All Sound Off. + Slider for volume. + Slider for note duration. + Spinner for instrument selection. + Spinner for reverb preset. + TextInput for note names such as C4, Fs4, Bb3. + Label for diagnostics. +- Bind UI controls to helper functions after Builder.load_string(KV) returns the layout. +- Keep callbacks short and catch exceptions. +- Show MIDI status and errors in a Label when the user asked for visible UI. + +Recommended simple UI behavior: +- Always include a Stop or All Sound Off button in interactive MIDI UIs. +- For note buttons, schedule note-off automatically. +- For instrument selection, display readable names from midi_instruments. +- For volume sliders, update synthesizer.volume or send Control.volume depending on the requested behavior. +- For reverb controls, map readable names to ReverbPreset values. +- For percussion, use channel 9 and explain through labels or code comments that channel 9 is the General MIDI percussion channel. + +MIDI diagnostics: +- For debugging, print readable labels. +- Useful diagnostics include: + - whether Synthesizer() initializes successfully, + - synthesizer.config, + - current volume, + - current reverb, + - selected channel, + - selected program number and instrument name, + - selected note number and note name, + - tracked notes in midistream_active_notes, + - used channels in midistream_used_channels, + - exception type and message. +- Catch MIDIException separately where useful. + +Good command examples: +- Initialize or reuse: + if "synthesizer" not in globals() or synthesizer is None: + synthesizer = Synthesizer() + + if "midistream_active_notes" not in globals(): + midistream_active_notes = set() + + if "midistream_used_channels" not in globals(): + midistream_used_channels = set() + +- Play middle C: + synthesizer.write(midi_note_on(60, channel=0, velocity=100)) + later send: + synthesizer.write(midi_note_off(60, channel=0)) + +- Play a note without blocking: + note_on(60, channel=0, velocity=100) + Clock.schedule_once(lambda dt: note_off(60, channel=0), 0.5) + +- Change instrument: + synthesizer.write(midi_program_change(0, channel=0)) + +- Change channel volume: + synthesizer.write(midi_control_change(Control.volume, 100, channel=0)) + +- Send multiple MIDI messages in one write: + synthesizer.write( + midi_program_change(0, channel=0) + + midi_control_change(Control.volume, 100, channel=0) + + midi_note_on(60, channel=0, velocity=100) + ) + +- Stop sound immediately on a channel: + synthesizer.write(midi_control_change(Control.all_sound_off, 0, channel=0)) + +Avoid: +- Do not generate code that starts notes without ever turning them off. +- Do not create a new Synthesizer for every note. +- Do not block the Kivy event loop with sleep calls or long loops. +- Do not assume audio output is available before Synthesizer() succeeds. + +For simple user requests: +- If the user asks to play a note, generate a minimal self-contained cell that imports midistream, initializes or reuses synthesizer, plays the note, schedules note-off with Clock.schedule_once, and prints diagnostics. +- If the user asks to play a melody, generate a cell that schedules all notes with Clock.schedule_once and provides all_sound_off cleanup. +- If the user asks for a Kivy UI, generate a PythonHere-compatible UI using the existing root object and include Stop / All Sound Off. +- If the user asks for an instrument picker, use midi_instruments for labels and midi_program_change for selection. +- If the user asks for drums, use channel 9. +- If the user asks for cleanup, generate close_synthesizer() code that stops tracked notes and closes the global synthesizer. diff --git a/pythonhere/magic_here/prompts/plyer.md b/pythonhere/magic_here/prompts/plyer.md new file mode 100644 index 0000000..56d2094 --- /dev/null +++ b/pythonhere/magic_here/prompts/plyer.md @@ -0,0 +1,202 @@ +## Plyer helpers + +Use this addon for Plyer-backed Android/device features: +notification, Android toast-style messages, vibration, audio recording, camera capture, +file chooser, GPS/location, battery, accelerometer, compass, text-to-speech, +and similar Plyer facades. + +`plyer` is installed; do not need to check for import errors before normal use. + +Rules: +- `plyer` is installed; do not need to check for import errors before normal use. +- Prefer the `plyer` package for the supported device facades listed here. +- Plyer does not have a separate `toast` facade. Do not write + `from plyer import toast`. +- For Android toast-style messages, use + `from plyer import notification` and call + `notification.notify(..., toast=True)`. +- Do not request permissions here unless the user explicitly asks; use the separate Android permissions prompt. +- Do not access camera, microphone, GPS/location, sensors, contacts, SMS, call logs, or private files unless the user requested that specific capability. +- Do not delete, overwrite, upload, or make network requests with selected files unless explicitly requested. +- For asynchronous Plyer callbacks, store results in globals. If a Kivy UI is + involved, update visible UI through the Kivy runtime pattern. +- For microphone recording, Android normally needs + `android.permission.RECORD_AUDIO` declared in the app manifest and granted at + runtime. Use the Android permissions prompt when the user asks to request or + check microphone permission. + +Text-to-speech rules: +- For simple text-to-speech, use exactly this API shape: + + from plyer import tts + tts.speak(message=text_to_read) + +- Do not use low-level Android framework speech APIs through Pyjnius for + ordinary read-aloud, speech, voice output, poem reading, or narration + requests. Use Pyjnius speech only when the user explicitly asks for lower-level + Android speech controls that Plyer does not expose. +- Do not use legacy SL4A-style Android helper speech APIs. +- Do not use desktop speech packages or platform shell commands for + Android/PythonHere TTS snippets. +- Do not generate `TTS_AVAILABLE` fallback scaffolding or probe multiple TTS + backends unless the user explicitly asks for cross-platform desktop code. +- Do not run `tts.speak(...)` inside a background Python thread. Use the Plyer + call directly from the generated cell or from a short Kivy callback. +- For a Kivy UI button or delayed speech start, the callback should call + `tts.speak(message=text)` directly and update UI state through the Kivy + runtime pattern. + +Plyer audio recording rules: +- Use `plyer.audio` for audio recording workflows. +- Do not use `plyer.audio` as a general local-file playback API. +- Never call `audio.play(path)` or `audio.play("file.wav")`. +- For Android Plyer recording, prefer `.3gp` output paths unless this runtime has + verified another format. +- Do not name Plyer Android recordings `.wav` unless the backend is known to + write real WAV PCM data. +- Replay audio recorded through Plyer with `audio.play()` and no arguments after + `audio.stop()`. +- Stop recording or Plyer-managed playback with `audio.stop()`. +- Do not use Kivy SoundLoader to replay audio just recorded through Plyer on + Android. Kivy SoundLoader is for normal existing local audio files and is + covered by the Kivy Runtime prompt. + +Toast example: + from plyer import notification + + notification.notify( + title="", + message="Hello", + app_name="PythonHere", + toast=True, + ) + +Notification example: + from plyer import notification + + notification.notify( + title="PythonHere", + message="Done", + app_name="PythonHere", + timeout=5, + ) + +Toast plus notification example: + from plyer import notification + + notification.notify( + title="", + message="Done", + app_name="PythonHere", + toast=True, + ) + notification.notify( + title="PythonHere", + message="Done", + app_name="PythonHere", + timeout=5, + ) + +Vibration example: + from plyer import vibrator + + vibrator.vibrate(0.2) + +Text-to-speech example: + from plyer import tts + + tts.speak(message="Hello from PythonHere.") + +File chooser example: + from plyer import filechooser + + def on_selection(paths): + plyer_filechooser_result = { + "paths": list(paths or []), + "cancelled_or_empty": not bool(paths), + } + globals()["plyer_filechooser_result"] = plyer_filechooser_result + + filechooser.open_file(on_selection=on_selection) + +Audio recording start example: + from pathlib import Path + from datetime import datetime + + from plyer import audio + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + plyer_audio_recording_path = str(Path.cwd() / f"pythonhere-recording-{timestamp}.3gp") + audio.file_path = plyer_audio_recording_path + audio.start() + plyer_audio_recording_status = { + "recording": True, + "path": plyer_audio_recording_path, + } + +Audio recording stop example: + from plyer import audio + + audio.stop() + plyer_audio_recording_status = { + "recording": False, + "path": plyer_audio_recording_path, + } + +Audio recording replay example: + from plyer import audio + + audio.play() + +Camera example: + from plyer import camera + + camera.take_picture( + filename="photo.jpg", + on_complete=lambda path: globals().__setitem__( + "plyer_camera_result", + {"path": path, "cancelled_or_empty": not bool(path)}, + ), + ) + +GPS example: + from plyer import gps + + def on_location(**kwargs): + globals()["plyer_gps_last_location"] = dict(kwargs) + + gps.configure(on_location=on_location) + gps.start() + +GPS stop example: + from plyer import gps + + gps.stop() + +Battery example: + from plyer import battery + + status = battery.status + +Accelerometer example: + from plyer import accelerometer + + accelerometer.enable() + acceleration = accelerometer.acceleration + +Compass example: + from plyer import compass + + compass.enable() + heading = compass.orientation + +Plyer callback state pattern: +- For every asynchronous Plyer facade, store callback results in a named global + such as `plyer_filechooser_result`, `plyer_camera_result`, or + `plyer_gps_last_location`. +- If a Kivy UI is involved, update a visible status widget from the callback + using `Clock.schedule_once(...)` when needed. +- Do not treat a callback returning an empty selection or `None` as an + exception; report it as a cancelled/empty result. +- Keep file chooser behavior read-only unless the user explicitly asks to open, + process, copy, upload, delete, or overwrite selected files. diff --git a/pythonhere/main.py b/pythonhere/main.py index 32ad1c1..959219f 100644 --- a/pythonhere/main.py +++ b/pythonhere/main.py @@ -12,12 +12,14 @@ startup_script_exception = None # pylint: disable=invalid-name import asyncio +import logging import os import sys import threading from pathlib import Path from typing import Any +import asyncssh from enum_here import ScreenName, ServerState from exception_manager_here import install_exception_handler, show_exception_popup from kivy.app import App @@ -31,6 +33,12 @@ monkeypatch_kivy() +def configure_logging(): + """Configure logging for the app runtime.""" + logging.getLogger().setLevel(logging.INFO) + asyncssh.set_log_level("WARNING") + + class PythonHereApp(App): """PythonHere main app.""" @@ -217,6 +225,7 @@ def chdir(self, path: str): async def main(): """Run PythonHere.""" + configure_logging() app = PythonHereApp() await app.run_app() diff --git a/tests/test_ai_prompts.py b/tests/test_ai_prompts.py new file mode 100644 index 0000000..40b99d6 --- /dev/null +++ b/tests/test_ai_prompts.py @@ -0,0 +1,57 @@ +import pytest +from herethere.there.ai import prompts + +import pythonhere +from pythonhere.magic_here.prompts import ( + PYTHONHERE_AI_ACTIVE_PROMPTS, + PYTHONHERE_AI_PROMPTS, + register_pythonhere_ai_prompts, +) + + +@pytest.fixture(autouse=True) +def preserve_ai_prompt_store(): + original_active_prompts = prompts._ai_prompt_store.active_prompts + original_registry = dict(prompts._ai_prompt_store.registry) + prompts.reset_ai_prompt_store() + yield + prompts._ai_prompt_store.active_prompts = original_active_prompts + prompts._ai_prompt_store.registry.clear() + prompts._ai_prompt_store.registry.update(original_registry) + + +def test_pythonhere_ai_prompts_are_registered_as_active_stack(): + register_pythonhere_ai_prompts() + + assert prompts._ai_prompt_store.active_prompts == ( + "default", + *PYTHONHERE_AI_ACTIVE_PROMPTS, + ) + for name in PYTHONHERE_AI_PROMPTS: + assert name in prompts.list_ai_prompts() + assert "able" not in prompts._ai_prompt_store.active_prompts + assert "midi" not in prompts._ai_prompt_store.active_prompts + + template = prompts.get_ai_template() + assert "Kivy" in template + assert "Android" in template + assert "Pyjnius" in template + assert "Kv design" in template + assert "Plyer helpers" in template + assert "Android Package Inventory" in template + assert "Android Intents and Settings flows" not in template + assert "Android BLE" not in template + assert "MIDI playback" not in template + + +def test_load_ipython_extension_preloads_pythonhere_ai_prompts(mocker): + load_herethere_extension = mocker.patch("pythonhere.load_herethere_extension") + ipython = mocker.Mock() + + pythonhere.load_ipython_extension(ipython) + + assert prompts._ai_prompt_store.active_prompts == ( + "default", + *PYTHONHERE_AI_ACTIVE_PROMPTS, + ) + load_herethere_extension.assert_called_once_with(ipython) diff --git a/tests/test_main.py b/tests/test_main.py index 4b07d4a..98a2252 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,4 +1,5 @@ import asyncio +import logging from pathlib import Path from types import SimpleNamespace from unittest.mock import PropertyMock @@ -6,7 +7,7 @@ import pytest from asyncssh import PermissionDenied from enum_here import ScreenName, ServerState -from main import PythonHereApp +from main import PythonHereApp, configure_logging, main from ui_here.server_screen_here import ServerScreenManager from version_here import __version__ @@ -15,6 +16,36 @@ def test_dev_version_is_set(): assert __version__ == "0.0.0" +def test_configure_logging_sets_default_info_and_asyncssh_warning(mocker): + set_log_level = mocker.patch("main.asyncssh.set_log_level") + root_logger = logging.getLogger() + + original_level = root_logger.level + try: + root_logger.setLevel(logging.NOTSET) + + configure_logging() + + assert root_logger.level == logging.INFO + set_log_level.assert_called_once_with("WARNING") + finally: + root_logger.setLevel(original_level) + + +@pytest.mark.asyncio +async def test_main_configures_logging_before_running_app(mocker): + configure = mocker.patch("main.configure_logging") + app = mocker.Mock() + app.run_app = mocker.AsyncMock() + app_class = mocker.patch("main.PythonHereApp", return_value=app) + + await main() + + configure.assert_called_once_with() + app_class.assert_called_once_with() + app.run_app.assert_awaited_once_with() + + def test_server_screen_update_states(mocker): screen = SimpleNamespace(current=None) app = SimpleNamespace( diff --git a/uv.lock b/uv.lock index acd671b..23ec14e 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,8 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'linux'", + "python_full_version >= '3.14' and sys_platform == 'linux'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'linux'", "python_full_version == '3.11.*' and sys_platform == 'linux'", "python_full_version < '3.11' and sys_platform == 'linux'", ] @@ -269,6 +270,106 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/92/dfd892312d822f36c55366118b95d914e5f16de11044a27cf10a7d71bbbf/commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9", size = 51068, upload-time = "2019-10-04T15:37:37.674Z" }, ] +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'linux'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'linux'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and sys_platform == 'linux'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, +] + [[package]] name = "coverage" version = "7.14.0" @@ -402,6 +503,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, ] +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + [[package]] name = "decorator" version = "5.3.1" @@ -468,18 +578,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, ] +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/8f/bdca24a84c81d56fffed052229cdcff368f6e05882e526f4558891481f65/fonttools-4.63.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579", size = 4946346, upload-time = "2026-05-14T12:02:43.41Z" }, + { url = "https://files.pythonhosted.org/packages/04/59/a639c0e136441ee91a65b56fdf89e5d075927e7a09c559d1b0f5276577db/fonttools-4.63.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22", size = 4903184, upload-time = "2026-05-14T12:02:45.742Z" }, + { url = "https://files.pythonhosted.org/packages/e6/53/91b7e0cb45b536f3da1b29ba8cbab89f27e8b986809e0b1982303a3f4eca/fonttools-4.63.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e", size = 4922967, upload-time = "2026-05-14T12:02:48.386Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b7/87439bf44e6b97c5538cd29d0b7e366a5b8ce2cc132a4134fb67fa3f2fa2/fonttools-4.63.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69", size = 5042799, upload-time = "2026-05-14T12:02:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + [[package]] name = "herethere" -version = "0.2.1" +version = "0.2.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asyncssh", marker = "sys_platform == 'linux'" }, { name = "click", marker = "sys_platform == 'linux'" }, { name = "python-dotenv", marker = "sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/c9/a4fea92b31e272866422f0ca46232cc8959768d7412758b726d564d74284/herethere-0.2.1.tar.gz", hash = "sha256:be4fd93485ca633320e15b66e8d3df9c680a5a981cea27ea079b8fef3f9749d4", size = 25665, upload-time = "2026-05-25T19:45:23.689Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/71/cbee79cf68f61555e01ceb8df420364a693f20a726b2e63eaa1b27c23d88/herethere-0.2.3.tar.gz", hash = "sha256:a7646562ec62a624a50a531ce0bf6b7943223f8a6c27e2e1a0a346070fefedd0", size = 35675, upload-time = "2026-06-04T11:56:39.463Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/33/2c9dfabe56c14e8e19000596b045aec69dde465a84d7c923346dfc8d7093/herethere-0.2.1-py3-none-any.whl", hash = "sha256:c5a509ad723b82e7984a3315d8dbe772501bf3b8ef30717c71cd049e7cf0ea94", size = 25472, upload-time = "2026-05-25T19:45:22.457Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2e/b33b11cda79f6043caa2329a6a34a5b9ecc563fc071f179a714e0f49abdc/herethere-0.2.3-py3-none-any.whl", hash = "sha256:f12edcf4ff6659da91686dea038ac35da018831c51b65a9f9aaf9d953e831ed2", size = 37090, upload-time = "2026-06-04T11:56:38.374Z" }, ] [package.optional-dependencies] @@ -569,7 +712,8 @@ name = "ipython" version = "9.13.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'linux'", + "python_full_version >= '3.14' and sys_platform == 'linux'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'linux'", "python_full_version == '3.11.*' and sys_platform == 'linux'", ] dependencies = [ @@ -788,6 +932,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/55/cd1555bde62f809219cbc5d8a0836b0293399da2f4ba4e8ee84b6a7cc393/Kivy_Garden-0.1.5-py3-none-any.whl", hash = "sha256:ef50f44b96358cf10ac5665f27a4751bb34ef54051c54b93af891f80afe42929", size = 4623, upload-time = "2022-03-23T23:25:33.752Z" }, ] +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/0e/ba4ae25d03722f64de8b2c13e80d82ab537a06b30fc7065183c6439357e3/kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22", size = 1628776, upload-time = "2026-03-09T13:12:41.976Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e4/3f43a011bc8a0860d1c96f84d32fa87439d3feedf66e672fef03bf5e8bac/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b", size = 1228164, upload-time = "2026-03-09T13:12:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/3a901559a1e0c218404f9a61a93be82d45cb8f44453ba43088644980f033/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e", size = 1246656, upload-time = "2026-03-09T13:12:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/87/9e/f78c466ea20527822b95ad38f141f2de1dcd7f23fb8716b002b0d91bbe59/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb", size = 1295562, upload-time = "2026-03-09T13:12:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/0a/66/fd0e4a612e3a286c24e6d6f3a5428d11258ed1909bc530ba3b59807fd980/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537", size = 2178473, upload-time = "2026-03-09T13:12:50.254Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8e/6cac929e0049539e5ee25c1ee937556f379ba5204840d03008363ced662d/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4", size = 2274035, upload-time = "2026-03-09T13:12:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/9d0c18f1b52ea8074b792452cf17f1f5a56bd0302a85191f405cfbf9da16/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c", size = 2443217, upload-time = "2026-03-09T13:12:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/2a/6e19368803a038b2a90857bf4ee9e3c7b667216d045866bf22d3439fd75e/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede", size = 2249196, upload-time = "2026-03-09T13:12:55.057Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b9/1d50e610ecadebe205b71d6728fd224ce0e0ca6aba7b9cbe1da049203ac5/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f", size = 79888, upload-time = "2026-03-09T13:15:43.317Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ee/b85ffcd75afed0357d74f0e6fc02a4507da441165de1ca4760b9f496390d/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed", size = 77584, upload-time = "2026-03-09T13:15:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -800,6 +1025,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[[package]] +name = "matplotlib" +version = "3.10.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and sys_platform == 'linux'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and sys_platform == 'linux'" }, + { name = "cycler", marker = "sys_platform == 'linux'" }, + { name = "fonttools", marker = "sys_platform == 'linux'" }, + { name = "kiwisolver", marker = "sys_platform == 'linux'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and sys_platform == 'linux'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and sys_platform == 'linux'" }, + { name = "packaging", marker = "sys_platform == 'linux'" }, + { name = "pillow", marker = "sys_platform == 'linux'" }, + { name = "pyparsing", marker = "sys_platform == 'linux'" }, + { name = "python-dateutil", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b7/d8bcec2626c35f96972bff656299fef4578113ea6193c8fdad324710410c/matplotlib-3.10.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1aa972116abb4c9d201bf245620b433726cb6856f3bef6a78f776a00f5c92d37", size = 8769389, upload-time = "2026-04-24T00:11:48.959Z" }, + { url = "https://files.pythonhosted.org/packages/12/49/b78e214a527ea732033b7f4d37f7afb504d74ba9d134bd47938230dfb8b1/matplotlib-3.10.9-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae2f11957b27ce53497dd4d7b235c4d4f1faf383dfb39d0c5beb833bff883294", size = 9589657, upload-time = "2026-04-24T00:11:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/5f/15/5246f7b43beae19c74dfee651d58d6cc8112e06f77adb4e88cc04f2e3a23/matplotlib-3.10.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b049278ddce116aaa1c1377ebf58adea909132dfce0281cf7e3a1ea9fc2e2c65", size = 9651983, upload-time = "2026-04-24T00:11:54.766Z" }, + { url = "https://files.pythonhosted.org/packages/79/db/e28c1b83e3680740aa78925f5fb2ae4d16207207419ad75ea9fe604f8676/matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb", size = 8777092, upload-time = "2026-04-24T00:12:06.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/fa/3ce7adfe9ba101748f465211660d9c6374c876b671bdb8c2bb6d347e8b94/matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9", size = 9595691, upload-time = "2026-04-24T00:12:09.706Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/6960a76686ed668f2c60f84e9799ba4c0d56abdb36b1577b60c1d061d1ec/matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb", size = 9659771, upload-time = "2026-04-24T00:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, + { url = "https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091, upload-time = "2026-04-24T00:12:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027, upload-time = "2026-04-24T00:12:47.583Z" }, + { url = "https://files.pythonhosted.org/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269, upload-time = "2026-04-24T00:12:50.878Z" }, + { url = "https://files.pythonhosted.org/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194, upload-time = "2026-04-24T00:13:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684, upload-time = "2026-04-24T00:13:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790, upload-time = "2026-04-24T00:13:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e8/368aab88f3c4cd8992800f31abfe0670c3e47540ba20a97e9fdbcde594b3/matplotlib-3.10.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6640f75af2c6148293caa0a2b39dd806a492dd66c8a8b04035813e33d0fd2585", size = 8764117, upload-time = "2026-04-24T00:14:01.684Z" }, + { url = "https://files.pythonhosted.org/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002, upload-time = "2026-04-24T00:14:09.816Z" }, +] + [[package]] name = "matplotlib-inline" version = "0.2.2" @@ -892,6 +1161,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/cb/7a39e72e668c8445bdd95e494b3e21cfdddc68329be8ea3522c8befb46c4/nh3-0.3.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e49c9b564e6bcb03ecd2f057213df9a0de15a95812ac9db9600b590db23d3ae9", size = 1040938, upload-time = "2026-04-25T10:44:10.775Z" }, ] +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'linux'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'linux'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and sys_platform == 'linux'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -901,6 +1241,92 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'linux'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and sys_platform == 'linux'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11' and sys_platform == 'linux'" }, + { name = "pytz", marker = "python_full_version < '3.11' and sys_platform == 'linux'" }, + { name = "tzdata", marker = "python_full_version < '3.11' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'linux'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and sys_platform == 'linux'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and sys_platform == 'linux'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, +] + [[package]] name = "parso" version = "0.8.7" @@ -1082,6 +1508,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pyproject-hooks" version = "1.2.0" @@ -1166,6 +1601,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1194,6 +1641,9 @@ dev = [ { name = "ifaddr", marker = "sys_platform == 'linux'" }, { name = "jupytext", marker = "sys_platform == 'linux'" }, { name = "kivy", marker = "sys_platform == 'linux'" }, + { name = "matplotlib", marker = "sys_platform == 'linux'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and sys_platform == 'linux'" }, + { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and sys_platform == 'linux'" }, { name = "pylint", marker = "sys_platform == 'linux'" }, { name = "pytest", marker = "sys_platform == 'linux'" }, { name = "pytest-asyncio", marker = "sys_platform == 'linux'" }, @@ -1204,11 +1654,14 @@ dev = [ ] docker = [ { name = "jupytext", marker = "sys_platform == 'linux'" }, + { name = "matplotlib", marker = "sys_platform == 'linux'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and sys_platform == 'linux'" }, + { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and sys_platform == 'linux'" }, ] [package.metadata] requires-dist = [ - { name = "herethere", extras = ["magic"], specifier = ">=0.2.1" }, + { name = "herethere", extras = ["magic"], specifier = ">=0.2.3" }, { name = "ipython" }, { name = "ipywidgets" }, { name = "pillow" }, @@ -1222,6 +1675,8 @@ dev = [ { name = "ifaddr" }, { name = "jupytext", specifier = "==1.19.3" }, { name = "kivy", specifier = "==2.3.1" }, + { name = "matplotlib" }, + { name = "pandas" }, { name = "pylint" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -1230,7 +1685,20 @@ dev = [ { name = "ruff" }, { name = "twine" }, ] -docker = [{ name = "jupytext", specifier = "==1.19.3" }] +docker = [ + { name = "jupytext", specifier = "==1.19.3" }, + { name = "matplotlib" }, + { name = "pandas" }, +] + +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] [[package]] name = "pyyaml" @@ -1366,6 +1834,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "stack-data" version = "0.6.3" @@ -1456,6 +1933,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + [[package]] name = "urllib3" version = "2.7.0"