diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 6deafc2..0000000 --- a/.flake8 +++ /dev/null @@ -1,2 +0,0 @@ -[flake8] -max-line-length = 120 diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml index 0dedb6e..f2d463b 100644 --- a/.github/actions/build/action.yml +++ b/.github/actions/build/action.yml @@ -1,12 +1,6 @@ name: Build distribution description: Builds the distribution -inputs: - use-scm-version: - description: Overwrite package version from SCM information - default: 'false' - required: false - runs: using: composite steps: @@ -27,29 +21,9 @@ runs: shell: bash run: python3 -m pip install uv - - name: Install tools - shell: bash - if: ${{ inputs.use-scm-version == true || inputs.use-scm-version == 'true' }} - run: python3 -m pip install setuptools_scm toml --user - - - name: Set package version - shell: python - if: ${{ inputs.use-scm-version == true || inputs.use-scm-version == 'true' }} - run: | - import setuptools_scm - import toml - - with open("pyproject.toml", "r") as f: - pyproject = toml.load(f) - - pyproject["project"]["version"] = setuptools_scm.get_version() - - with open("pyproject.toml", "w") as f: - toml.dump(pyproject, f) - - name: Build distribution packages shell: bash - run: uv build + run: uv build --all-packages env: UV_PROJECT_ENVIRONMENT: .venv diff --git a/.github/actions/check/action.yml b/.github/actions/check/action.yml index f470240..14d97df 100644 --- a/.github/actions/check/action.yml +++ b/.github/actions/check/action.yml @@ -14,7 +14,7 @@ runs: - name: Install system dependencies shell: bash - run: sudo apt-get install -y pulseaudio libhidapi-libusb0 + run: sudo apt-get install -y pulseaudio libhidapi-libusb0 libfontconfig-dev - name: Set up Python uses: actions/setup-python@v5 @@ -27,13 +27,44 @@ runs: - name: Setup project shell: bash - run: uv sync + run: uv sync --group dev env: UV_PROJECT_ENVIRONMENT: .venv - - name: Run pre-commit - uses: pre-commit/action@v3.0.1 + - name: Run ruff check on main project + uses: astral-sh/ruff-action@v3 + with: + args: "check" + + - name: Run ruff format check on main project + uses: astral-sh/ruff-action@v3 + with: + args: "format --check" + + - name: Run ruff check on plugins + uses: astral-sh/ruff-action@v3 + with: + src: "plugins/" + args: "check" + + - name: Run ruff format check on plugins + uses: astral-sh/ruff-action@v3 + with: + src: "plugins/" + args: "format --check" + + - name: Test core package + shell: bash + run: uv run pytest tests/ + + - name: Test OBS plugin + shell: bash + run: cd plugins/obs && uv run pytest tests/ + + - name: Test audio plugin + shell: bash + run: cd plugins/audio && uv run pytest tests/ - - name: pytest + - name: Test example plugin shell: bash - run: uv run pytest + run: cd plugins/example && uv run pytest tests/ diff --git a/.github/workflows/check-and-build.yml b/.github/workflows/check-and-build.yml index 23dc957..ef6caf3 100644 --- a/.github/workflows/check-and-build.yml +++ b/.github/workflows/check-and-build.yml @@ -23,11 +23,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: # required to get the correct scm version - fetch-depth: 0 - fetch-tags: true - name: Build uses: ./.github/actions/build - with: - use-scm-version: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 20d167b..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,36 +0,0 @@ -# See https://pre-commit.com for more information -# See https://pre-commit.com/hooks.html for more hooks -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 - hooks: - - id: check-added-large-files - - id: check-case-conflict - - id: check-docstring-first - - id: check-merge-conflict - - id: check-symlinks - - id: check-toml - - id: debug-statements - - id: end-of-file-fixer - - id: fix-byte-order-marker - - id: mixed-line-ending - - id: trailing-whitespace - - repo: https://github.com/pycqa/isort - rev: 6.0.1 - hooks: - - id: isort - name: isort (python) - - repo: https://github.com/psf/black - rev: 25.1.0 - hooks: - - id: black - - repo: https://github.com/pycqa/flake8 - rev: 7.3.0 - hooks: - - id: flake8 - additional_dependencies: [flake8-bugbear] - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.17.1 - hooks: - - id: mypy - args: [--strict, --ignore-missing-imports] diff --git a/LICENSE.md b/LICENSE.md index 4778124..d0c43ac 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -592,16 +592,4 @@ proprietary programs. If your program is a subroutine library, you may consider more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read -<>. - - -Third Party Work -================ - -This repository/package includes the font "Roboto Regular" by Christian Robertson that -is released under the terms of the Apache License, Version 2.0 -(). - -Also the font "Material Icons" is included. It is licensed under the terms of the -Apache License, Version 2.0 (). -See for more information. +<>. \ No newline at end of file diff --git a/README.md b/README.md index d11c961..60d3ac1 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,17 @@ Connect and control Elgato Stream Decks from Linux. ### PyPI - pip install knoepfe +```bash +pip install knoepfe +``` -should do the trick :) +For additional functionality, install plugins: + +```bash +pip install knoepfe[obs] # OBS Studio integration +pip install knoepfe[audio] # Audio control widgets +pip install knoepfe[all] # All available plugins +``` ### Arch Linux AUR @@ -28,7 +36,9 @@ should do the trick :) If you're on Arch Linux you can use the [PKGBUILD in the AUR](https://aur.archlinux.org/packages/knoepfe) to install Knöpfe. Provided you're using `yay` - yay -S knoepfe +```bash +yay -S knoepfe +``` should be enough. @@ -38,11 +48,13 @@ udev rules are required for Knöpfe to be able to communicate with the device. Create ` /etc/udev/rules.d/99-streamdeck.rules` with following content: - SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0060", TAG+="uaccess" - SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006d", TAG+="uaccess" - SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0080", TAG+="uaccess" - SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0063", TAG+="uaccess" - SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006c", TAG+="uaccess" +``` +SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0060", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006d", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0080", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0063", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006c", TAG+="uaccess" + ``` Then, run `sudo udevadm control --reload-rules` and reconnect the device. You should be ready to go then. @@ -50,52 +62,152 @@ Then, run `sudo udevadm control --reload-rules` and reconnect the device. You sh If you want to start Knöpfe automatically on user login, consider creating and enabling a systemd unit in `~/.config/systemd/user/knoepfe.service`: - [Unit] - Description=Knoepfe +``` +[Unit] +Description=Knoepfe - [Service] - # Set path to where Knoepfe executable was installed to - ExecStart=/usr/local/bin/knoepfe - Restart=always +[Service] +# Set path to where Knoepfe executable was installed to +ExecStart=/usr/local/bin/knoepfe +Restart=always - [Install] - WantedBy=default.target +[Install] +WantedBy=default.target +``` And start and enable it by running: - systemctl --user enable knoepfe - systemctl --user start knoepfe +```bash +systemctl --user enable knoepfe +systemctl --user start knoepfe +``` ## Usage ### Starting -Usually just running `knoepfe` should be enough. It reads the configuration from `~/.config/knoepfe/knoepfe.cfg` (see below for more information) and connects to the stream deck. +Usually just running `knoepfe` should be enough. It reads the configuration from `~/.config/knoepfe/knoepfe.toml` (see below for more information) and connects to the stream deck. -Anyway, some command line options are available: +Command line options are available: +``` +Usage: knoepfe [OPTIONS] COMMAND [ARGS]... - knopfe - Connect and control Elgato Stream Decks +Connect and control Elgato Stream Decks. - Usage: - knoepfe [(-v | --verbose)] [--config=] - knoepfe (-h | --help) - knoepfe --version +Options: +-v, --verbose Print debug information. +--config PATH Config file to use. +--mock-device Don't connect to a real device. Mainly useful for + debugging. +--no-cython-hid Disable experimental CythonHIDAPI transport. +--version Show the version and exit. +--help Show this message and exit. - Options: - -h --help Show this screen. - -v --verbose Print debug information. - --config= Config file to use. +Commands: +list-widgets List all available widgets. +widget-info Show detailed information about a widget. +``` ### Configuration -Unless overwritten on command line, Knöpfe loads its configuration from `~/.config/knoepfe/knoepfe.cfg`. So you should create that file if you don't want to stick to the example config used as fallback. - -Anyway, the example is a great way to start. It can be found as `knoepfe/default.cfg` in this repository and the installation target directory. - -The configuration is parsed as Python code. So every valid Python statement can be used, allowing to dynamically create and reuse parts of it. -The default configuration is heavily commented, hopefully explaining how to use it clear enough. +Knöpfe uses TOML format for configuration files. Create your configuration at `~/.config/knoepfe/knoepfe.toml`. + +Example configurations can be found in the `src/knoepfe/data/` directory: +- `default.toml` - Basic configuration with built-in widgets +- `clocks.toml` - Various clock widget examples +- `streaming.toml` - Configuration with OBS integration + +#### Basic Configuration Structure + +```toml +# Device settings +[device] +brightness = 100 +sleep_timeout = 10.0 +device_poll_frequency = 5 + +# Plugin configurations (optional) +[plugins.obs] +enabled = true +host = "localhost" +port = 4455 +password = "${OBS_PASSWORD}" # Load from environment variable + +# Decks - at least one deck named "main" is required +# Widgets in the main deck - properties can be specified directly +[[deck.main]] +type = "Clock" +[[deck.main.segments]] +format = "%H:%M" +x = 0 +y = 0 +width = 96 +height = 96 + +[[deck.main]] +type = "Text" +text = "Hello\nWorld" + +# Widgets can be assigned to specific positions using the 'index' parameter +# Without index, widgets are placed in order of appearance +[[deck.main]] +type = "Timer" +index = 5 # Place this widget at position 5 (0-based) + +# Additional decks can be defined similarly +[[deck.utilities]] +type = "Text" +text = "Back" +switch_deck = "main" +``` + +#### Widget Positioning + +By default, widgets are placed on the Stream Deck in the order they appear in the configuration file. However, you can explicitly control widget positions using the `index` parameter: + +```toml +# Without index - widgets placed in order (0, 1, 2, ...) +[[deck.main]] +type = "Clock" + +[[deck.main]] +type = "Text" +text = "Button 1" + +# With explicit index - can be out of order +[[deck.main]] +type = "Timer" +index = 5 # This will be at position 5 + +[[deck.main]] +type = "Text" +text = "Button 3" +index = 3 # This will be at position 3 + +# Mixing indexed and unindexed widgets +# Unindexed widgets fill remaining positions in order +[[deck.main]] +type = "Text" +text = "Auto" # Will fill next available position +``` + +**Note:** Index is 0-based, so `index = 0` is the first button, `index = 1` is the second, etc. + +#### Environment Variables + +Configuration values can reference environment variables using `${VAR_NAME}` syntax. This is particularly useful for sensitive data like passwords: + +```toml +[plugins.obs] +password = "${OBS_PASSWORD}" +``` + +You can also use the `KNOEPFE_` prefix to override any configuration value via environment variables: +```bash +export KNOEPFE_DEVICE__BRIGHTNESS=50 +export KNOEPFE_PLUGINS__OBS__PASSWORD=mysecret +``` ## Widgets @@ -103,78 +215,89 @@ Following widgets are included: ### Text -Simple widget just displaying a text. +Simple widget displaying text. -Can be instantiated as: - - widget({'type': 'knoepfe.widgets.Text', 'text': 'My great text!'}) - -Does nothing but showing the text specified with `text` on the key. +```toml +[[deck.main]] +type = "Text" +text = "My great text!" +``` ### Clock -Widget displaying the current time. Instantiated as: +Widget displaying the current time with customizable segments. - widget({'type': 'knoepfe.widgets.Clock', 'format': '%H:%M'}) +```toml +[[deck.main]] +type = "Clock" +interval = 1.0 # Update interval in seconds +[[deck.main.segments]] +format = "%H:%M" # strftime format code +x = 0 +y = 0 +width = 96 +height = 96 +``` -`format` expects a [strftime() format code](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes) to define the formatting. +The `format` field expects a [strftime() format code](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes). ### Timer Stop watch widget. -Instantiated as: - - widget({'type': 'knoepfe.widgets.Timer'}) +```toml +[[deck.main]] +type = "Timer" +``` -When pressed it counts the seconds until it is pressed again. It then shows the time elapsed between both presses until pressed again to reset. +When pressed it counts the seconds until pressed again. It then shows the elapsed time until pressed again to reset. -This widget acquires the wake lock while the time is running, preventing the device from going to sleep. +This widget acquires the wake lock while running, preventing the device from going to sleep. ### Mic Mute -Mute/unmute PulseAudio source, i.e. microphone. +Mute/unmute PulseAudio source (microphone). **Requires the audio plugin** (`pip install knoepfe[audio]`). -Instantiated with: +```toml +[[deck.main]] +type = "MicMute" +# device = "alsa_input.usb-..." # Optional: specific device name +``` - widget({'type': 'knoepfe.widgets.MicMute'}) - -Accepts `device` as optional argument with the name of source the operate with. If not set, the default source is used. -This widget shows if the source is muted and toggles the state on pressing it. +If no device is specified, the default source is used. Shows mute state and toggles on press. ### OBS Streaming and Recording -Show and toggle OBS streaming/recording. - -These widgets can be instantiated with - - widget({'type': 'knoepfe.widgets.obs.Recording'}) +Show and toggle OBS streaming/recording. **Requires the OBS plugin** (`pip install knoepfe[obs]`). -and +```toml +[[deck.main]] +type = "OBSRecording" - widget({'type': 'knoepfe.widgets.obs.Streaming'}) +[[deck.main]] +type = "OBSStreaming" +``` -They connect to OBS (if running, they're quite gray if not) and show if the stream or recording is running. On a long press the state is toggled. +These widgets connect to OBS and show if streaming/recording is active. Long press toggles the state. -As long as the connection to OBS is established these widgest hold the wake lock. +As long as the connection to OBS is established, these widgets hold the wake lock. ### OBS Current Scene and Scene Switch -Show and switch active OBS scene. - -These widgets are instantiated with - - widget({'type': 'knoepfe.widgets.obs.CurrentScene'}) - -and +Show and switch active OBS scene. **Requires the OBS plugin** (`pip install knoepfe[obs]`). - widget({'type': 'knoepfe.widgets.obs.SwitchScene', 'scene': 'Scene'}) +```toml +[[deck.main]] +type = "OBSCurrentScene" -The current scene widget just displays the active OBS scene. +[[deck.scenes]] +type = "OBSSwitchScene" +scene = "Scene Name" +``` -The scene switch widget indicates if the scene set with the `scene` key is currently active. If not and the widget is pressed it switches to the scene. +The current scene widget displays the active OBS scene. The scene switch widget indicates if the specified scene is active and switches to it when pressed. -As long as the connection to OBS is established these widgets hold the wake lock. +As long as the connection to OBS is established, these widgets hold the wake lock. ## Development @@ -182,7 +305,7 @@ Please feel free to open an [issue](https://github.com/lnqs/knoepfe/issues) if y Pull requests are also very welcome :) -As widgets are loaded by their module path it should also be possible to add new functionality in a plugin-ish way by just creating independent python modules defining their behaviour. But, well, I haven't tested that yet. +Knoepfe supports a plugin system for extending functionality. Plugins can be installed as separate packages and will be automatically discovered and loaded. See the existing plugins (obs, audio, example) as examples for creating new plugins. ## Mentions diff --git a/knoepfe/MaterialIcons-Regular.codepoints b/knoepfe/MaterialIcons-Regular.codepoints deleted file mode 100644 index 3c8b075..0000000 --- a/knoepfe/MaterialIcons-Regular.codepoints +++ /dev/null @@ -1,932 +0,0 @@ -3d_rotation e84d -ac_unit eb3b -access_alarm e190 -access_alarms e191 -access_time e192 -accessibility e84e -accessible e914 -account_balance e84f -account_balance_wallet e850 -account_box e851 -account_circle e853 -adb e60e -add e145 -add_a_photo e439 -add_alarm e193 -add_alert e003 -add_box e146 -add_circle e147 -add_circle_outline e148 -add_location e567 -add_shopping_cart e854 -add_to_photos e39d -add_to_queue e05c -adjust e39e -airline_seat_flat e630 -airline_seat_flat_angled e631 -airline_seat_individual_suite e632 -airline_seat_legroom_extra e633 -airline_seat_legroom_normal e634 -airline_seat_legroom_reduced e635 -airline_seat_recline_extra e636 -airline_seat_recline_normal e637 -airplanemode_active e195 -airplanemode_inactive e194 -airplay e055 -airport_shuttle eb3c -alarm e855 -alarm_add e856 -alarm_off e857 -alarm_on e858 -album e019 -all_inclusive eb3d -all_out e90b -android e859 -announcement e85a -apps e5c3 -archive e149 -arrow_back e5c4 -arrow_downward e5db -arrow_drop_down e5c5 -arrow_drop_down_circle e5c6 -arrow_drop_up e5c7 -arrow_forward e5c8 -arrow_upward e5d8 -art_track e060 -aspect_ratio e85b -assessment e85c -assignment e85d -assignment_ind e85e -assignment_late e85f -assignment_return e860 -assignment_returned e861 -assignment_turned_in e862 -assistant e39f -assistant_photo e3a0 -attach_file e226 -attach_money e227 -attachment e2bc -audiotrack e3a1 -autorenew e863 -av_timer e01b -backspace e14a -backup e864 -battery_alert e19c -battery_charging_full e1a3 -battery_full e1a4 -battery_std e1a5 -battery_unknown e1a6 -beach_access eb3e -beenhere e52d -block e14b -bluetooth e1a7 -bluetooth_audio e60f -bluetooth_connected e1a8 -bluetooth_disabled e1a9 -bluetooth_searching e1aa -blur_circular e3a2 -blur_linear e3a3 -blur_off e3a4 -blur_on e3a5 -book e865 -bookmark e866 -bookmark_border e867 -border_all e228 -border_bottom e229 -border_clear e22a -border_color e22b -border_horizontal e22c -border_inner e22d -border_left e22e -border_outer e22f -border_right e230 -border_style e231 -border_top e232 -border_vertical e233 -branding_watermark e06b -brightness_1 e3a6 -brightness_2 e3a7 -brightness_3 e3a8 -brightness_4 e3a9 -brightness_5 e3aa -brightness_6 e3ab -brightness_7 e3ac -brightness_auto e1ab -brightness_high e1ac -brightness_low e1ad -brightness_medium e1ae -broken_image e3ad -brush e3ae -bubble_chart e6dd -bug_report e868 -build e869 -burst_mode e43c -business e0af -business_center eb3f -cached e86a -cake e7e9 -call e0b0 -call_end e0b1 -call_made e0b2 -call_merge e0b3 -call_missed e0b4 -call_missed_outgoing e0e4 -call_received e0b5 -call_split e0b6 -call_to_action e06c -camera e3af -camera_alt e3b0 -camera_enhance e8fc -camera_front e3b1 -camera_rear e3b2 -camera_roll e3b3 -cancel e5c9 -card_giftcard e8f6 -card_membership e8f7 -card_travel e8f8 -casino eb40 -cast e307 -cast_connected e308 -center_focus_strong e3b4 -center_focus_weak e3b5 -change_history e86b -chat e0b7 -chat_bubble e0ca -chat_bubble_outline e0cb -check e5ca -check_box e834 -check_box_outline_blank e835 -check_circle e86c -chevron_left e5cb -chevron_right e5cc -child_care eb41 -child_friendly eb42 -chrome_reader_mode e86d -class e86e -clear e14c -clear_all e0b8 -close e5cd -closed_caption e01c -cloud e2bd -cloud_circle e2be -cloud_done e2bf -cloud_download e2c0 -cloud_off e2c1 -cloud_queue e2c2 -cloud_upload e2c3 -code e86f -collections e3b6 -collections_bookmark e431 -color_lens e3b7 -colorize e3b8 -comment e0b9 -compare e3b9 -compare_arrows e915 -computer e30a -confirmation_number e638 -contact_mail e0d0 -contact_phone e0cf -contacts e0ba -content_copy e14d -content_cut e14e -content_paste e14f -control_point e3ba -control_point_duplicate e3bb -copyright e90c -create e150 -create_new_folder e2cc -credit_card e870 -crop e3be -crop_16_9 e3bc -crop_3_2 e3bd -crop_5_4 e3bf -crop_7_5 e3c0 -crop_din e3c1 -crop_free e3c2 -crop_landscape e3c3 -crop_original e3c4 -crop_portrait e3c5 -crop_rotate e437 -crop_square e3c6 -dashboard e871 -data_usage e1af -date_range e916 -dehaze e3c7 -delete e872 -delete_forever e92b -delete_sweep e16c -description e873 -desktop_mac e30b -desktop_windows e30c -details e3c8 -developer_board e30d -developer_mode e1b0 -device_hub e335 -devices e1b1 -devices_other e337 -dialer_sip e0bb -dialpad e0bc -directions e52e -directions_bike e52f -directions_boat e532 -directions_bus e530 -directions_car e531 -directions_railway e534 -directions_run e566 -directions_subway e533 -directions_transit e535 -directions_walk e536 -disc_full e610 -dns e875 -do_not_disturb e612 -do_not_disturb_alt e611 -do_not_disturb_off e643 -do_not_disturb_on e644 -dock e30e -domain e7ee -done e876 -done_all e877 -donut_large e917 -donut_small e918 -drafts e151 -drag_handle e25d -drive_eta e613 -dvr e1b2 -edit e3c9 -edit_location e568 -eject e8fb -email e0be -enhanced_encryption e63f -equalizer e01d -error e000 -error_outline e001 -euro_symbol e926 -ev_station e56d -event e878 -event_available e614 -event_busy e615 -event_note e616 -event_seat e903 -exit_to_app e879 -expand_less e5ce -expand_more e5cf -explicit e01e -explore e87a -exposure e3ca -exposure_neg_1 e3cb -exposure_neg_2 e3cc -exposure_plus_1 e3cd -exposure_plus_2 e3ce -exposure_zero e3cf -extension e87b -face e87c -fast_forward e01f -fast_rewind e020 -favorite e87d -favorite_border e87e -featured_play_list e06d -featured_video e06e -feedback e87f -fiber_dvr e05d -fiber_manual_record e061 -fiber_new e05e -fiber_pin e06a -fiber_smart_record e062 -file_download e2c4 -file_upload e2c6 -filter e3d3 -filter_1 e3d0 -filter_2 e3d1 -filter_3 e3d2 -filter_4 e3d4 -filter_5 e3d5 -filter_6 e3d6 -filter_7 e3d7 -filter_8 e3d8 -filter_9 e3d9 -filter_9_plus e3da -filter_b_and_w e3db -filter_center_focus e3dc -filter_drama e3dd -filter_frames e3de -filter_hdr e3df -filter_list e152 -filter_none e3e0 -filter_tilt_shift e3e2 -filter_vintage e3e3 -find_in_page e880 -find_replace e881 -fingerprint e90d -first_page e5dc -fitness_center eb43 -flag e153 -flare e3e4 -flash_auto e3e5 -flash_off e3e6 -flash_on e3e7 -flight e539 -flight_land e904 -flight_takeoff e905 -flip e3e8 -flip_to_back e882 -flip_to_front e883 -folder e2c7 -folder_open e2c8 -folder_shared e2c9 -folder_special e617 -font_download e167 -format_align_center e234 -format_align_justify e235 -format_align_left e236 -format_align_right e237 -format_bold e238 -format_clear e239 -format_color_fill e23a -format_color_reset e23b -format_color_text e23c -format_indent_decrease e23d -format_indent_increase e23e -format_italic e23f -format_line_spacing e240 -format_list_bulleted e241 -format_list_numbered e242 -format_paint e243 -format_quote e244 -format_shapes e25e -format_size e245 -format_strikethrough e246 -format_textdirection_l_to_r e247 -format_textdirection_r_to_l e248 -format_underlined e249 -forum e0bf -forward e154 -forward_10 e056 -forward_30 e057 -forward_5 e058 -free_breakfast eb44 -fullscreen e5d0 -fullscreen_exit e5d1 -functions e24a -g_translate e927 -gamepad e30f -games e021 -gavel e90e -gesture e155 -get_app e884 -gif e908 -golf_course eb45 -gps_fixed e1b3 -gps_not_fixed e1b4 -gps_off e1b5 -grade e885 -gradient e3e9 -grain e3ea -graphic_eq e1b8 -grid_off e3eb -grid_on e3ec -group e7ef -group_add e7f0 -group_work e886 -hd e052 -hdr_off e3ed -hdr_on e3ee -hdr_strong e3f1 -hdr_weak e3f2 -headset e310 -headset_mic e311 -healing e3f3 -hearing e023 -help e887 -help_outline e8fd -high_quality e024 -highlight e25f -highlight_off e888 -history e889 -home e88a -hot_tub eb46 -hotel e53a -hourglass_empty e88b -hourglass_full e88c -http e902 -https e88d -image e3f4 -image_aspect_ratio e3f5 -import_contacts e0e0 -import_export e0c3 -important_devices e912 -inbox e156 -indeterminate_check_box e909 -info e88e -info_outline e88f -input e890 -insert_chart e24b -insert_comment e24c -insert_drive_file e24d -insert_emoticon e24e -insert_invitation e24f -insert_link e250 -insert_photo e251 -invert_colors e891 -invert_colors_off e0c4 -iso e3f6 -keyboard e312 -keyboard_arrow_down e313 -keyboard_arrow_left e314 -keyboard_arrow_right e315 -keyboard_arrow_up e316 -keyboard_backspace e317 -keyboard_capslock e318 -keyboard_hide e31a -keyboard_return e31b -keyboard_tab e31c -keyboard_voice e31d -kitchen eb47 -label e892 -label_outline e893 -landscape e3f7 -language e894 -laptop e31e -laptop_chromebook e31f -laptop_mac e320 -laptop_windows e321 -last_page e5dd -launch e895 -layers e53b -layers_clear e53c -leak_add e3f8 -leak_remove e3f9 -lens e3fa -library_add e02e -library_books e02f -library_music e030 -lightbulb_outline e90f -line_style e919 -line_weight e91a -linear_scale e260 -link e157 -linked_camera e438 -list e896 -live_help e0c6 -live_tv e639 -local_activity e53f -local_airport e53d -local_atm e53e -local_bar e540 -local_cafe e541 -local_car_wash e542 -local_convenience_store e543 -local_dining e556 -local_drink e544 -local_florist e545 -local_gas_station e546 -local_grocery_store e547 -local_hospital e548 -local_hotel e549 -local_laundry_service e54a -local_library e54b -local_mall e54c -local_movies e54d -local_offer e54e -local_parking e54f -local_pharmacy e550 -local_phone e551 -local_pizza e552 -local_play e553 -local_post_office e554 -local_printshop e555 -local_see e557 -local_shipping e558 -local_taxi e559 -location_city e7f1 -location_disabled e1b6 -location_off e0c7 -location_on e0c8 -location_searching e1b7 -lock e897 -lock_open e898 -lock_outline e899 -looks e3fc -looks_3 e3fb -looks_4 e3fd -looks_5 e3fe -looks_6 e3ff -looks_one e400 -looks_two e401 -loop e028 -loupe e402 -low_priority e16d -loyalty e89a -mail e158 -mail_outline e0e1 -map e55b -markunread e159 -markunread_mailbox e89b -memory e322 -menu e5d2 -merge_type e252 -message e0c9 -mic e029 -mic_none e02a -mic_off e02b -mms e618 -mode_comment e253 -mode_edit e254 -monetization_on e263 -money_off e25c -monochrome_photos e403 -mood e7f2 -mood_bad e7f3 -more e619 -more_horiz e5d3 -more_vert e5d4 -motorcycle e91b -mouse e323 -move_to_inbox e168 -movie e02c -movie_creation e404 -movie_filter e43a -multiline_chart e6df -music_note e405 -music_video e063 -my_location e55c -nature e406 -nature_people e407 -navigate_before e408 -navigate_next e409 -navigation e55d -near_me e569 -network_cell e1b9 -network_check e640 -network_locked e61a -network_wifi e1ba -new_releases e031 -next_week e16a -nfc e1bb -no_encryption e641 -no_sim e0cc -not_interested e033 -note e06f -note_add e89c -notifications e7f4 -notifications_active e7f7 -notifications_none e7f5 -notifications_off e7f6 -notifications_paused e7f8 -offline_pin e90a -ondemand_video e63a -opacity e91c -open_in_browser e89d -open_in_new e89e -open_with e89f -pages e7f9 -pageview e8a0 -palette e40a -pan_tool e925 -panorama e40b -panorama_fish_eye e40c -panorama_horizontal e40d -panorama_vertical e40e -panorama_wide_angle e40f -party_mode e7fa -pause e034 -pause_circle_filled e035 -pause_circle_outline e036 -payment e8a1 -people e7fb -people_outline e7fc -perm_camera_mic e8a2 -perm_contact_calendar e8a3 -perm_data_setting e8a4 -perm_device_information e8a5 -perm_identity e8a6 -perm_media e8a7 -perm_phone_msg e8a8 -perm_scan_wifi e8a9 -person e7fd -person_add e7fe -person_outline e7ff -person_pin e55a -person_pin_circle e56a -personal_video e63b -pets e91d -phone e0cd -phone_android e324 -phone_bluetooth_speaker e61b -phone_forwarded e61c -phone_in_talk e61d -phone_iphone e325 -phone_locked e61e -phone_missed e61f -phone_paused e620 -phonelink e326 -phonelink_erase e0db -phonelink_lock e0dc -phonelink_off e327 -phonelink_ring e0dd -phonelink_setup e0de -photo e410 -photo_album e411 -photo_camera e412 -photo_filter e43b -photo_library e413 -photo_size_select_actual e432 -photo_size_select_large e433 -photo_size_select_small e434 -picture_as_pdf e415 -picture_in_picture e8aa -picture_in_picture_alt e911 -pie_chart e6c4 -pie_chart_outlined e6c5 -pin_drop e55e -place e55f -play_arrow e037 -play_circle_filled e038 -play_circle_outline e039 -play_for_work e906 -playlist_add e03b -playlist_add_check e065 -playlist_play e05f -plus_one e800 -poll e801 -polymer e8ab -pool eb48 -portable_wifi_off e0ce -portrait e416 -power e63c -power_input e336 -power_settings_new e8ac -pregnant_woman e91e -present_to_all e0df -print e8ad -priority_high e645 -public e80b -publish e255 -query_builder e8ae -question_answer e8af -queue e03c -queue_music e03d -queue_play_next e066 -radio e03e -radio_button_checked e837 -radio_button_unchecked e836 -rate_review e560 -receipt e8b0 -recent_actors e03f -record_voice_over e91f -redeem e8b1 -redo e15a -refresh e5d5 -remove e15b -remove_circle e15c -remove_circle_outline e15d -remove_from_queue e067 -remove_red_eye e417 -remove_shopping_cart e928 -reorder e8fe -repeat e040 -repeat_one e041 -replay e042 -replay_10 e059 -replay_30 e05a -replay_5 e05b -reply e15e -reply_all e15f -report e160 -report_problem e8b2 -restaurant e56c -restaurant_menu e561 -restore e8b3 -restore_page e929 -ring_volume e0d1 -room e8b4 -room_service eb49 -rotate_90_degrees_ccw e418 -rotate_left e419 -rotate_right e41a -rounded_corner e920 -router e328 -rowing e921 -rss_feed e0e5 -rv_hookup e642 -satellite e562 -save e161 -scanner e329 -schedule e8b5 -school e80c -screen_lock_landscape e1be -screen_lock_portrait e1bf -screen_lock_rotation e1c0 -screen_rotation e1c1 -screen_share e0e2 -sd_card e623 -sd_storage e1c2 -search e8b6 -security e32a -select_all e162 -send e163 -sentiment_dissatisfied e811 -sentiment_neutral e812 -sentiment_satisfied e813 -sentiment_very_dissatisfied e814 -sentiment_very_satisfied e815 -settings e8b8 -settings_applications e8b9 -settings_backup_restore e8ba -settings_bluetooth e8bb -settings_brightness e8bd -settings_cell e8bc -settings_ethernet e8be -settings_input_antenna e8bf -settings_input_component e8c0 -settings_input_composite e8c1 -settings_input_hdmi e8c2 -settings_input_svideo e8c3 -settings_overscan e8c4 -settings_phone e8c5 -settings_power e8c6 -settings_remote e8c7 -settings_system_daydream e1c3 -settings_voice e8c8 -share e80d -shop e8c9 -shop_two e8ca -shopping_basket e8cb -shopping_cart e8cc -short_text e261 -show_chart e6e1 -shuffle e043 -signal_cellular_4_bar e1c8 -signal_cellular_connected_no_internet_4_bar e1cd -signal_cellular_no_sim e1ce -signal_cellular_null e1cf -signal_cellular_off e1d0 -signal_wifi_4_bar e1d8 -signal_wifi_4_bar_lock e1d9 -signal_wifi_off e1da -sim_card e32b -sim_card_alert e624 -skip_next e044 -skip_previous e045 -slideshow e41b -slow_motion_video e068 -smartphone e32c -smoke_free eb4a -smoking_rooms eb4b -sms e625 -sms_failed e626 -snooze e046 -sort e164 -sort_by_alpha e053 -spa eb4c -space_bar e256 -speaker e32d -speaker_group e32e -speaker_notes e8cd -speaker_notes_off e92a -speaker_phone e0d2 -spellcheck e8ce -star e838 -star_border e83a -star_half e839 -stars e8d0 -stay_current_landscape e0d3 -stay_current_portrait e0d4 -stay_primary_landscape e0d5 -stay_primary_portrait e0d6 -stop e047 -stop_screen_share e0e3 -storage e1db -store e8d1 -store_mall_directory e563 -straighten e41c -streetview e56e -strikethrough_s e257 -style e41d -subdirectory_arrow_left e5d9 -subdirectory_arrow_right e5da -subject e8d2 -subscriptions e064 -subtitles e048 -subway e56f -supervisor_account e8d3 -surround_sound e049 -swap_calls e0d7 -swap_horiz e8d4 -swap_vert e8d5 -swap_vertical_circle e8d6 -switch_camera e41e -switch_video e41f -sync e627 -sync_disabled e628 -sync_problem e629 -system_update e62a -system_update_alt e8d7 -tab e8d8 -tab_unselected e8d9 -tablet e32f -tablet_android e330 -tablet_mac e331 -tag_faces e420 -tap_and_play e62b -terrain e564 -text_fields e262 -text_format e165 -textsms e0d8 -texture e421 -theaters e8da -thumb_down e8db -thumb_up e8dc -thumbs_up_down e8dd -time_to_leave e62c -timelapse e422 -timeline e922 -timer e425 -timer_10 e423 -timer_3 e424 -timer_off e426 -title e264 -toc e8de -today e8df -toll e8e0 -tonality e427 -touch_app e913 -toys e332 -track_changes e8e1 -traffic e565 -train e570 -tram e571 -transfer_within_a_station e572 -transform e428 -translate e8e2 -trending_down e8e3 -trending_flat e8e4 -trending_up e8e5 -tune e429 -turned_in e8e6 -turned_in_not e8e7 -tv e333 -unarchive e169 -undo e166 -unfold_less e5d6 -unfold_more e5d7 -update e923 -usb e1e0 -verified_user e8e8 -vertical_align_bottom e258 -vertical_align_center e259 -vertical_align_top e25a -vibration e62d -video_call e070 -video_label e071 -video_library e04a -videocam e04b -videocam_off e04c -videogame_asset e338 -view_agenda e8e9 -view_array e8ea -view_carousel e8eb -view_column e8ec -view_comfy e42a -view_compact e42b -view_day e8ed -view_headline e8ee -view_list e8ef -view_module e8f0 -view_quilt e8f1 -view_stream e8f2 -view_week e8f3 -vignette e435 -visibility e8f4 -visibility_off e8f5 -voice_chat e62e -voicemail e0d9 -volume_down e04d -volume_mute e04e -volume_off e04f -volume_up e050 -vpn_key e0da -vpn_lock e62f -wallpaper e1bc -warning e002 -watch e334 -watch_later e924 -wb_auto e42c -wb_cloudy e42d -wb_incandescent e42e -wb_iridescent e436 -wb_sunny e430 -wc e63d -web e051 -web_asset e069 -weekend e16b -whatshot e80e -widgets e1bd -wifi e63e -wifi_lock e1e1 -wifi_tethering e1e2 -work e8f9 -wrap_text e25b -youtube_searched_for e8fa -zoom_in e8ff -zoom_out e900 -zoom_out_map e56b diff --git a/knoepfe/MaterialIcons-Regular.ttf b/knoepfe/MaterialIcons-Regular.ttf deleted file mode 100644 index 7015564..0000000 Binary files a/knoepfe/MaterialIcons-Regular.ttf and /dev/null differ diff --git a/knoepfe/Roboto-Regular.ttf b/knoepfe/Roboto-Regular.ttf deleted file mode 100644 index 7d9a6c4..0000000 Binary files a/knoepfe/Roboto-Regular.ttf and /dev/null differ diff --git a/knoepfe/__init__.py b/knoepfe/__init__.py deleted file mode 100644 index 485f44a..0000000 --- a/knoepfe/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "0.1.1" diff --git a/knoepfe/__main__.py b/knoepfe/__main__.py deleted file mode 100644 index 73c2b17..0000000 --- a/knoepfe/__main__.py +++ /dev/null @@ -1,97 +0,0 @@ -"""knoepfe - -Connect and control Elgato Stream Decks - -Usage: - knoepfe [(-v | --verbose)] [--config=] [--mock-device] - knoepfe (-h | --help) - knoepfe --version - -Options: - -h --help Show this screen. - -v --verbose Print debug information. - --config= Config file to use. - --mock-device Don't connect to a real device. Mainly useful for debugging. -""" - -from asyncio import sleep -from pathlib import Path -from textwrap import indent - -from aiorun import run -from docopt import docopt -from StreamDeck.DeviceManager import DeviceManager -from StreamDeck.Devices import StreamDeck -from StreamDeck.Transport.Transport import TransportError - -from knoepfe import __version__, log -from knoepfe.config import process_config -from knoepfe.deckmanager import DeckManager -from knoepfe.log import debug, info -from knoepfe.mockdeck import MockDeck - - -class Knoepfe: - def __init__(self) -> None: - self.device = None - - async def run(self, config_path: Path | None, mock_device: bool = False) -> None: - try: - debug("Processing config") - global_config, active_deck, decks = process_config(config_path) - except Exception as e: - raise RuntimeError( - f'Failed to parse configuration:\n{indent(str(e), " ")}' - ) - - while True: - device = await self.connect_device() if not mock_device else MockDeck() - - try: - deck_manager = DeckManager(active_deck, decks, global_config, device) - await deck_manager.run() - except TransportError: - debug("Transport error, trying to reconnect") - continue - - async def connect_device(self) -> StreamDeck: - info("Searching for devices") - device = None - - while True: - devices = DeviceManager().enumerate() - if len(devices): - device = devices[0] - break - await sleep(1.0) - - device.open() - device.reset() - - info( - f"Connected to {device.deck_type()} {device.get_serial_number()} " - f"(Firmware {device.get_firmware_version()}, {device.key_layout()[0]}x{device.key_layout()[1]} keys)" - ) - - return device - - def shutdown(self) -> None: - if self.device: - debug("Closing device") - self.device.close() - - -def main() -> None: - arguments = docopt(__doc__, version=__version__) - - config_path = Path(arguments["--config"]) if arguments["--config"] else None - mock_device = arguments["--mock-device"] - log.verbose = arguments["--verbose"] - - knoepfe = Knoepfe() - - run( - knoepfe.run(config_path, mock_device), - stop_on_unhandled_errors=True, - shutdown_callback=lambda _: knoepfe.shutdown(), - ) diff --git a/knoepfe/config.py b/knoepfe/config.py deleted file mode 100644 index a176c8f..0000000 --- a/knoepfe/config.py +++ /dev/null @@ -1,125 +0,0 @@ -from importlib import import_module -from pathlib import Path -from typing import Any, Dict, List, Tuple, Type, TypedDict - -import appdirs -from schema import And, Optional, Schema - -from knoepfe.deck import Deck -from knoepfe.log import info -from knoepfe.widgets.base import Widget - -DeckConfig = TypedDict("DeckConfig", {"id": str, "widgets": List[Widget | None]}) - -device = Schema( - { - Optional("brightness"): And(int, lambda b: 0 <= b <= 100), - Optional("sleep_timeout"): And(float, lambda b: b > 0.0), - Optional("device_poll_frequency"): And(int, lambda v: 1 <= v <= 1000), - } -) - - -def get_config_path(path: Path | None = None) -> Path: - if path: - return path - - path = Path(appdirs.user_config_dir(__package__), "knoepfe.cfg") - if path.exists(): - return path - - default_config = Path(__file__).parent.joinpath("default.cfg") - info( - f"No configuration file found at `{path}`. Consider copying the default" - f"config from `{default_config}` to this place and adjust it to your needs." - ) - - return default_config - - -def exec_config(config: str) -> Tuple[Dict[str, Any], Deck, List[Deck]]: - global_config: Dict[str, Any] = {} - decks = [] - default = None - - def config_(c: Dict[str, Any]) -> None: - type_, conf = create_config(c) - if type_ in global_config: - raise RuntimeError(f"Config {type_} already set") - global_config[type_] = conf - - def deck(c: DeckConfig) -> Deck: - d = create_deck(c) - decks.append(d) - return d - - def default_deck(c: DeckConfig) -> Deck: - nonlocal default - if default: - raise RuntimeError("default deck already set") - d = deck(c) - default = d - return d - - def widget(c: Dict[str, Any]) -> Widget: - return create_widget(c, global_config) - - exec( - config, - { - "config": config_, - "deck": deck, - "default_deck": default_deck, - "widget": widget, - }, - ) - - if not default: - raise RuntimeError("No default deck specified") - - return global_config, default, decks - - -def process_config(path: Path | None = None) -> Tuple[Dict[str, Any], Deck, List[Deck]]: - path = get_config_path(path) - with open(path) as f: - config = f.read() - - return exec_config(config) - - -def create_config(config: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]: - type_ = config["type"] - parts = type_.rsplit(".", 1) - module = import_module(parts[0]) - schema: Schema = getattr(module, parts[-1]) - - if not isinstance(schema, Schema): - raise RuntimeError(f"{schema} isn't a Schema") - - config = config.copy() - del config["type"] - schema.validate(config) - - return type_, config - - -def create_deck(config: DeckConfig) -> Deck: - return Deck(**config) - - -def create_widget(config: Dict[str, Any], global_config: Dict[str, Any]) -> Widget: - parts = config["type"].rsplit(".", 1) - module = import_module(parts[0]) - class_: Type[Widget] = getattr(module, parts[-1]) - - if not issubclass(class_, Widget): - raise RuntimeError(f"{class_} isn't a subclass of Widget") - - config = config.copy() - del config["type"] - schema = class_.get_config_schema() - - schema.validate(config) - - return class_(config, global_config) diff --git a/knoepfe/deck.py b/knoepfe/deck.py deleted file mode 100644 index 0cff3f2..0000000 --- a/knoepfe/deck.py +++ /dev/null @@ -1,63 +0,0 @@ -import asyncio -from asyncio import Event -from typing import TYPE_CHECKING, List, Optional - -from StreamDeck.Devices import StreamDeck - -from knoepfe.key import Key -from knoepfe.log import debug -from knoepfe.wakelock import WakeLock - -if TYPE_CHECKING: # pragma: no cover - from knoepfe.widgets.base import Widget - - -class SwitchDeckException(BaseException): - def __init__(self, new_deck: str) -> None: - self.new_deck = new_deck - - -class Deck: - def __init__(self, id: str, widgets: List[Optional["Widget"]]) -> None: - self.id = id - self.widgets = widgets - - async def activate( - self, device: StreamDeck, update_requested_event: Event, wake_lock: WakeLock - ) -> None: - with device: - for i in range(device.key_count()): - device.set_key_image(i, None) - - for widget in self.widgets: - if widget: - widget.update_requested_event = update_requested_event - widget.wake_lock = wake_lock - await asyncio.gather(*[w.activate() for w in self.widgets if w]) - await self.update(device, True) - - async def deactivate(self, device: StreamDeck) -> None: - await asyncio.gather(*[w.deactivate() for w in self.widgets if w]) - - async def update(self, device: StreamDeck, force: bool = False) -> None: - if len(self.widgets) > device.key_count(): - raise RuntimeError("Number of widgets exceeds number of device keys") - - async def update_widget(w: Optional["Widget"], i: int) -> None: - if w and (force or w.needs_update): - debug(f"Updating widget on key {i}") - await w.update(Key(device, i)) - w.needs_update = False - - await asyncio.gather( - *[update_widget(widget, index) for index, widget in enumerate(self.widgets)] - ) - - async def handle_key(self, index: int, pressed: bool) -> None: - if index < len(self.widgets): - widget = self.widgets[index] - if widget: - if pressed: - await widget.pressed() - else: - await widget.released() diff --git a/knoepfe/default.cfg b/knoepfe/default.cfg deleted file mode 100644 index 78446dd..0000000 --- a/knoepfe/default.cfg +++ /dev/null @@ -1,76 +0,0 @@ -# Knöpfe configuration. -# This file is parsed as Python code. -# Every valid Python statement can be used, allowing to dynamically create and reuse -# configuration parts. - -# Knöpfe imports several functions into this files namespace. These are: -# -# `config()` -- set global configuration. A `type` needs to be specified defining the -# schema. This configuration can be used by widgets. -# -# `default_deck()` -- set deck configuration for the deck loaded at program start. -# -# `deck()` -- set deck configuration for auxiliary decks that can be loaded from other decks. -# -# `widget()` -- create a widgets to be used by decks. - -config({ - # Global device configuration - 'type': 'knoepfe.config.device', - # Device brightness in percent - 'brightness': 100, - # Time in seconds until the device goes to sleep. Set no `None` to prevent this from happening. - # Widgets may acquire a wake lock to keep the device awake. - 'sleep_timeout': 10.0, - # Frequency to poll the hardware state in Hz (1-1000). Higher value means more CPU usage but - # also more responsive feedback. - 'device_poll_frequency': 5, -}) - -config({ - # Configuration for the OBS widgets. Just leave the whole block away if you don't want to control - # OBS. If you want to, obs-websocket () needs to be - # installed and activated. - 'type': 'knoepfe.widgets.obs.config', - # Host OBS is running. Probably `localhost`. - 'host': 'localhost', - # Port to obs-websocket is listening on. Defaults to 4455. - 'port': 4455, - # Passwort to use when authenticating with obs-websocket. - 'password': 'supersecret', -}) - -# Default deck. This one is displayed on the device when Knöpfe is stared. -# Please note this deck contains OBS widgets. All of these prevent the device from sleeping -# as long as a connection to OBS is established. -default_deck({ - # Arbitraty ID of the deck to be used to switch to this deck from others - 'id': 'main', - 'widgets': [ - # Widget to toggle mute state of a pulseaudio source (i.e. microphone). If no source is specified - # with `device` the default source is used. - widget({'type': 'knoepfe.widgets.MicMute'}), - # A simple timer widget. Acquires the wake lock while running. - widget({'type': 'knoepfe.widgets.Timer'}), - # A simple clock widget - widget({'type': 'knoepfe.widgets.Clock', 'format': '%H:%M'}), - # Widget showing and toggling the OBS recording state - widget({'type': 'knoepfe.widgets.obs.Recording'}), - # Widget showing and toggling the OBS streaming state - widget({'type': 'knoepfe.widgets.obs.Streaming'}), - # Widget showing the currently active OBS scene. Also defines a deck switch is this example, - # setting the active deck to `scenes` when pressed (can be used with all widgets). - widget({'type': 'knoepfe.widgets.obs.CurrentScene', 'switch_deck': 'scenes'}), - ], -}) - -# Another deck displaying OBS scenes and providing functionality to activate them. -deck({ - 'id': 'scenes', - 'widgets': [ - # Widget showing if the scene `Scene` is active and activating it on pressing it - widget({'type': 'knoepfe.widgets.obs.SwitchScene', 'scene': 'Scene', 'switch_deck': 'main'}), - # Widget showing if the scene `Scene` is active and activating it on pressing it - widget({'type': 'knoepfe.widgets.obs.SwitchScene', 'scene': 'Other Scene', 'switch_deck': 'main'}), - ], -}) diff --git a/knoepfe/key.py b/knoepfe/key.py deleted file mode 100644 index 62f7b7e..0000000 --- a/knoepfe/key.py +++ /dev/null @@ -1,92 +0,0 @@ -from contextlib import contextmanager -from pathlib import Path -from typing import Iterator, Literal, Tuple - -from PIL import Image, ImageDraw, ImageFont -from PIL.ImageFont import FreeTypeFont -from StreamDeck.Devices import StreamDeck -from StreamDeck.ImageHelpers import PILHelper - -Align = Literal["left", "center", "right"] -VAlign = Literal["top", "middle", "bottom"] - - -ICONS = dict( - line.split(" ") - for line in Path(__file__) - .parent.joinpath("MaterialIcons-Regular.codepoints") - .read_text() - .split("\n") - if line -) - - -class Renderer: - def __init__(self) -> None: - self.image = Image.new("RGB", (96, 96)) - - def text(self, text: str, size: int = 24, color: str | None = None) -> "Renderer": - return self._render_text("text", text, size, color) - - def icon(self, text: str, color: str | None = None) -> "Renderer": - return self._render_text("icon", text, 86, color) - - def icon_and_text( - self, icon: str, text: str, color: str | None = None - ) -> "Renderer": - self._render_text("icon", icon, 86, color, "top") - self._render_text("text", text, 16, color, "bottom") - return self - - def _render_text( - self, - type: Literal["text", "icon"], - text: str, - size: int, - color: str | None, - valign: VAlign = "middle", - ) -> "Renderer": - font = self._get_font(type, size) - draw = ImageDraw.Draw(self.image) - text_width = int(draw.textlength(text, font=font)) - text_height = font.size * (text.strip().count("\n") + 1) - x, y = self._aligned(text_width, text_height, "center", valign) - draw.text((x, y), text=text, font=font, fill=color, align="center") - return self - - def _aligned(self, w: int, h: int, align: Align, valign: VAlign) -> Tuple[int, int]: - x, y = 0, 0 - - if align == "center": - x = self.image.width // 2 - w // 2 - elif align == "right": - x = self.image.width - w - - if valign == "middle": - y = self.image.height // 2 - h // 2 - elif valign == "bottom": - y = self.image.height - h - 6 - - return x, y - - def _get_font(self, type: Literal["text", "icon"], size: int) -> FreeTypeFont: - font_file = ( - "Roboto-Regular.ttf" if type == "text" else "MaterialIcons-Regular.ttf" - ) - font_path = Path(__file__).parent.joinpath(font_file) - return ImageFont.truetype(str(font_path), size) - - -class Key: - def __init__(self, device: StreamDeck, index: int) -> None: - self.device = device - self.index = index - - @contextmanager - def renderer(self) -> Iterator[Renderer]: - r = Renderer() - yield r - - image = PILHelper.to_native_format(self.device, r.image) - with self.device: - self.device.set_key_image(self.index, image) diff --git a/knoepfe/log.py b/knoepfe/log.py deleted file mode 100644 index 1a94074..0000000 --- a/knoepfe/log.py +++ /dev/null @@ -1,16 +0,0 @@ -import sys - -verbose = False - - -def debug(message: str) -> None: - if verbose: - print(message, file=sys.stderr) - - -def info(message: str) -> None: - print(message, file=sys.stderr) - - -def error(message: str) -> None: - print(message, file=sys.stderr) diff --git a/knoepfe/mockdeck.py b/knoepfe/mockdeck.py deleted file mode 100644 index 25799d7..0000000 --- a/knoepfe/mockdeck.py +++ /dev/null @@ -1,61 +0,0 @@ -from typing import List - -from StreamDeck.Devices.StreamDeck import StreamDeck -from StreamDeck.Transport.Dummy import Dummy - - -class MockDeck(StreamDeck): # type: ignore - KEY_COUNT = 16 - KEY_COLS = 4 - KEY_ROWS = 4 - - KEY_PIXEL_WIDTH = 80 - KEY_PIXEL_HEIGHT = 80 - KEY_IMAGE_FORMAT = "BMP" - KEY_FLIP = (True, True) - KEY_ROTATION = 0 - - DECK_TYPE = None - - def __init__(self) -> None: - super().__init__(Dummy.Device("0000", "0000")) - - def _read_key_states(self) -> List[bool]: - return self.KEY_COUNT * [False] - - def _reset_key_stream(self) -> None: - pass - - def reset(self) -> None: - pass - - def set_brightness(self, percent: int) -> None: - pass - - def get_serial_number(self) -> str: - return "MOCK" - - def get_firmware_version(self) -> str: - return "1.0.0" - - def set_key_image(self, key: int, image: str) -> None: - pass - - def _read_control_states(self) -> None: - pass - - def set_touchscreen_image( - self, - image: bytes, - x_pos: int = 0, - y_pos: int = 0, - width: int = 0, - height: int = 0, - ) -> None: - pass - - def set_key_color(self, key: int, r: int, g: int, b: int) -> None: - pass - - def set_screen_image(self, image: bytes) -> None: - pass diff --git a/knoepfe/widgets/__init__.py b/knoepfe/widgets/__init__.py deleted file mode 100644 index 6b6faf6..0000000 --- a/knoepfe/widgets/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from knoepfe.widgets.clock import Clock -from knoepfe.widgets.mic_mute import MicMute -from knoepfe.widgets.text import Text -from knoepfe.widgets.timer import Timer - -__all__ = ["Text", "MicMute", "Clock", "Timer"] diff --git a/knoepfe/widgets/base.py b/knoepfe/widgets/base.py deleted file mode 100644 index 26e8cf0..0000000 --- a/knoepfe/widgets/base.py +++ /dev/null @@ -1,92 +0,0 @@ -from asyncio import Event, Task, get_event_loop, sleep -from typing import Any, Dict - -from schema import Optional, Schema - -from knoepfe.deck import SwitchDeckException -from knoepfe.key import Key -from knoepfe.wakelock import WakeLock - - -class Widget: - def __init__( - self, widget_config: Dict[str, Any], global_config: Dict[str, Any] - ) -> None: - self.config = widget_config - self.global_config = global_config - self.update_requested_event: Event | None = None - self.wake_lock: WakeLock | None = None - self.holds_wait_lock = False - self.needs_update = False - self.periodic_update_task: Task[None] | None = None - self.long_press_task: Task[None] | None = None - - async def activate(self) -> None: # pragma: no cover - pass - - async def deactivate(self) -> None: # pragma: no cover - pass - - async def update(self, key: Key) -> None: # pragma: no cover - pass - - async def pressed(self) -> None: - async def maybe_trigger_longpress() -> None: - await sleep(1.0) - self.long_press_task = None - await self.triggered(True) - - self.long_press_task = get_event_loop().create_task(maybe_trigger_longpress()) - - async def released(self) -> None: - if self.long_press_task: - self.long_press_task.cancel() - self.long_press_task = None - await self.triggered(False) - - if "switch_deck" in self.config: - raise SwitchDeckException(self.config["switch_deck"]) - - async def triggered(self, long_press: bool = False) -> None: - pass - - def request_update(self) -> None: - self.needs_update = True - if self.update_requested_event: - self.update_requested_event.set() - - def request_periodic_update(self, interval: float) -> None: - if not self.periodic_update_task: - loop = get_event_loop() - self.periodic_update_task = loop.create_task( - self.periodic_update_loop(interval) - ) - - def stop_periodic_update(self) -> None: - if self.periodic_update_task: - self.periodic_update_task.cancel() - self.periodic_update_task = None - - async def periodic_update_loop(self, interval: float) -> None: - while True: - await sleep(interval) - self.request_update() - - def acquire_wake_lock(self) -> None: - if self.wake_lock and not self.holds_wait_lock: - self.wake_lock.acquire() - self.holds_wait_lock = True - - def release_wake_lock(self) -> None: - if self.wake_lock and self.holds_wait_lock: - self.wake_lock.release() - self.holds_wait_lock = False - - @classmethod - def get_config_schema(cls) -> Schema: - return cls.add_defaults(Schema({})) - - @classmethod - def add_defaults(cls, schema: Schema) -> Schema: - schema.schema.update({Optional("switch_deck"): str}) - return schema diff --git a/knoepfe/widgets/clock.py b/knoepfe/widgets/clock.py deleted file mode 100644 index 42a2370..0000000 --- a/knoepfe/widgets/clock.py +++ /dev/null @@ -1,37 +0,0 @@ -from datetime import datetime -from typing import Any, Dict - -from schema import Schema - -from knoepfe.key import Key -from knoepfe.widgets.base import Widget - - -class Clock(Widget): - def __init__( - self, widget_config: Dict[str, Any], global_config: Dict[str, Any] - ) -> None: - super().__init__(widget_config, global_config) - self.last_time = "" - - async def activate(self) -> None: - self.request_periodic_update(1.0) - - async def deactivate(self) -> None: - self.stop_periodic_update() - self.last_time = "" - - async def update(self, key: Key) -> None: - time = datetime.now().strftime(self.config["format"]) - if time != self.last_time: - self.last_time = time - - with key.renderer() as renderer: - renderer.text( - time, - ) - - @classmethod - def get_config_schema(cls) -> Schema: - schema = Schema({"format": str}) - return cls.add_defaults(schema) diff --git a/knoepfe/widgets/mic_mute.py b/knoepfe/widgets/mic_mute.py deleted file mode 100644 index 5720a19..0000000 --- a/knoepfe/widgets/mic_mute.py +++ /dev/null @@ -1,76 +0,0 @@ -from asyncio import Task, get_event_loop -from typing import Any, Dict - -from pulsectl import PulseEventTypeEnum -from pulsectl_asyncio import PulseAsync -from schema import Optional, Schema - -from knoepfe.key import Key -from knoepfe.log import error -from knoepfe.widgets.base import Widget - - -class MicMute(Widget): - def __init__( - self, widget_config: Dict[str, Any], global_config: Dict[str, Any] - ) -> None: - super().__init__(widget_config, global_config) - self.pulse: None | PulseAsync = None - self.event_listener: Task[None] | None = None - - async def activate(self) -> None: - if not self.pulse: - self.pulse = PulseAsync("MicMuteControl") - await self.pulse.connect() - if not self.event_listener: - loop = get_event_loop() - self.event_listener = loop.create_task(self.listen()) - - async def deactivate(self) -> None: - if self.event_listener: - self.event_listener.cancel() - self.event_listener = None - if self.pulse: - self.pulse.disconnect() - self.pulse = None - - async def update(self, key: Key) -> None: - source = await self.get_source() - with key.renderer() as renderer: - if source.mute: - renderer.icon("mic_off") - else: - renderer.icon("mic", color="red") - - async def triggered(self, long_press: bool = False) -> None: - assert self.pulse - - source = await self.get_source() - await self.pulse.source_mute(source.index, mute=not source.mute) - - async def get_source(self) -> Any: - assert self.pulse - - source = self.config.get("source") - if not source: - server_info = await self.pulse.server_info() - source = server_info.default_source_name - - sources = await self.pulse.source_list() - for s in sources: - if s.name == source: - return s - - error(f"Source {source} not found") - - async def listen(self) -> None: - assert self.pulse - - async for event in self.pulse.subscribe_events("source"): - if event.t == PulseEventTypeEnum.change: - self.request_update() - - @classmethod - def get_config_schema(cls) -> Schema: - schema = Schema({Optional("source"): str}) - return cls.add_defaults(schema) diff --git a/knoepfe/widgets/obs/__init__.py b/knoepfe/widgets/obs/__init__.py deleted file mode 100644 index 08229e5..0000000 --- a/knoepfe/widgets/obs/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from knoepfe.widgets.obs.connector import config -from knoepfe.widgets.obs.current_scene import CurrentScene -from knoepfe.widgets.obs.recording import Recording -from knoepfe.widgets.obs.streaming import Streaming -from knoepfe.widgets.obs.switch_scene import SwitchScene - -__all__ = ["config", "Recording", "Streaming", "CurrentScene", "SwitchScene"] diff --git a/knoepfe/widgets/obs/base.py b/knoepfe/widgets/obs/base.py deleted file mode 100644 index eed8d81..0000000 --- a/knoepfe/widgets/obs/base.py +++ /dev/null @@ -1,36 +0,0 @@ -from asyncio import Task, get_event_loop -from typing import Any, Dict, List - -from knoepfe.widgets.base import Widget -from knoepfe.widgets.obs.connector import obs - - -class OBSWidget(Widget): - relevant_events: List[str] = [] - - def __init__( - self, widget_config: Dict[str, Any], global_config: Dict[str, Any] - ) -> None: - super().__init__(widget_config, global_config) - self.listening_task: Task[None] | None = None - - async def activate(self) -> None: - await obs.connect(self.global_config.get("knoepfe.widgets.obs.config", {})) - - if not self.listening_task: - self.listening_task = get_event_loop().create_task(self.listener()) - - async def deactivate(self) -> None: - if self.listening_task: - self.listening_task.cancel() - self.listening_task = None - - async def listener(self) -> None: - async for event in obs.listen(): - if event == "ConnectionEstablished": - self.acquire_wake_lock() - elif event == "ConnectionLost": - self.release_wake_lock() - - if event in self.relevant_events: - self.request_update() diff --git a/knoepfe/widgets/obs/current_scene.py b/knoepfe/widgets/obs/current_scene.py deleted file mode 100644 index 148ed01..0000000 --- a/knoepfe/widgets/obs/current_scene.py +++ /dev/null @@ -1,25 +0,0 @@ -from schema import Schema - -from knoepfe.key import Key -from knoepfe.widgets.obs.base import OBSWidget -from knoepfe.widgets.obs.connector import obs - - -class CurrentScene(OBSWidget): - relevant_events = [ - "ConnectionEstablished", - "ConnectionLost", - "CurrentProgramSceneChanged", - ] - - async def update(self, key: Key) -> None: - with key.renderer() as renderer: - if obs.connected: - renderer.icon_and_text("panorama", obs.current_scene or "[none]") - else: - renderer.icon_and_text("panorama", "[none]", color="#202020") - - @classmethod - def get_config_schema(cls) -> Schema: - schema = Schema({}) - return cls.add_defaults(schema) diff --git a/knoepfe/widgets/obs/recording.py b/knoepfe/widgets/obs/recording.py deleted file mode 100644 index b842c83..0000000 --- a/knoepfe/widgets/obs/recording.py +++ /dev/null @@ -1,67 +0,0 @@ -from asyncio import sleep -from typing import Any, Dict - -from schema import Schema - -from knoepfe.key import Key -from knoepfe.widgets.obs.base import OBSWidget -from knoepfe.widgets.obs.connector import obs - - -class Recording(OBSWidget): - relevant_events = [ - "ConnectionEstablished", - "ConnectionLost", - "RecordStateChanged", - ] - - def __init__( - self, widget_config: Dict[str, Any], global_config: Dict[str, Any] - ) -> None: - super().__init__(widget_config, global_config) - self.recording = False - self.show_help = False - self.show_loading = False - - async def update(self, key: Key) -> None: - if obs.recording != self.recording: - if obs.recording: - self.request_periodic_update(1.0) - else: - self.stop_periodic_update() - self.recording = obs.recording - - with key.renderer() as renderer: - if self.show_loading: - self.show_loading = False - renderer.icon("more_horiz") - elif not obs.connected: - renderer.icon("videocam_off", color="#202020") - elif self.show_help: - renderer.text("long press\nto toggle", size=16) - elif obs.recording: - timecode = (await obs.get_recording_timecode() or "").rsplit(".", 1)[0] - renderer.icon_and_text("videocam", timecode, color="red") - else: - renderer.icon("videocam_off") - - async def triggered(self, long_press: bool = False) -> None: - if long_press: - if obs.recording: - await obs.stop_recording() - else: - await obs.start_recording() - - self.show_loading = True - self.request_update() - else: - self.show_help = True - self.request_update() - await sleep(1.0) - self.show_help = False - self.request_update() - - @classmethod - def get_config_schema(cls) -> Schema: - schema = Schema({}) - return cls.add_defaults(schema) diff --git a/knoepfe/widgets/obs/streaming.py b/knoepfe/widgets/obs/streaming.py deleted file mode 100644 index 325c0ae..0000000 --- a/knoepfe/widgets/obs/streaming.py +++ /dev/null @@ -1,67 +0,0 @@ -from asyncio import sleep -from typing import Any, Dict - -from schema import Schema - -from knoepfe.key import Key -from knoepfe.widgets.obs.base import OBSWidget -from knoepfe.widgets.obs.connector import obs - - -class Streaming(OBSWidget): - relevant_events = [ - "ConnectionEstablished", - "ConnectionLost", - "StreamStateChanged", - ] - - def __init__( - self, widget_config: Dict[str, Any], global_config: Dict[str, Any] - ) -> None: - super().__init__(widget_config, global_config) - self.streaming = False - self.show_help = False - self.show_loading = False - - async def update(self, key: Key) -> None: - if obs.streaming != self.streaming: - if obs.streaming: - self.request_periodic_update(1.0) - else: - self.stop_periodic_update() - self.streaming = obs.streaming - - with key.renderer() as renderer: - if self.show_loading: - self.show_loading = False - renderer.icon("more_horiz") - elif not obs.connected: - renderer.icon("stop_screen_share", color="#202020") - elif self.show_help: - renderer.text("long press\nto toggle", size=16) - elif obs.streaming: - timecode = (await obs.get_streaming_timecode() or "").rsplit(".", 1)[0] - renderer.icon_and_text("screen_share", timecode, color="red") - else: - renderer.icon("stop_screen_share") - - async def triggered(self, long_press: bool = False) -> None: - if long_press: - if obs.streaming: - await obs.stop_streaming() - else: - await obs.start_streaming() - - self.show_loading = True - self.request_update() - else: - self.show_help = True - self.request_update() - await sleep(1.0) - self.show_help = False - self.request_update() - - @classmethod - def get_config_schema(cls) -> Schema: - schema = Schema({}) - return cls.add_defaults(schema) diff --git a/knoepfe/widgets/obs/switch_scene.py b/knoepfe/widgets/obs/switch_scene.py deleted file mode 100644 index acc293d..0000000 --- a/knoepfe/widgets/obs/switch_scene.py +++ /dev/null @@ -1,32 +0,0 @@ -from schema import Schema - -from knoepfe.key import Key -from knoepfe.widgets.obs.base import OBSWidget -from knoepfe.widgets.obs.connector import obs - - -class SwitchScene(OBSWidget): - relevant_events = [ - "ConnectionEstablished", - "ConnectionLost", - "SwitchScenes", - ] - - async def update(self, key: Key) -> None: - color = None - if not obs.connected: - color = "#202020" - elif obs.current_scene == self.config["scene"]: - color = "red" - - with key.renderer() as renderer: - renderer.icon_and_text("panorama", self.config["scene"], color=color) - - async def triggered(self, long_press: bool = False) -> None: - if obs.connected: - await obs.set_scene(self.config["scene"]) - - @classmethod - def get_config_schema(cls) -> Schema: - schema = Schema({"scene": str}) - return cls.add_defaults(schema) diff --git a/knoepfe/widgets/text.py b/knoepfe/widgets/text.py deleted file mode 100644 index 442f8bf..0000000 --- a/knoepfe/widgets/text.py +++ /dev/null @@ -1,15 +0,0 @@ -from schema import Schema - -from knoepfe.key import Key -from knoepfe.widgets.base import Widget - - -class Text(Widget): - async def update(self, key: Key) -> None: - with key.renderer() as renderer: - renderer.text(self.config["text"]) - - @classmethod - def get_config_schema(cls) -> Schema: - schema = Schema({"text": str}) - return cls.add_defaults(schema) diff --git a/knoepfe/widgets/timer.py b/knoepfe/widgets/timer.py deleted file mode 100644 index 2dc77bf..0000000 --- a/knoepfe/widgets/timer.py +++ /dev/null @@ -1,61 +0,0 @@ -import time -from datetime import timedelta -from typing import Any, Dict - -from schema import Schema - -from knoepfe.key import Key -from knoepfe.widgets.base import Widget - - -class Timer(Widget): - def __init__( - self, widget_config: Dict[str, Any], global_config: Dict[str, Any] - ) -> None: - super().__init__(widget_config, global_config) - self.start: float | None = None - self.stop: float | None = None - - async def deactivate(self) -> None: - self.stop_periodic_update() - self.start = None - self.stop = None - self.release_wake_lock() - - async def update(self, key: Key) -> None: - with key.renderer() as renderer: - if self.start and not self.stop: - renderer.text( - f"{timedelta(seconds=time.monotonic() - self.start)}".rsplit( - ".", 1 - )[0], - ) - elif self.start and self.stop: - renderer.text( - f"{timedelta(seconds=self.stop - self.start)}".rsplit(".", 1)[0], - color="red", - ) - else: - renderer.icon("timer") - - async def triggered(self, long_press: bool = False) -> None: - if not self.start: - self.start = time.monotonic() - self.request_periodic_update(1.0) - self.request_update() - self.acquire_wake_lock() - elif self.start and not self.stop: - self.stop = time.monotonic() - self.stop_periodic_update() - self.request_update() - self.release_wake_lock() - else: - self.stop_periodic_update() - self.start = None - self.stop = None - self.request_update() - - @classmethod - def get_config_schema(cls) -> Schema: - schema = Schema({}) - return cls.add_defaults(schema) diff --git a/plugins/audio/README.md b/plugins/audio/README.md new file mode 100644 index 0000000..f078ece --- /dev/null +++ b/plugins/audio/README.md @@ -0,0 +1,151 @@ +# Knoepfe Audio Plugin + +Audio control widgets for [knoepfe](https://github.com/lnqs/knoepfe) using PulseAudio. + +## Installation + +```bash +# Install with knoepfe +pip install knoepfe[audio] + +# Or install separately +pip install knoepfe-audio-plugin +``` + +## Plugin Configuration + +The audio plugin supports global configuration that applies to all widgets: + +```toml +# ============================================================================ +# Audio Plugin Configuration +# ============================================================================ + +[plugins.audio] +# Enable/disable the audio plugin +enabled = true + +# Default PulseAudio source name for all audio widgets +# Individual widgets can override this with their own 'source' parameter +# Find available sources with: pactl list sources short +default_source = "alsa_input.usb-Blue_Microphones_Yeti_Stereo_Microphone" +``` + +**Parameters:** + +- `enabled` (optional): Enable the audio plugin. Default: `true` +- `default_source` (optional): Default PulseAudio source name to use for all audio widgets. Individual widgets can override this with their own `source` parameter. + +## Widgets + +### MicMute + +Controls microphone mute/unmute functionality via PulseAudio. + +**Configuration:** + +```toml +# ---------------------------------------------------------------------------- +# Example 1: Use system default microphone +# ---------------------------------------------------------------------------- +[[deck.main]] +type = "MicMute" + +# ---------------------------------------------------------------------------- +# Example 2: Use plugin's default_source (if configured) +# ---------------------------------------------------------------------------- +[plugins.audio] +default_source = "alsa_input.usb-Blue_Microphones_Yeti_Stereo_Microphone" + +[[deck.main]] +type = "MicMute" + +# ---------------------------------------------------------------------------- +# Example 3: Override with widget-specific source +# ---------------------------------------------------------------------------- +[[deck.main]] +type = "MicMute" +source = "alsa_input.pci-0000_00_1f.3.analog-stereo" + +# ---------------------------------------------------------------------------- +# Example 4: Customize icons and colors +# ---------------------------------------------------------------------------- +[[deck.main]] +type = "MicMute" +muted_icon = "🔇" +unmuted_icon = "🎤" +muted_color = "gray" +unmuted_color = "green" + +# ---------------------------------------------------------------------------- +# Example 5: Use Nerd Font codepoints +# ---------------------------------------------------------------------------- +[[deck.main]] +type = "MicMute" +muted_icon = "\ue02b" +unmuted_icon = "\ue029" +muted_color = "blue" +unmuted_color = "red" +``` + +**Parameters:** + +- `source` (optional): PulseAudio source name for this specific widget. If not specified, falls back to the plugin's `default_source`, or the system default source. +- `muted_icon` (optional): Icon to display when muted. Can be a unicode character (e.g., `'🔇'`) or codepoint (e.g., `'\uf036d'`). Default: `'\uf036d'` (nf-md-microphone_off) +- `unmuted_icon` (optional): Icon to display when unmuted. Can be a unicode character (e.g., `'🎤'`) or codepoint (e.g., `'\uf036c'`). Default: `'\uf036c'` (nf-md-microphone) +- `muted_color` (optional): Icon color when muted. Default: `'white'` +- `unmuted_color` (optional): Icon color when unmuted. Default: `'red'` + +**Source Selection Priority:** + +1. Widget's `source` parameter (highest priority) +2. Plugin's `default_source` configuration +3. System default source from PulseAudio (lowest priority) + +**Features:** + +- Shows microphone icon with customizable appearance +- Default: red icon when unmuted, white icon when muted +- Click to toggle mute/unmute +- Automatically updates when mute state changes externally +- Works with any PulseAudio-compatible microphone +- Fully customizable icons (unicode or codepoints) and colors + +**Finding Your Microphone Source:** + +```bash +# List available sources +pactl list sources short + +# Example output: +# 0 alsa_input.pci-0000_00_1f.3.analog-stereo ... +# 1 alsa_input.usb-Blue_Microphones_Yeti_Stereo_Microphone ... +``` + +## Requirements + +- PulseAudio audio system +- `pulsectl-asyncio>=1.2.2` (installed automatically) + +## Troubleshooting + +**Widget shows as disconnected:** + +- Ensure PulseAudio is running: `pulseaudio --check` +- Check if the specified source exists: `pactl list sources short` + +**Permission issues:** + +- Ensure your user is in the `audio` group: `groups $USER` +- Add to audio group if needed: `sudo usermod -a -G audio $USER` + +## Development + +```bash +# Install in development mode +uv pip install -e plugins/audio + +# Run tests +pytest plugins/audio/tests/ +``` + diff --git a/plugins/audio/pyproject.toml b/plugins/audio/pyproject.toml new file mode 100644 index 0000000..bb691cd --- /dev/null +++ b/plugins/audio/pyproject.toml @@ -0,0 +1,84 @@ +[project] +name = "knoepfe-audio-plugin" +dynamic = ["version"] +description = "Audio control widgets for knoepfe" +authors = [ + { name = "Simon Hayessen", email = "simon@lnqs.io" }, + { name = "Simon Brakhane", email = "simon@brakhane.net" }, +] +requires-python = ">=3.11" +readme = "README.md" +license = "GPL-3.0-or-later" +keywords = ["streamdeck", "audio", "pulseaudio", "knoepfe", "plugin"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia", + "Topic :: System :: Hardware", +] +dependencies = ["knoepfe", "pulsectl>=24.11.0", "pulsectl-asyncio>=1.2.2"] + +[project.urls] +Homepage = "https://github.com/lnqs/knoepfe" +Repository = "https://github.com/lnqs/knoepfe" +Issues = "https://github.com/lnqs/knoepfe/issues" + +# Audio plugin registration via entry points +[project.entry-points."knoepfe.plugins"] +audio = "knoepfe_audio_plugin:AudioPluginDescriptor" + +[tool.uv.sources] +knoepfe = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "src/knoepfe_audio_plugin/__init__.py" + +[dependency-groups] +dev = ["pytest>=8.4.2", "pytest-asyncio>=0.24.0", "pytest-cov>=5.0.0"] + +[tool.hatch.build.targets.wheel] +packages = ["src/knoepfe_audio_plugin"] + +[tool.hatch.build.targets.sdist] +include = [ + "src/", + "tests/", + "README.md", + "LICENSE.md", + "CHANGELOG.md", + "pyproject.toml", +] + +[tool.ruff] +line-length = 120 +include = ["src/**/*.py", "tests/**/*.py"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.format] +docstring-code-format = true + +[tool.pyright] +include = ["src", "tests"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.coverage.report] +fail_under = 100 +exclude_lines = ["if __name__ == .__main__.:", "if sys.platform"] +exclude_also = [ + "no cover: start(?s:.)*?no cover: stop", + "\\A(?s:.*# pragma: exclude file.*)\\Z", +] diff --git a/plugins/audio/src/knoepfe_audio_plugin/__init__.py b/plugins/audio/src/knoepfe_audio_plugin/__init__.py new file mode 100644 index 0000000..651dfb0 --- /dev/null +++ b/plugins/audio/src/knoepfe_audio_plugin/__init__.py @@ -0,0 +1,21 @@ +"""Audio plugin for knoepfe.""" + +from typing import Type + +from knoepfe.plugins import PluginDescriptor +from knoepfe.widgets import Widget + +from .config import AudioPluginConfig +from .plugin import AudioPlugin +from .widgets import MicMute + +__version__ = "0.1.0" + + +class AudioPluginDescriptor(PluginDescriptor[AudioPluginConfig, AudioPlugin]): + """Audio control widgets for knoepfe.""" + + @classmethod + def widgets(cls) -> list[Type[Widget]]: + """Widgets provided by this plugin.""" + return [MicMute] diff --git a/plugins/audio/src/knoepfe_audio_plugin/config.py b/plugins/audio/src/knoepfe_audio_plugin/config.py new file mode 100644 index 0000000..d503d4a --- /dev/null +++ b/plugins/audio/src/knoepfe_audio_plugin/config.py @@ -0,0 +1,10 @@ +"""Audio control plugin for knoepfe.""" + +from knoepfe.config.plugin import PluginConfig +from pydantic import Field + + +class AudioPluginConfig(PluginConfig): + """Configuration for audio plugin.""" + + default_source: str | None = Field(default=None, description="Default audio source name") diff --git a/plugins/audio/src/knoepfe_audio_plugin/connector.py b/plugins/audio/src/knoepfe_audio_plugin/connector.py new file mode 100644 index 0000000..1ee6bdd --- /dev/null +++ b/plugins/audio/src/knoepfe_audio_plugin/connector.py @@ -0,0 +1,186 @@ +"""PulseAudio connector for managing shared connection across audio widgets.""" + +import logging +from asyncio import Condition +from typing import Any, AsyncIterator + +from knoepfe.utils.task_manager import TaskManager +from pulsectl import PulseEventTypeEnum +from pulsectl_asyncio import PulseAsync + +logger = logging.getLogger(__name__) + +# Task name constants +TASK_EVENT_WATCHER = "pulse_event_watcher" + + +class PulseAudioConnector: + """Manages a shared PulseAudio connection for all audio widgets. + + This connector maintains a single connection to PulseAudio and distributes + events to all listening widgets. It handles automatic reconnection and + provides a clean interface for audio operations. + + Attributes: + tasks: TaskManager for managing background tasks. + pulse: The PulseAsync connection instance. + connected: Whether currently connected to PulseAudio. + last_event: The most recent PulseAudio event received. + event_condition: Condition variable for event notification. + """ + + def __init__(self, tasks: TaskManager) -> None: + """Initialize the PulseAudio connector. + + Args: + tasks: TaskManager from plugin instance for managing background tasks. + """ + self.tasks = tasks + self.pulse: PulseAsync | None = None + self._connected = False + + self.last_event: Any = None + self.event_condition = Condition() + + async def connect(self) -> None: + """Connect to PulseAudio if not already connected. + + This method is idempotent - calling it multiple times will only + create one connection. Starts the event watcher task. + """ + if self.tasks.is_running(TASK_EVENT_WATCHER): + return + + if not self.pulse: + self.pulse = PulseAsync("KnoepfeAudioPlugin") + try: + await self.pulse.connect() + self._connected = True + logger.debug("Connected to PulseAudio") + await self._handle_event({"type": "ConnectionEstablished"}) + except Exception as e: + logger.error(f"Failed to connect to PulseAudio: {e}") + self._connected = False + self.pulse = None + return + + self.tasks.start_task(TASK_EVENT_WATCHER, self._watch_events()) + + async def disconnect(self) -> None: + """Disconnect from PulseAudio and clean up resources.""" + self.tasks.stop_task(TASK_EVENT_WATCHER) + + if self.pulse: + self.pulse.disconnect() + self.pulse = None + self._connected = False + logger.debug("Disconnected from PulseAudio") + await self._handle_event({"type": "ConnectionLost"}) + + @property + def connected(self) -> bool: + """Check if currently connected to PulseAudio.""" + return self._connected and self.pulse is not None + + async def listen(self) -> AsyncIterator[dict[str, Any]]: + """Listen for PulseAudio events. + + Yields: + Event dictionaries containing event type and data. + """ + while True: + async with self.event_condition: + await self.event_condition.wait() + assert self.last_event + event = self.last_event + yield event + + async def get_source(self, source_name: str) -> Any: + """Get a PulseAudio source by name. + + Args: + source_name: The name of the source to retrieve. + + Returns: + The PulseAudio source object, or None if not found. + + Raises: + RuntimeError: If not connected to PulseAudio. + """ + if not self.pulse: + raise RuntimeError("Not connected to PulseAudio") + + sources = await self.pulse.source_list() + for source in sources: + if source.name == source_name: + return source + + logger.error(f"Source {source_name} not found") + return None + + async def get_default_source(self) -> Any: + """Get the system default PulseAudio source. + + Returns: + The default source object. + + Raises: + RuntimeError: If not connected to PulseAudio. + """ + if not self.pulse: + raise RuntimeError("Not connected to PulseAudio") + + server_info = await self.pulse.server_info() + default_source_name = server_info.default_source_name # pyright: ignore[reportAttributeAccessIssue] + + sources = await self.pulse.source_list() + for source in sources: + if source.name == default_source_name: + return source + + logger.error(f"Default source {default_source_name} not found") + return None + + async def source_mute(self, index: int, mute: bool) -> None: + """Set the mute state of a source. + + Args: + index: The index of the source to mute/unmute. + mute: True to mute, False to unmute. + + Raises: + RuntimeError: If not connected to PulseAudio. + """ + if not self.pulse: + raise RuntimeError("Not connected to PulseAudio") + + await self.pulse.source_mute(index, mute=mute) + + async def _watch_events(self) -> None: + """Watch for PulseAudio events and distribute them to listeners. + + This runs as a background task and monitors the PulseAudio event stream. + """ + if not self.pulse: + return + + try: + async for event in self.pulse.subscribe_events("source"): + if event.t == PulseEventTypeEnum.change: # pyright: ignore[reportAttributeAccessIssue] + await self._handle_event({"type": "SourceChanged", "data": event}) + except Exception as e: + logger.error(f"Error in PulseAudio event watcher: {e}") + self._connected = False + await self._handle_event({"type": "ConnectionLost"}) + + async def _handle_event(self, event: dict[str, Any]) -> None: + """Handle a PulseAudio event and notify all listeners. + + Args: + event: The event dictionary to handle. + """ + logger.debug(f"PulseAudio event received: {event}") + + async with self.event_condition: + self.last_event = event + self.event_condition.notify_all() diff --git a/plugins/audio/src/knoepfe_audio_plugin/plugin.py b/plugins/audio/src/knoepfe_audio_plugin/plugin.py new file mode 100644 index 0000000..4b351ab --- /dev/null +++ b/plugins/audio/src/knoepfe_audio_plugin/plugin.py @@ -0,0 +1,67 @@ +"""Audio plugin instance for knoepfe.""" + +import logging +from typing import TYPE_CHECKING + +from knoepfe.plugins import Plugin + +from .config import AudioPluginConfig +from .connector import PulseAudioConnector + +if TYPE_CHECKING: + from knoepfe.widgets.widget import Widget + +logger = logging.getLogger(__name__) + + +class AudioPlugin(Plugin): + """Audio plugin instance for knoepfe. + + Provides shared state and resources for all audio widgets, including + a single PulseAudio connection that is shared across all widgets. + + The PulseAudio connection is lazily initialized when the first widget + is activated and remains connected for the lifetime of the plugin. + + Attributes: + default_source: Default PulseAudio source name from plugin config. + pulse: Shared PulseAudio connector instance. + mute_states: Dictionary tracking mute states of sources. + """ + + def __init__(self, config: AudioPluginConfig): + """Initialize the audio plugin instance. + + Args: + config: Plugin configuration containing default_source and other settings. + """ + super().__init__(config) + + # Plugin-specific state + self.default_source = config.default_source + self.pulse = PulseAudioConnector(self.tasks) + self.mute_states: dict[str, bool] = {} + + async def on_widget_activate(self, widget: "Widget") -> None: + """Connect to PulseAudio when first widget activates. + + This is called BEFORE the widget's activate() method. + The connection remains active for the lifetime of the plugin. + """ + await super().on_widget_activate(widget) + + # Connect to PulseAudio if not already connected + if not self.pulse.connected: + logger.info("Audio plugin: First widget activated, connecting to PulseAudio...") + await self.pulse.connect() + else: + logger.debug(f"Audio plugin: Widget {widget.name} activated (PulseAudio already connected)") + + def sync_mute_state(self, source: str, muted: bool) -> None: + """Synchronize mute state across all widgets. + + Args: + source: The source name whose mute state changed. + muted: The new mute state. + """ + self.mute_states[source] = muted diff --git a/plugins/audio/src/knoepfe_audio_plugin/widgets/__init__.py b/plugins/audio/src/knoepfe_audio_plugin/widgets/__init__.py new file mode 100644 index 0000000..bd3ded3 --- /dev/null +++ b/plugins/audio/src/knoepfe_audio_plugin/widgets/__init__.py @@ -0,0 +1,6 @@ +"""Audio widgets for knoepfe.""" + +from .audio_widget import AudioWidget +from .mic_mute import MicMute + +__all__ = ["AudioWidget", "MicMute"] diff --git a/plugins/audio/src/knoepfe_audio_plugin/widgets/audio_widget.py b/plugins/audio/src/knoepfe_audio_plugin/widgets/audio_widget.py new file mode 100644 index 0000000..8fec297 --- /dev/null +++ b/plugins/audio/src/knoepfe_audio_plugin/widgets/audio_widget.py @@ -0,0 +1,69 @@ +"""Base class for audio widgets with shared PulseAudio connection.""" + +from typing import Any, Generic, TypeVar + +from knoepfe.config.widget import WidgetConfig +from knoepfe.widgets import Widget + +from ..plugin import AudioPlugin + +TConfig = TypeVar("TConfig", bound=WidgetConfig) + +# Task name constants +TASK_EVENT_LISTENER = "event_listener" + + +class AudioWidget(Widget[TConfig, AudioPlugin], Generic[TConfig]): + """Base class for audio widgets with shared PulseAudio connection. + + Subclasses should define `relevant_events` with event types they care about. + """ + + relevant_events: list[str] = [] + + @property + def pulse(self): + """Get the shared PulseAudio connector from plugin.""" + return self.plugin.pulse + + async def activate(self) -> None: + """Start event listener. + + The PulseAudio connection is managed by the plugin instance and is + established when the first widget activates. + """ + self.tasks.start_task(TASK_EVENT_LISTENER, self.listener()) + + async def listener(self) -> None: + """Listen for PulseAudio events and request updates when relevant.""" + async for event in self.pulse.listen(): + event_type = event.get("type") + + if event_type == "ConnectionEstablished": + self.acquire_wake_lock() + elif event_type == "ConnectionLost": + self.release_wake_lock() + + if event_type in self.relevant_events: + self.request_update() + + async def get_source(self, source_name: str | None = None) -> Any: + """Get a PulseAudio source with fallback logic. + + Priority: parameter > widget config > plugin config > system default + """ + # Use explicit parameter if provided + if not source_name: + # Try widget config source + if hasattr(self.config, "source"): + source_name = self.config.source # type: ignore + + # Fall back to plugin default + if not source_name: + source_name = self.plugin.default_source + + # Use system default if still no source specified + if not source_name: + return await self.pulse.get_default_source() + + return await self.pulse.get_source(source_name) diff --git a/plugins/audio/src/knoepfe_audio_plugin/widgets/mic_mute.py b/plugins/audio/src/knoepfe_audio_plugin/widgets/mic_mute.py new file mode 100644 index 0000000..36c576e --- /dev/null +++ b/plugins/audio/src/knoepfe_audio_plugin/widgets/mic_mute.py @@ -0,0 +1,61 @@ +"""Microphone mute control widget for PulseAudio.""" + +import logging + +from knoepfe.config.widget import WidgetConfig +from knoepfe.rendering import Renderer +from knoepfe.widgets.widget import UpdateResult +from pydantic import Field + +from .audio_widget import AudioWidget + +logger = logging.getLogger(__name__) + + +class MicMuteConfig(WidgetConfig): + """Configuration for MicMute widget.""" + + source: str | None = Field(default=None, description="Audio source name to control") + muted_icon: str = Field( + default="󰍭", # nf-md-microphone_off + description="Icon to display when muted (unicode character or codepoint)", + ) + unmuted_icon: str = Field( + default="󰍬", # nf-md-microphone + description="Icon to display when unmuted (unicode character or codepoint)", + ) + muted_color: str | None = Field(default=None, description="Icon color when muted (defaults to base color)") + unmuted_color: str = Field(default="red", description="Icon color when unmuted") + + +class MicMute(AudioWidget[MicMuteConfig]): + """Toggle microphone mute status. + + Displays a microphone icon (configurable color when unmuted, configurable color when muted) + and toggles mute state on button press. Updates automatically when mute state changes. + """ + + name = "MicMute" + relevant_events = ["SourceChanged"] + + async def update(self, renderer: Renderer) -> UpdateResult: + """Update the key display based on current mute state.""" + source = await self.get_source() + if not source: + return UpdateResult.UNCHANGED + + renderer.clear() + if source.mute: + renderer.icon(self.config.muted_icon, color=self.config.muted_color or self.config.color) + else: + renderer.icon(self.config.unmuted_icon, color=self.config.unmuted_color) + + return UpdateResult.UPDATED + + async def triggered(self, long_press: bool = False) -> None: + """Toggle microphone mute state.""" + source = await self.get_source() + if not source: + return + + await self.pulse.source_mute(source.index, mute=not source.mute) diff --git a/plugins/audio/tests/__init__.py b/plugins/audio/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/audio/tests/conftest.py b/plugins/audio/tests/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/audio/tests/test_mic_mute.py b/plugins/audio/tests/test_mic_mute.py new file mode 100644 index 0000000..e8de03a --- /dev/null +++ b/plugins/audio/tests/test_mic_mute.py @@ -0,0 +1,209 @@ +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from pytest import fixture + +from knoepfe_audio_plugin.config import AudioPluginConfig +from knoepfe_audio_plugin.plugin import AudioPlugin +from knoepfe_audio_plugin.widgets.audio_widget import TASK_EVENT_LISTENER +from knoepfe_audio_plugin.widgets.mic_mute import MicMute, MicMuteConfig + + +@fixture +def mock_plugin(): + return AudioPlugin(AudioPluginConfig()) + + +@fixture +def mic_mute_widget(mock_plugin): + widget = MicMute(MicMuteConfig(), mock_plugin) + + # Mock the TaskManager to avoid pytest warnings about unawaited tasks + def mock_start_task(name, coro): + # Close the coroutine to prevent "never awaited" warnings + coro.close() + return Mock() + + widget.tasks = Mock() + widget.tasks.start_task = Mock(side_effect=mock_start_task) + widget.tasks.stop_task = Mock() + widget.tasks.is_running = Mock(return_value=False) + widget.tasks.cleanup = AsyncMock() + return widget + + +@fixture +def mock_source(): + source = Mock() + source.mute = False + source.index = 1 + source.name = "test_source" + return source + + +def test_mic_mute_init(mock_plugin): + """Test MicMute widget initialization.""" + widget = MicMute(MicMuteConfig(), mock_plugin) + assert widget.pulse == mock_plugin.pulse + assert widget.tasks is not None + + +async def test_mic_mute_activate(mic_mute_widget): + """Test widget activation starts listener. + + Note: PulseAudio connection is managed by the plugin lifecycle hooks, not by individual widget activation. + """ + await mic_mute_widget.activate() + + mic_mute_widget.tasks.start_task.assert_called_once() + # Verify the task name is correct + call_args = mic_mute_widget.tasks.start_task.call_args + assert call_args[0][0] == TASK_EVENT_LISTENER + + +async def test_mic_mute_deactivate(mic_mute_widget): + """Test widget deactivation - tasks are cleaned up by Deck automatically.""" + # Simulate that a task is running + mic_mute_widget.tasks.is_running.return_value = True + + # Deactivate should not stop tasks (Deck handles cleanup) + await mic_mute_widget.deactivate() + + # Verify stop_task was NOT called (cleanup is handled by Deck) + mic_mute_widget.tasks.stop_task.assert_not_called() + + +async def test_mic_mute_update_muted(mic_mute_widget, mock_source): + """Test update renders muted icon when source is muted.""" + mock_source.mute = True + renderer = MagicMock() + + with patch.object(mic_mute_widget, "get_source", AsyncMock(return_value=mock_source)): + await mic_mute_widget.update(renderer) + + renderer.clear.assert_called_once() + renderer.icon.assert_called_with( + "󰍭", # nf-md-microphone_off + color="white", + ) + + +async def test_mic_mute_update_unmuted(mic_mute_widget, mock_source): + """Test update renders unmuted icon when source is unmuted.""" + mock_source.mute = False + renderer = MagicMock() + + with patch.object(mic_mute_widget, "get_source", AsyncMock(return_value=mock_source)): + await mic_mute_widget.update(renderer) + + renderer.clear.assert_called_once() + renderer.icon.assert_called_with( + "󰍬", # nf-md-microphone + color="red", + ) + + +async def test_mic_mute_update_no_source(mic_mute_widget): + """Test update handles missing source gracefully.""" + renderer = MagicMock() + + with patch.object(mic_mute_widget, "get_source", AsyncMock(return_value=None)): + await mic_mute_widget.update(renderer) + + # Should return early without rendering + renderer.clear.assert_not_called() + renderer.icon.assert_not_called() + + +async def test_mic_mute_triggered(mic_mute_widget, mock_source): + """Test triggered toggles mute state from unmuted to muted.""" + mock_source.mute = False + + with patch.object(mic_mute_widget, "get_source", AsyncMock(return_value=mock_source)): + with patch.object(mic_mute_widget.pulse, "source_mute", AsyncMock()) as mock_mute: + await mic_mute_widget.triggered() + + mock_mute.assert_called_once_with(mock_source.index, mute=True) + + +async def test_mic_mute_triggered_unmute(mic_mute_widget, mock_source): + """Test triggered toggles mute state from muted to unmuted.""" + mock_source.mute = True + + with patch.object(mic_mute_widget, "get_source", AsyncMock(return_value=mock_source)): + with patch.object(mic_mute_widget.pulse, "source_mute", AsyncMock()) as mock_mute: + await mic_mute_widget.triggered() + + mock_mute.assert_called_once_with(mock_source.index, mute=False) + + +async def test_mic_mute_triggered_no_source(mic_mute_widget): + """Test triggered handles missing source gracefully.""" + with patch.object(mic_mute_widget, "get_source", AsyncMock(return_value=None)): + with patch.object(mic_mute_widget.pulse, "source_mute", AsyncMock()) as mock_mute: + await mic_mute_widget.triggered() + + # Should return early without calling source_mute + mock_mute.assert_not_called() + + +def test_mic_mute_config(): + """Test that MicMuteConfig validates correctly.""" + # Test with defaults + config = MicMuteConfig() + assert config.source is None + assert config.muted_icon == "󰍭" # nf-md-microphone_off + assert config.unmuted_icon == "󰍬" # nf-md-microphone + assert config.muted_color is None # Defaults to base color + assert config.color == "white" # Base color + assert config.unmuted_color == "red" + + # Test with custom values + config = MicMuteConfig( + source="test_source", + muted_icon="🔇", + unmuted_icon="🎤", + color="blue", # Base color + muted_color="gray", + unmuted_color="green", + ) + assert config.source == "test_source" + assert config.muted_icon == "🔇" + assert config.unmuted_icon == "🎤" + assert config.color == "blue" + assert config.muted_color == "gray" + assert config.unmuted_color == "green" + + +async def test_get_source_widget_config(mic_mute_widget): + """Test get_source uses widget config source when specified.""" + widget = MicMute(MicMuteConfig(source="widget_source"), mic_mute_widget.plugin) + mock_source = Mock() + + with patch.object(widget.pulse, "get_source", AsyncMock(return_value=mock_source)) as mock_get: + result = await widget.get_source() + + mock_get.assert_called_once_with("widget_source") + assert result == mock_source + + +async def test_get_source_plugin_config(mic_mute_widget): + """Test get_source falls back to plugin config default_source.""" + mic_mute_widget.plugin.default_source = "plugin_source" + mock_source = Mock() + + with patch.object(mic_mute_widget.pulse, "get_source", AsyncMock(return_value=mock_source)) as mock_get: + result = await mic_mute_widget.get_source() + + mock_get.assert_called_once_with("plugin_source") + assert result == mock_source + + +async def test_get_source_system_default(mic_mute_widget): + """Test get_source falls back to system default when no config specified.""" + mock_source = Mock() + + with patch.object(mic_mute_widget.pulse, "get_default_source", AsyncMock(return_value=mock_source)) as mock_get: + result = await mic_mute_widget.get_source() + + mock_get.assert_called_once() + assert result == mock_source diff --git a/plugins/example/README.md b/plugins/example/README.md new file mode 100644 index 0000000..627da89 --- /dev/null +++ b/plugins/example/README.md @@ -0,0 +1,276 @@ +# Knoepfe Example Plugin + +A minimal example plugin demonstrating how to create custom widgets for [knoepfe](https://github.com/lnqs/knoepfe). + +This plugin serves as a template and learning resource for developers who want to create their own knoepfe widgets. + +## Installation + +This example plugin is not published to PyPI. To install it for development: + +```bash +# Install in development mode from the knoepfe monorepo +uv pip install -e plugins/example + +# Or if you're working outside the monorepo +pip install -e /path/to/knoepfe/plugins/example +``` + +## Widget: ExampleWidget + +A simple interactive widget that demonstrates the basic structure and functionality of a knoepfe widget. + +### Configuration + +```toml +# Basic usage with defaults +[[deck.main]] +type = "ExampleWidget" + +# Customized configuration +[[deck.main]] +type = "ExampleWidget" +message = "Hello World" +``` + +### Parameters + +- `message` (optional, default: 'Example'): The text message to display on the widget + +### Features + +- **Interactive Display**: Shows a customizable message with click counter +- **Click Tracking**: Counts and displays the number of times the widget has been clicked +- **State Management**: Demonstrates how to maintain widget state between updates + +### Behavior + +1. **Initial State**: Shows the configured message with "Click me!" text +2. **After Clicking**: Displays click count + +## Development Guide + +This example demonstrates the essential components of a knoepfe widget: + +### 1. Plugin Descriptor + +Define a plugin descriptor that declares your plugin's configuration and widgets: + +```python +from knoepfe.plugins import PluginDescriptor + +class ExamplePluginDescriptor(PluginDescriptor[ExamplePluginConfig, ExamplePlugin]): + """Brief description of your plugin (used as plugin description).""" + + @classmethod + def widgets(cls) -> list[Type[Widget]]: + return [ExampleWidget] +``` + +### 2. Widget Class Structure + +```python +from knoepfe.widgets import Widget + +class ExampleWidget(Widget[ExampleWidgetConfig, ExamplePlugin]): + """Brief description of your widget (used as widget description).""" + + name = "ExampleWidget" + + def __init__(self, config: ExampleWidgetConfig, plugin: ExamplePlugin) -> None: + super().__init__(config, plugin) + # Initialize widget state + + async def activate(self) -> None: + # Called when widget becomes active + + async def deactivate(self) -> None: + # Called when widget becomes inactive + + async def update(self, renderer: Renderer) -> None: + # Render the widget display + + async def pressed(self) -> None: + # Handle key press events + + async def released(self) -> WidgetAction | None: + # Handle key release events + return None +``` + +**Important**: Widget and plugin descriptions are automatically extracted from class docstrings. Do not use a separate `description` attribute. + +### 3. Entry Point Registration + +In `pyproject.toml`: + +```toml +[project.entry-points."knoepfe.plugins"] +example = "knoepfe_example_plugin:ExamplePluginDescriptor" +``` + +### 4. Configuration with Pydantic + +Use Pydantic models to define and validate configuration: + +```python +from pydantic import Field +from knoepfe.config.widget import WidgetConfig + +class ExampleWidgetConfig(WidgetConfig): + """Configuration for ExampleWidget.""" + + message: str = Field(default="Example", description="The text message to display") +``` + +### 5. Rendering with Renderer + +Use the renderer to draw the widget: + +```python +async def update(self, renderer: Renderer) -> None: + renderer.clear() + renderer.text((48, 48), 'Hello World', anchor='mm') +``` + +### 6. State Management + +Widgets can maintain both internal state and shared plugin state: + +```python +def __init__(self, config: ExampleWidgetConfig, plugin: ExamplePlugin) -> None: + super().__init__(config, plugin) + self._click_count = 0 # Internal widget state +``` + +#### Plugin Instance vs Widget State + +- **Widget State**: Private to each widget instance (e.g., `self._click_count`) +- **Plugin Instance**: Shared between all widgets of the same plugin (e.g., `self.plugin`) + +The plugin instance is useful for: +- Sharing connections (like OBS WebSocket) +- Coordinating between multiple widget instances +- Maintaining plugin-wide configuration +- Managing shared resources and background tasks + +### 7. Event Handling + +Handle user interactions: + +```python +async def pressed(self) -> None: + # Called when key is pressed + pass + +async def released(self) -> WidgetAction | None: + # Called when key is released + self._click_count += 1 + self.request_update() # Trigger re-render + return None +``` + +## Plugin Structure + +``` +plugins/example/ +├── README.md # This file +├── pyproject.toml # Package configuration +├── src/ +│ └── knoepfe_example_plugin/ +│ ├── __init__.py # Package initialization & plugin descriptor +│ ├── plugin.py # Plugin instance with state management +│ └── example_widget.py # Widget implementation +└── tests/ + └── test_example_widget.py # Unit tests (optional) +``` + +### Creating Custom Plugin Instances + +For plugins that need to share data or resources between widgets, create a custom plugin class: + +```python +# plugin.py +from knoepfe.plugins import Plugin + +class ExamplePlugin(Plugin): + def __init__(self, config: ExamplePluginConfig): + super().__init__(config) + self.shared_counter = 0 + self.widget_instances = [] + + def increment_counter(self): + self.shared_counter += 1 + return self.shared_counter + + def shutdown(self): + """Called when the plugin is being shut down.""" + # Clean up resources here + pass +``` + +For simple plugins that don't need shared state, use the base `Plugin` class directly in your descriptor. + +## Key Concepts + +### Plugin Lifecycle + +1. **Discovery**: Plugin descriptors are discovered via entry points +2. **Instantiation**: Plugin instance is created with validated configuration +3. **Widget Registration**: Widgets from the plugin are registered with the system +4. **Runtime**: Plugin instance is shared across all widget instances +5. **Shutdown**: `shutdown()` method is called for cleanup when knoepfe exits + +### Widget Lifecycle + +1. **Initialization**: `__init__()` - Set up initial state and configuration +2. **Activation**: `activate()` - Start background tasks, initialize resources +3. **Updates**: `update()` - Render the widget display (called frequently) +4. **Events**: `pressed()`, `released()`, `triggered()` - Handle user interactions +5. **Deactivation**: `deactivate()` - Clean up resources, stop tasks + +### Configuration Management + +- Use `self.config` to access widget-specific configuration (typed as your WidgetConfig subclass) +- Define configuration with Pydantic models for validation and type safety +- Use `Field()` with defaults and descriptions for configuration parameters + +### Rendering + +- The `renderer` is passed directly to `update()` method +- Use `renderer.text()`, `renderer.icon()`, etc. for drawing +- Call `self.request_update()` to trigger re-rendering + +## Testing + +```bash +# Run tests (if implemented) +pytest plugins/example/tests/ + +# Test widget discovery +uv run python -m knoepfe list-widgets + +# Test widget info +uv run python -m knoepfe widget-info ExampleWidget +``` + +## Next Steps + +To create your own widget: + +1. Copy this example plugin structure +2. Rename the package and widget class +3. Implement your custom logic in the widget methods +4. Update the configuration schema for your parameters +5. Add your widget to the entry points in `pyproject.toml` +6. Install and test your plugin + +## Resources + +- [Knoepfe Documentation](https://github.com/lnqs/knoepfe) +- [Schema Library Documentation](https://github.com/keleshev/schema) +- [Stream Deck SDK](https://developer.elgato.com/documentation/stream-deck/) + +## License + +GPL-3.0-or-later \ No newline at end of file diff --git a/plugins/example/pyproject.toml b/plugins/example/pyproject.toml new file mode 100644 index 0000000..a938ce4 --- /dev/null +++ b/plugins/example/pyproject.toml @@ -0,0 +1,84 @@ +[project] +name = "knoepfe-example-plugin" +dynamic = ["version"] +description = "Example plugin demonstrating knoepfe widget development" +authors = [ + { name = "Simon Hayessen", email = "simon@lnqs.io" }, + { name = "Simon Brakhane", email = "simon@brakhane.net" }, +] +requires-python = ">=3.11" +readme = "README.md" +license = "GPL-3.0-or-later" +keywords = ["streamdeck", "knoepfe", "plugin", "example"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia", + "Topic :: System :: Hardware", +] +dependencies = ["knoepfe"] + +[project.urls] +Homepage = "https://github.com/lnqs/knoepfe" +Repository = "https://github.com/lnqs/knoepfe" +Issues = "https://github.com/lnqs/knoepfe/issues" + +# Example plugin registration via entry points +[project.entry-points."knoepfe.plugins"] +example = "knoepfe_example_plugin:ExamplePluginDescriptor" + +[tool.uv.sources] +knoepfe = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "src/knoepfe_example_plugin/__init__.py" + +[dependency-groups] +dev = ["pytest>=8.4.2", "pytest-asyncio>=0.24.0", "pytest-cov>=5.0.0"] + +[tool.hatch.build.targets.wheel] +packages = ["src/knoepfe_example_plugin"] + +[tool.hatch.build.targets.sdist] +include = [ + "src/", + "tests/", + "README.md", + "LICENSE.md", + "CHANGELOG.md", + "pyproject.toml", +] + +[tool.ruff] +line-length = 120 +include = ["src/**/*.py", "tests/**/*.py"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.format] +docstring-code-format = true + +[tool.pyright] +include = ["src", "tests"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.coverage.report] +fail_under = 100 +exclude_lines = ["if __name__ == .__main__.:", "if sys.platform"] +exclude_also = [ + "no cover: start(?s:.)*?no cover: stop", + "\\A(?s:.*# pragma: exclude file.*)\\Z", +] diff --git a/plugins/example/src/knoepfe_example_plugin/__init__.py b/plugins/example/src/knoepfe_example_plugin/__init__.py new file mode 100644 index 0000000..340253f --- /dev/null +++ b/plugins/example/src/knoepfe_example_plugin/__init__.py @@ -0,0 +1,24 @@ +"""Knoepfe Example Plugin + +A minimal example plugin demonstrating how to create widgets for knoepfe. +""" + +from typing import Type + +from knoepfe.plugins import PluginDescriptor +from knoepfe.widgets import Widget + +from .config import ExamplePluginConfig +from .plugin import ExamplePlugin +from .widgets import ExampleWidget + +__version__ = "0.1.0" + + +class ExamplePluginDescriptor(PluginDescriptor[ExamplePluginConfig, ExamplePlugin]): + """Example plugin demonstrating knoepfe widget development.""" + + @classmethod + def widgets(cls) -> list[Type[Widget]]: + """Widgets provided by this plugin.""" + return [ExampleWidget] diff --git a/plugins/example/src/knoepfe_example_plugin/config.py b/plugins/example/src/knoepfe_example_plugin/config.py new file mode 100644 index 0000000..f103a49 --- /dev/null +++ b/plugins/example/src/knoepfe_example_plugin/config.py @@ -0,0 +1,10 @@ +"""Configuration for example plugin.""" + +from knoepfe.config.plugin import PluginConfig +from pydantic import Field + + +class ExamplePluginConfig(PluginConfig): + """Configuration for example plugin.""" + + default_message: str = Field(default="Example", description="Default message to display") diff --git a/plugins/example/src/knoepfe_example_plugin/plugin.py b/plugins/example/src/knoepfe_example_plugin/plugin.py new file mode 100644 index 0000000..d2e0b70 --- /dev/null +++ b/plugins/example/src/knoepfe_example_plugin/plugin.py @@ -0,0 +1,73 @@ +"""Example plugin instance for knoepfe.""" + +import logging +from typing import TYPE_CHECKING + +from knoepfe.plugins import Plugin + +from .config import ExamplePluginConfig + +if TYPE_CHECKING: + from knoepfe.widgets.widget import Widget + +logger = logging.getLogger(__name__) + + +class ExamplePlugin(Plugin): + """Example plugin instance for knoepfe. + + This example demonstrates the lifecycle hooks that plugins can use + to be notified when widgets are activated or deactivated. + """ + + def __init__(self, config: "ExamplePluginConfig"): + super().__init__(config) + # Shared state + self.active_widget_count = 0 + self.total_clicks = 0 + + async def on_widget_activate(self, widget: "Widget") -> None: + """Called when a widget using this plugin is activated. + + This is called BEFORE the widget's activate() method. + Use this for lazy initialization of shared resources. + """ + await super().on_widget_activate(widget) + + # Log when widgets are activated + if self.active_widget_count == 1: + # First widget activated - could initialize shared resources here + logger.info( + f"Example plugin: First widget activated ({widget.name}). " + f"This is where you would initialize shared resources like connections." + ) + else: + logger.debug( + f"Example plugin: Widget {widget.name} activated ({self.active_widget_count} total active widgets)" + ) + + async def on_widget_deactivate(self, widget: "Widget") -> None: + """Called when a widget using this plugin is deactivated. + + This is called AFTER the widget's deactivate() method. + Use this for cleanup when the last widget deactivates. + """ + logger.debug( + f"Example plugin: Widget {widget.name} deactivated " + f"({self.active_widget_count - 1} remaining active widgets)" + ) + + self.active_widget_count -= 1 + await super().on_widget_deactivate(widget) + + if self.active_widget_count == 0: + # Last widget deactivated - could clean up shared resources here + logger.info( + "Example plugin: Last widget deactivated. " + "This is where you would clean up shared resources like connections." + ) + + def increment_clicks(self) -> int: + """Increment the total click count and return the new value.""" + self.total_clicks += 1 + return self.total_clicks diff --git a/plugins/example/src/knoepfe_example_plugin/widgets/__init__.py b/plugins/example/src/knoepfe_example_plugin/widgets/__init__.py new file mode 100644 index 0000000..e1283ee --- /dev/null +++ b/plugins/example/src/knoepfe_example_plugin/widgets/__init__.py @@ -0,0 +1,5 @@ +"""Example widgets for knoepfe.""" + +from .example_widget import ExampleWidget + +__all__ = ["ExampleWidget"] diff --git a/plugins/example/src/knoepfe_example_plugin/widgets/example_widget.py b/plugins/example/src/knoepfe_example_plugin/widgets/example_widget.py new file mode 100644 index 0000000..62f4788 --- /dev/null +++ b/plugins/example/src/knoepfe_example_plugin/widgets/example_widget.py @@ -0,0 +1,106 @@ +"""Example Widget - A minimal widget demonstrating knoepfe plugin development.""" + +from knoepfe.config.widget import WidgetConfig +from knoepfe.rendering import Renderer +from knoepfe.widgets import Widget +from knoepfe.widgets.widget import UpdateResult +from pydantic import Field + +from ..plugin import ExamplePlugin + + +class ExampleWidgetConfig(WidgetConfig): + """Configuration for ExampleWidget.""" + + message: str = Field(default="Example", description="Message to display") + + +class ExampleWidget(Widget[ExampleWidgetConfig, ExamplePlugin]): + """Interactive example widget with click counter. + + This widget displays a customizable message and changes appearance when clicked. + It serves as a template for developing custom widgets. + """ + + name = "ExampleWidget" + + def __init__(self, config: ExampleWidgetConfig, plugin: ExamplePlugin) -> None: + """Initialize the ExampleWidget. + + Args: + config: Widget-specific configuration + plugin: Example plugin instance for sharing data between widgets and access to TaskManager + """ + super().__init__(config, plugin) + + # Internal state to track clicks + self._click_count = 0 + + async def activate(self) -> None: + """Called when the widget becomes active. + + Use this method to start background tasks, initialize resources, etc. + """ + # Reset click count when widget becomes active + self._click_count = 0 + + async def deactivate(self) -> None: + """Called when the widget becomes inactive. + + Use this method to clean up resources, stop background tasks, etc. + """ + # Clean up any resources if needed + pass + + async def update(self, renderer: Renderer) -> UpdateResult: + """Update the widget display. + + This method is called whenever the widget needs to be redrawn. + + Args: + renderer: Renderer instance to draw the widget display + + Returns: + UpdateResult.UPDATED to push the rendered canvas to the device + """ + # Get the message from config + message = self.config.message + + # Create display text based on click count + if self._click_count == 0: + display_text = f"{message}\nClick me!" + else: + display_text = f"{message}\nClicked {self._click_count}x" + + # Use the renderer to draw the widget + renderer.clear() + # Draw the text + renderer.text_multiline(display_text) + + return UpdateResult.UPDATED + + async def on_key_down(self) -> None: + """Handle key press events. + + This method is called when the Stream Deck key is pressed down. + """ + # Increment local click counter + self._click_count += 1 + + # Also increment the shared plugin counter + total_clicks = self.plugin.increment_clicks() + + # Log the shared plugin state for demonstration + print(f"Widget clicked {self._click_count} times, total across all widgets: {total_clicks}") + + # Request an update to show the new state + self.request_update() + + async def on_key_up(self) -> None: + """Handle key release events. + + This method is called when the Stream Deck key is released. + """ + # Optional: Handle key release if needed + # For this example, we don't need to do anything on key up + pass diff --git a/plugins/example/tests/test_example_widget.py b/plugins/example/tests/test_example_widget.py new file mode 100644 index 0000000..116d55e --- /dev/null +++ b/plugins/example/tests/test_example_widget.py @@ -0,0 +1,139 @@ +"""Tests for the ExampleWidget.""" + +from unittest.mock import Mock + +import pytest +from pydantic import ValidationError + +from knoepfe_example_plugin.config import ExamplePluginConfig +from knoepfe_example_plugin.plugin import ExamplePlugin +from knoepfe_example_plugin.widgets.example_widget import ExampleWidget, ExampleWidgetConfig + + +class TestExampleWidget: + """Test cases for ExampleWidget.""" + + def test_init_with_defaults(self): + """Test widget initialization with default configuration.""" + widget_config = ExampleWidgetConfig() + plugin = ExamplePlugin(ExamplePluginConfig()) + + widget = ExampleWidget(widget_config, plugin) + + assert widget._click_count == 0 + assert widget.config.message == "Example" # Default value + + def test_init_with_custom_config(self): + """Test widget initialization with custom configuration.""" + widget_config = ExampleWidgetConfig(message="Custom Message") + plugin = ExamplePlugin(ExamplePluginConfig()) + + widget = ExampleWidget(widget_config, plugin) + + assert widget.config.message == "Custom Message" + + @pytest.mark.asyncio + async def test_activate_resets_click_count(self): + """Test that activate resets the click count.""" + plugin = ExamplePlugin(ExamplePluginConfig()) + widget = ExampleWidget(ExampleWidgetConfig(), plugin) + widget._click_count = 5 + + await widget.activate() + + assert widget._click_count == 0 + + @pytest.mark.asyncio + async def test_deactivate(self): + """Test deactivate method.""" + plugin = ExamplePlugin(ExamplePluginConfig()) + widget = ExampleWidget(ExampleWidgetConfig(), plugin) + + # Should not raise any exceptions + await widget.deactivate() + + @pytest.mark.asyncio + async def test_update_with_defaults(self): + """Test update method with default configuration.""" + plugin = ExamplePlugin(ExamplePluginConfig()) + widget = ExampleWidget(ExampleWidgetConfig(), plugin) + + # Mock the renderer + mock_renderer = Mock() + + await widget.update(mock_renderer) + + # Verify renderer was called + mock_renderer.clear.assert_called_once() + mock_renderer.text_multiline.assert_called_once_with("Example\nClick me!") + + @pytest.mark.asyncio + async def test_update_with_custom_config(self): + """Test update method with custom configuration.""" + widget_config = ExampleWidgetConfig(message="Hello") + plugin = ExamplePlugin(ExamplePluginConfig()) + widget = ExampleWidget(widget_config, plugin) + + # Mock the renderer + mock_renderer = Mock() + + await widget.update(mock_renderer) + + # Verify renderer was called with custom values + mock_renderer.clear.assert_called_once() + mock_renderer.text_multiline.assert_called_once_with("Hello\nClick me!") + + @pytest.mark.asyncio + async def test_update_after_clicks(self): + """Test update method after some clicks.""" + plugin = ExamplePlugin(ExamplePluginConfig()) + widget = ExampleWidget(ExampleWidgetConfig(), plugin) + widget._click_count = 3 + + # Mock the renderer + mock_renderer = Mock() + + await widget.update(mock_renderer) + + # Verify renderer shows click count + mock_renderer.clear.assert_called_once() + mock_renderer.text_multiline.assert_called_once_with("Example\nClicked 3x") + + @pytest.mark.asyncio + async def test_on_key_down_increments_counter(self): + """Test that key down increments click counter.""" + plugin = ExamplePlugin(ExamplePluginConfig()) + widget = ExampleWidget(ExampleWidgetConfig(), plugin) + widget.request_update = Mock() # Mock the request_update method + + initial_count = widget._click_count + + await widget.on_key_down() + + assert widget._click_count == initial_count + 1 + widget.request_update.assert_called_once() + + @pytest.mark.asyncio + async def test_on_key_up(self): + """Test key up handler.""" + plugin = ExamplePlugin(ExamplePluginConfig()) + widget = ExampleWidget(ExampleWidgetConfig(), plugin) + + # Should not raise any exceptions + await widget.on_key_up() + + def test_widget_config_validation(self): + """Test configuration validation with Pydantic.""" + # Test that config validates correct configurations + valid_config = ExampleWidgetConfig(message="Test Message") + assert valid_config.message == "Test Message" + + # Test defaults + minimal_config = ExampleWidgetConfig() + assert minimal_config.message == "Example" + + def test_config_validation_error(self): + """Test that invalid configuration raises validation error.""" + # Invalid configuration (wrong type) + with pytest.raises(ValidationError): + ExampleWidgetConfig(message=123) # type: ignore[arg-type] # Should be string diff --git a/plugins/obs/README.md b/plugins/obs/README.md new file mode 100644 index 0000000..ac87771 --- /dev/null +++ b/plugins/obs/README.md @@ -0,0 +1,183 @@ +# Knoepfe OBS Plugin + +OBS Studio integration widgets for [knoepfe](https://github.com/lnqs/knoepfe). + +## Installation + +```bash +# Install with knoepfe +pip install knoepfe[obs] + +# Or install separately +pip install knoepfe-obs-plugin +``` + +## Widgets + +### OBSRecording +Controls OBS recording functionality. + +**Configuration:** +```python +# Use default icons and colors +widget.OBSRecording() + +# Customize icons and colors +widget.OBSRecording( + recording_icon='🔴', + stopped_icon='⏹️', + loading_icon='⏳', + recording_color='green', + stopped_color='blue' +) +``` + +**Parameters:** +- `recording_icon` (optional): Icon when recording. Can be unicode character or codepoint. Default: `'\uf0567'` (nf-md-video) +- `stopped_icon` (optional): Icon when stopped. Can be unicode character or codepoint. Default: `'\uf0568'` (nf-md-video_off) +- `loading_icon` (optional): Icon when loading. Can be unicode character or codepoint. Default: `'\uf0772'` (nf-md-loading) +- `recording_color` (optional): Icon/text color when recording. Default: `'red'` +- `stopped_color` (optional): Icon color when stopped. Default: `'white'` + +**Features:** +- Shows recording status with customizable colors +- Displays recording timecode +- Long press to start/stop recording +- Short press shows help text +- Fully customizable icons and colors + +### OBSStreaming +Controls OBS streaming functionality. + +**Configuration:** +```python +# Use default icons and colors +widget.OBSStreaming() + +# Customize icons and colors +widget.OBSStreaming( + streaming_icon='📡', + stopped_icon='🚫', + loading_icon='⏳', + streaming_color='green', + stopped_color='blue' +) +``` + +**Parameters:** +- `streaming_icon` (optional): Icon when streaming. Can be unicode character or codepoint. Default: `'\uf0118'` (nf-md-cast) +- `stopped_icon` (optional): Icon when stopped. Can be unicode character or codepoint. Default: `'\uf0118'` (nf-md-cast) +- `loading_icon` (optional): Icon when loading. Can be unicode character or codepoint. Default: `'\uf0772'` (nf-md-loading) +- `streaming_color` (optional): Icon/text color when streaming. Default: `'red'` +- `stopped_color` (optional): Icon color when stopped. Default: `'white'` + +**Features:** +- Shows streaming status with customizable colors +- Displays streaming timecode +- Long press to start/stop streaming +- Short press shows help text +- Fully customizable icons and colors + +### OBSCurrentScene +Displays the currently active OBS scene. + +**Configuration:** +```python +# Use default icon and color +widget.OBSCurrentScene() + +# Customize icon and color +widget.OBSCurrentScene( + icon='🎬', + connected_color='cyan' +) +``` + +**Parameters:** +- `icon` (optional): Scene icon. Can be unicode character or codepoint. Default: `'\uf01c5'` (nf-md-desktop_tower) +- `connected_color` (optional): Icon/text color when connected. Default: `'white'` + +**Features:** +- Shows current scene name +- Updates automatically when scene changes +- Grayed out when OBS is disconnected +- Customizable icon and color + +### OBSSwitchScene +Switch to a specific OBS scene. + +**Configuration:** +```python +# Basic usage +widget.OBSSwitchScene(scene='Gaming') + +# Customize icon and colors +widget.OBSSwitchScene( + scene='Gaming', + icon='🎮', + active_color='green', + inactive_color='gray' +) +``` + +**Parameters:** +- `scene` (required): Name of the OBS scene to switch to +- `icon` (optional): Scene icon. Can be unicode character or codepoint. Default: `'\uf01c5'` (nf-md-desktop_tower) +- `active_color` (optional): Icon/text color when scene is active. Default: `'red'` +- `inactive_color` (optional): Icon/text color when scene is inactive. Default: `'white'` + +**Features:** +- Shows scene name on button +- Customizable highlight when scene is active +- Click to switch to the scene +- Grayed out when OBS is disconnected +- Fully customizable icon and colors + +## Plugin Configuration + +Configure OBS connection and global settings in your knoepfe config: + +```toml +# ============================================================================ +# OBS Plugin Configuration +# ============================================================================ + +[plugins.obs] +# Enable/disable the OBS plugin +enabled = true + +# Host OBS is running on (usually 'localhost') +host = "localhost" + +# Port obs-websocket is listening on (default: 4455) +port = 4455 + +# Password for obs-websocket authentication +# You can use environment variables: password = "${OBS_PASSWORD}" +# Or set via: export KNOEPFE_PLUGINS__OBS__PASSWORD="your_password" +password = "supersecret" + +# Icon color when OBS is disconnected (applies to all widgets) +disconnected_color = "#202020" +``` + +**Parameters:** +- `enabled` (optional): Enable the OBS plugin. Default: `true` +- `host` (optional): OBS WebSocket host. Default: `'localhost'` +- `port` (optional): OBS WebSocket port. Default: `4455` +- `password` (optional): OBS WebSocket password. Supports environment variables. Default: `None` +- `disconnected_color` (optional): Icon color when OBS is disconnected. Default: `'#202020'` + +## Requirements + +- OBS Studio with WebSocket plugin enabled +- `simpleobsws>=1.4.0` (installed automatically) + +## Development + +```bash +# Install in development mode +uv pip install -e plugins/obs + +# Run tests +pytest plugins/obs/tests/ \ No newline at end of file diff --git a/plugins/obs/pyproject.toml b/plugins/obs/pyproject.toml new file mode 100644 index 0000000..2c81a60 --- /dev/null +++ b/plugins/obs/pyproject.toml @@ -0,0 +1,84 @@ +[project] +name = "knoepfe-obs-plugin" +dynamic = ["version"] +description = "OBS Studio integration widgets for knoepfe" +authors = [ + { name = "Simon Hayessen", email = "simon@lnqs.io" }, + { name = "Simon Brakhane", email = "simon@brakhane.net" }, +] +requires-python = ">=3.11" +readme = "README.md" +license = "GPL-3.0-or-later" +keywords = ["streamdeck", "obs", "knoepfe", "plugin"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia", + "Topic :: System :: Hardware", +] +dependencies = ["knoepfe", "simpleobsws>=1.4.0"] + +[project.urls] +Homepage = "https://github.com/lnqs/knoepfe" +Repository = "https://github.com/lnqs/knoepfe" +Issues = "https://github.com/lnqs/knoepfe/issues" + +# OBS plugin registration via entry points +[project.entry-points."knoepfe.plugins"] +obs = "knoepfe_obs_plugin:OBSPluginDescriptor" + +[tool.uv.sources] +knoepfe = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "src/knoepfe_obs_plugin/__init__.py" + +[dependency-groups] +dev = ["pytest>=8.4.2", "pytest-asyncio>=0.24.0", "pytest-cov>=5.0.0"] + +[tool.hatch.build.targets.wheel] +packages = ["src/knoepfe_obs_plugin"] + +[tool.hatch.build.targets.sdist] +include = [ + "src/", + "tests/", + "README.md", + "LICENSE.md", + "CHANGELOG.md", + "pyproject.toml", +] + +[tool.ruff] +line-length = 120 +include = ["src/**/*.py", "tests/**/*.py"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.format] +docstring-code-format = true + +[tool.pyright] +include = ["src", "tests"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.coverage.report] +fail_under = 100 +exclude_lines = ["if __name__ == .__main__.:", "if sys.platform"] +exclude_also = [ + "no cover: start(?s:.)*?no cover: stop", + "\\A(?s:.*# pragma: exclude file.*)\\Z", +] diff --git a/plugins/obs/src/knoepfe_obs_plugin/__init__.py b/plugins/obs/src/knoepfe_obs_plugin/__init__.py new file mode 100644 index 0000000..74bfa0a --- /dev/null +++ b/plugins/obs/src/knoepfe_obs_plugin/__init__.py @@ -0,0 +1,24 @@ +"""OBS Studio integration widgets for knoepfe. + +This plugin provides widgets for controlling OBS Studio via WebSocket connection. +""" + +from typing import Type + +from knoepfe.plugins import PluginDescriptor +from knoepfe.widgets import Widget + +from .config import OBSPluginConfig +from .plugin import OBSPlugin +from .widgets import CurrentScene, Recording, Streaming, SwitchScene + +__version__ = "0.1.0" + + +class OBSPluginDescriptor(PluginDescriptor[OBSPluginConfig, OBSPlugin]): + """OBS Studio integration widgets for knoepfe.""" + + @classmethod + def widgets(cls) -> list[Type[Widget]]: + """Widgets provided by this plugin.""" + return [Recording, Streaming, CurrentScene, SwitchScene] diff --git a/plugins/obs/src/knoepfe_obs_plugin/config.py b/plugins/obs/src/knoepfe_obs_plugin/config.py new file mode 100644 index 0000000..486f283 --- /dev/null +++ b/plugins/obs/src/knoepfe_obs_plugin/config.py @@ -0,0 +1,14 @@ +"""Configuration for OBS plugin.""" + +from knoepfe.config.plugin import PluginConfig +from pydantic import Field + + +class OBSPluginConfig(PluginConfig): + """Configuration for OBS plugin.""" + + host: str = Field(default="localhost", description="OBS WebSocket host") + port: int = Field(default=4455, ge=1, le=65535, description="OBS WebSocket port") + password: str | None = Field(default=None, description="OBS WebSocket password") + + disconnected_color: str = Field(default="#202020", description="Icon color when OBS is disconnected") diff --git a/knoepfe/widgets/obs/connector.py b/plugins/obs/src/knoepfe_obs_plugin/connector.py similarity index 59% rename from knoepfe/widgets/obs/connector.py rename to plugins/obs/src/knoepfe_obs_plugin/connector.py index 9d83fb8..dc01ee2 100644 --- a/knoepfe/widgets/obs/connector.py +++ b/plugins/obs/src/knoepfe_obs_plugin/connector.py @@ -1,26 +1,24 @@ -from asyncio import Condition, Task, get_event_loop, sleep -from typing import Any, AsyncIterator, Awaitable, Callable, Dict, cast +import logging +from asyncio import Condition, sleep +from typing import Any, AsyncIterator, Awaitable, Callable, cast import simpleobsws -from schema import Optional, Schema +from knoepfe.utils.task_manager import TaskManager -from knoepfe.log import debug, info +from .config import OBSPluginConfig -config = Schema( - { - Optional("host"): str, - Optional("port"): int, - Optional("password"): str, - } -) +logger = logging.getLogger(__name__) + +# Task name constants +TASK_CONNECTION_WATCHER = "connection_watcher" +TASK_STATUS_WATCHER = "status_watcher" class OBS: - def __init__(self) -> None: + def __init__(self, config: OBSPluginConfig, tasks: TaskManager) -> None: self.ws = simpleobsws.WebSocketClient() self.ws.register_event_callback(self._handle_event) - self.connection_watcher: Task[None] | None = None - self.status_watcher: Task[None] | None = None + self.tasks = tasks self.streaming = False self.recording = False self.streaming_timecode = None @@ -30,22 +28,19 @@ def __init__(self) -> None: self.last_event: Any = None self.event_condition = Condition() - async def connect(self, config: Dict[str, Any]) -> None: - if self.connection_watcher: - return + self.ws.url = f"ws://{config.host}:{config.port}" + if config.password: + self.ws.password = config.password - host = config.get("host", "localhost") - port = config.get("port", 4444) - password = cast(str, config.get("password")) - self.ws.url = f"ws://{host}:{port}" - self.ws.password = password + async def connect(self) -> None: + if self.tasks.is_running(TASK_CONNECTION_WATCHER): + return - loop = get_event_loop() - self.connection_watcher = loop.create_task(self._watch_connection()) + self.tasks.start_task(TASK_CONNECTION_WATCHER, self._watch_connection()) @property def connected(self) -> bool: - return bool(self.ws and self.ws.ws and self.ws.ws.open) + return bool(self.ws and self.ws.ws_open) async def listen(self) -> AsyncIterator[str]: while True: @@ -56,27 +51,25 @@ async def listen(self) -> AsyncIterator[str]: yield event async def start_recording(self) -> None: - info("Starting OBS recording") + logger.info("Starting OBS recording") await self.ws.call(simpleobsws.Request("StartRecord")) async def stop_recording(self) -> None: - info("Stopping OBS recording") + logger.info("Stopping OBS recording") await self.ws.call(simpleobsws.Request("StopRecord")) async def start_streaming(self) -> None: - info("Starting OBS streaming") + logger.info("Starting OBS streaming") await self.ws.call(simpleobsws.Request("StartStream")) async def stop_streaming(self) -> None: - info("Stopping OBS streaming") + logger.info("Stopping OBS streaming") await self.ws.call(simpleobsws.Request("StopStream")) async def set_scene(self, scene: str) -> None: if scene != self.current_scene: - info(f"Setting current OBS scene to {scene}") - await self.ws.call( - simpleobsws.Request("SetCurrentProgramScene", {"sceneName": scene}) - ) + logger.info(f"Setting current OBS scene to {scene}") + await self.ws.call(simpleobsws.Request("SetCurrentProgramScene", {"sceneName": scene})) async def get_streaming_timecode(self) -> str | None: status = await self.ws.call(simpleobsws.Request("GetStreamStatus")) @@ -95,40 +88,34 @@ async def _watch_connection(self) -> None: while True: if not self.connected and was_connected: - debug("Connection to OBS lost") + logger.debug("Connection to OBS lost") was_connected = False - await self._handle_event( - {"eventType": "ConnectionLost"} - ) # Fake connection lost event + await self._handle_event({"eventType": "ConnectionLost"}) # Fake connection lost event if not self.connected: try: - debug("Trying to connect to OBS") + logger.debug("Trying to connect to OBS") await cast(Callable[[], Awaitable[bool]], self.ws.connect)() if not await self.ws.wait_until_identified(): raise OSError("Failed to identify to OBS") - debug("Connected to OBS") + logger.debug("Connected to OBS") was_connected = True await self._handle_event( {"eventType": "ConnectionEstablished"} ) # Fake connection established event except OSError as e: - debug(f"Failed to connect to OBS: {e}") + logger.debug(f"Failed to connect to OBS: {e}") await sleep(5.0) - async def _handle_event(self, event: Dict[str, Any]) -> None: - debug(f"OBS event received: {event}") + async def _handle_event(self, event: dict[str, Any]) -> None: + logger.debug(f"OBS event received: {event}") if event["eventType"] == "ConnectionEstablished": - self.current_scene = ( - await self.ws.call(simpleobsws.Request("GetCurrentProgramScene")) - ).responseData["currentProgramSceneName"] - self.streaming = ( - await self.ws.call(simpleobsws.Request("GetStreamStatus")) - ).responseData["outputActive"] - self.recording = ( - await self.ws.call(simpleobsws.Request("GetRecordStatus")) - ).responseData["outputActive"] + self.current_scene = (await self.ws.call(simpleobsws.Request("GetCurrentProgramScene"))).responseData[ + "currentProgramSceneName" + ] + self.streaming = (await self.ws.call(simpleobsws.Request("GetStreamStatus"))).responseData["outputActive"] + self.recording = (await self.ws.call(simpleobsws.Request("GetRecordStatus"))).responseData["outputActive"] await self.get_recording_timecode() elif event["eventType"] == "CurrentProgramSceneChanged": self.current_scene = event["eventData"]["sceneName"] @@ -144,6 +131,3 @@ async def _handle_event(self, event: Dict[str, Any]) -> None: async with self.event_condition: self.last_event = event self.event_condition.notify_all() - - -obs = OBS() diff --git a/plugins/obs/src/knoepfe_obs_plugin/plugin.py b/plugins/obs/src/knoepfe_obs_plugin/plugin.py new file mode 100644 index 0000000..0e03b35 --- /dev/null +++ b/plugins/obs/src/knoepfe_obs_plugin/plugin.py @@ -0,0 +1,45 @@ +"""OBS plugin instance for knoepfe.""" + +import logging +from typing import TYPE_CHECKING + +from knoepfe.plugins import Plugin + +from .config import OBSPluginConfig +from .connector import OBS + +if TYPE_CHECKING: + from knoepfe.widgets.widget import Widget + +logger = logging.getLogger(__name__) + + +class OBSPlugin(Plugin): + """OBS plugin instance for knoepfe. + + Provides shared state and resources for all OBS widgets, including + a single OBS WebSocket connection that is shared across all widgets. + + The OBS connection is lazily initialized when the first widget + is activated and remains connected for the lifetime of the plugin. + """ + + def __init__(self, config: OBSPluginConfig): + super().__init__(config) + self.obs = OBS(config, self.tasks) + self.disconnected_color = config.disconnected_color + + async def on_widget_activate(self, widget: "Widget") -> None: + """Connect to OBS when first widget activates. + + This is called BEFORE the widget's activate() method. + The connection remains active for the lifetime of the plugin. + """ + await super().on_widget_activate(widget) + + # Connect to OBS if not already connected + if not self.obs.connected: + logger.info("OBS plugin: First widget activated, connecting to OBS...") + await self.obs.connect() + else: + logger.debug(f"OBS plugin: Widget {widget.name} activated (OBS already connected)") diff --git a/plugins/obs/src/knoepfe_obs_plugin/widgets/__init__.py b/plugins/obs/src/knoepfe_obs_plugin/widgets/__init__.py new file mode 100644 index 0000000..672f7e3 --- /dev/null +++ b/plugins/obs/src/knoepfe_obs_plugin/widgets/__init__.py @@ -0,0 +1,9 @@ +"""OBS widgets for knoepfe.""" + +from .current_scene import CurrentScene +from .obs_widget import OBSWidget +from .recording import Recording +from .streaming import Streaming +from .switch_scene import SwitchScene + +__all__ = ["OBSWidget", "CurrentScene", "Recording", "Streaming", "SwitchScene"] diff --git a/plugins/obs/src/knoepfe_obs_plugin/widgets/current_scene.py b/plugins/obs/src/knoepfe_obs_plugin/widgets/current_scene.py new file mode 100644 index 0000000..d6c4a65 --- /dev/null +++ b/plugins/obs/src/knoepfe_obs_plugin/widgets/current_scene.py @@ -0,0 +1,51 @@ +from knoepfe.config.widget import WidgetConfig +from knoepfe.rendering import Renderer +from knoepfe.widgets.widget import UpdateResult +from pydantic import Field + +from ..plugin import OBSPlugin +from .obs_widget import OBSWidget + + +class CurrentSceneConfig(WidgetConfig): + """Configuration for CurrentScene widget.""" + + icon: str = Field( + default="󰏜", # nf-md-panorama + description="Scene icon (unicode character or codepoint)", + ) + connected_color: str | None = Field( + default=None, description="Icon/text color when connected (defaults to base color)" + ) + + +class CurrentScene(OBSWidget[CurrentSceneConfig]): + """Display currently active OBS scene.""" + + name = "OBSCurrentScene" + + relevant_events = [ + "ConnectionEstablished", + "ConnectionLost", + "CurrentProgramSceneChanged", + ] + + def __init__(self, config: CurrentSceneConfig, plugin: OBSPlugin) -> None: + super().__init__(config, plugin) + + async def update(self, renderer: Renderer) -> UpdateResult: + renderer.clear() + if self.plugin.obs.connected: + color = self.config.connected_color or self.config.color + renderer.icon_and_text( + self.config.icon, + self.plugin.obs.current_scene or "[none]", + icon_size=64, + text_size=16, + icon_color=color, + text_color=color, + ) + else: + renderer.icon(self.config.icon, color=self.plugin.disconnected_color) + + return UpdateResult.UPDATED diff --git a/plugins/obs/src/knoepfe_obs_plugin/widgets/obs_widget.py b/plugins/obs/src/knoepfe_obs_plugin/widgets/obs_widget.py new file mode 100644 index 0000000..f03f66f --- /dev/null +++ b/plugins/obs/src/knoepfe_obs_plugin/widgets/obs_widget.py @@ -0,0 +1,35 @@ +from typing import Generic, TypeVar + +from knoepfe.config.widget import WidgetConfig +from knoepfe.widgets import Widget + +from ..plugin import OBSPlugin + +TConfig = TypeVar("TConfig", bound=WidgetConfig) + +# Task name constants +TASK_EVENT_LISTENER = "event_listener" + + +class OBSWidget(Widget[TConfig, OBSPlugin], Generic[TConfig]): + """Base class for OBS widgets with typed configuration.""" + + relevant_events: list[str] = [] + + async def activate(self) -> None: + """Start event listener. + + The OBS connection is managed by the plugin instance and is + established when the first widget activates. + """ + self.tasks.start_task(TASK_EVENT_LISTENER, self.listener()) + + async def listener(self) -> None: + async for event in self.plugin.obs.listen(): + if event == "ConnectionEstablished": + self.acquire_wake_lock() + elif event == "ConnectionLost": + self.release_wake_lock() + + if event in self.relevant_events: + self.request_update() diff --git a/plugins/obs/src/knoepfe_obs_plugin/widgets/recording.py b/plugins/obs/src/knoepfe_obs_plugin/widgets/recording.py new file mode 100644 index 0000000..a8e518b --- /dev/null +++ b/plugins/obs/src/knoepfe_obs_plugin/widgets/recording.py @@ -0,0 +1,94 @@ +from asyncio import sleep + +from knoepfe.config.widget import WidgetConfig +from knoepfe.rendering import Renderer +from knoepfe.widgets.widget import UpdateResult +from pydantic import Field + +from ..plugin import OBSPlugin +from .obs_widget import OBSWidget + + +class RecordingConfig(WidgetConfig): + """Configuration for Recording widget.""" + + recording_icon: str = Field( + default="󰕧", # nf-md-video + description="Icon when recording (unicode character or codepoint)", + ) + stopped_icon: str = Field( + default="󰕨", # nf-md-video_off + description="Icon when stopped (unicode character or codepoint)", + ) + loading_icon: str = Field( + default="󰔟", # nf-md-timer_sand + description="Icon when loading (unicode character or codepoint)", + ) + recording_color: str = Field(default="red", description="Icon/text color when recording") + stopped_color: str | None = Field(default=None, description="Icon color when stopped (defaults to base color)") + + +class Recording(OBSWidget[RecordingConfig]): + """Start/stop OBS recording with timecode display.""" + + name = "OBSRecording" + + relevant_events = [ + "ConnectionEstablished", + "ConnectionLost", + "RecordStateChanged", + ] + + def __init__(self, config: RecordingConfig, plugin: OBSPlugin) -> None: + super().__init__(config, plugin) + self.recording = False + self.show_help = False + self.show_loading = False + + async def update(self, renderer: Renderer) -> UpdateResult: + if self.plugin.obs.recording != self.recording: + if self.plugin.obs.recording: + self.request_periodic_update(1.0) + else: + self.stop_periodic_update() + self.recording = self.plugin.obs.recording + + renderer.clear() + if self.show_loading: + self.show_loading = False + renderer.icon(self.config.loading_icon) + elif not self.plugin.obs.connected: + renderer.icon(self.config.stopped_icon, color=self.plugin.disconnected_color) + elif self.show_help: + renderer.text_multiline("long press\nto toggle", size=16) + elif self.plugin.obs.recording: + timecode = (await self.plugin.obs.get_recording_timecode() or "").rsplit(".", 1)[0] + renderer.icon_and_text( + self.config.recording_icon, + timecode, + icon_color=self.config.recording_color, + text_color=self.config.recording_color, + ) + else: + renderer.icon(self.config.stopped_icon, color=self.config.stopped_color or self.config.color) + + return UpdateResult.UPDATED + + async def triggered(self, long_press: bool = False) -> None: + if long_press: + if not self.plugin.obs.connected: + return + + if self.plugin.obs.recording: + await self.plugin.obs.stop_recording() + else: + await self.plugin.obs.start_recording() + + self.show_loading = True + self.request_update() + else: + self.show_help = True + self.request_update() + await sleep(1.0) + self.show_help = False + self.request_update() diff --git a/plugins/obs/src/knoepfe_obs_plugin/widgets/streaming.py b/plugins/obs/src/knoepfe_obs_plugin/widgets/streaming.py new file mode 100644 index 0000000..d85253a --- /dev/null +++ b/plugins/obs/src/knoepfe_obs_plugin/widgets/streaming.py @@ -0,0 +1,94 @@ +from asyncio import sleep + +from knoepfe.config.widget import WidgetConfig +from knoepfe.rendering import Renderer +from knoepfe.widgets.widget import UpdateResult +from pydantic import Field + +from ..plugin import OBSPlugin +from .obs_widget import OBSWidget + + +class StreamingConfig(WidgetConfig): + """Configuration for Streaming widget.""" + + streaming_icon: str = Field( + default="󰄘", # nf-md-cast + description="Icon when streaming (unicode character or codepoint)", + ) + stopped_icon: str = Field( + default="󰄘", # nf-md-cast + description="Icon when stopped (unicode character or codepoint)", + ) + loading_icon: str = Field( + default="󰔟", # nf-md-timer_sand + description="Icon when loading (unicode character or codepoint)", + ) + streaming_color: str = Field(default="red", description="Icon/text color when streaming") + stopped_color: str | None = Field(default=None, description="Icon color when stopped (defaults to base color)") + + +class Streaming(OBSWidget[StreamingConfig]): + """Start/stop OBS streaming with timecode display.""" + + name = "OBSStreaming" + + relevant_events = [ + "ConnectionEstablished", + "ConnectionLost", + "StreamStateChanged", + ] + + def __init__(self, config: StreamingConfig, plugin: OBSPlugin) -> None: + super().__init__(config, plugin) + self.streaming = False + self.show_help = False + self.show_loading = False + + async def update(self, renderer: Renderer) -> UpdateResult: + if self.plugin.obs.streaming != self.streaming: + if self.plugin.obs.streaming: + self.request_periodic_update(1.0) + else: + self.stop_periodic_update() + self.streaming = self.plugin.obs.streaming + + renderer.clear() + if self.show_loading: + self.show_loading = False + renderer.icon(self.config.loading_icon) + elif not self.plugin.obs.connected: + renderer.icon(self.config.stopped_icon, color=self.plugin.disconnected_color) + elif self.show_help: + renderer.text_multiline("long press\nto toggle", size=16) + elif self.plugin.obs.streaming: + timecode = (await self.plugin.obs.get_streaming_timecode() or "").rsplit(".", 1)[0] + renderer.icon_and_text( + self.config.streaming_icon, + timecode, + icon_color=self.config.streaming_color, + text_color=self.config.streaming_color, + ) + else: + renderer.icon(self.config.stopped_icon, color=self.config.stopped_color or self.config.color) + + return UpdateResult.UPDATED + + async def triggered(self, long_press: bool = False) -> None: + if long_press: + if not self.plugin.obs.connected: + return + + if self.plugin.obs.streaming: + await self.plugin.obs.stop_streaming() + else: + await self.plugin.obs.start_streaming() + + self.show_loading = True + self.request_update() + else: + self.show_help = True + self.request_update() + await sleep(1.0) + self.show_help = False + self.request_update() diff --git a/plugins/obs/src/knoepfe_obs_plugin/widgets/switch_scene.py b/plugins/obs/src/knoepfe_obs_plugin/widgets/switch_scene.py new file mode 100644 index 0000000..e0131be --- /dev/null +++ b/plugins/obs/src/knoepfe_obs_plugin/widgets/switch_scene.py @@ -0,0 +1,60 @@ +from knoepfe.config.widget import WidgetConfig +from knoepfe.rendering import Renderer +from knoepfe.widgets.widget import UpdateResult +from pydantic import Field + +from ..plugin import OBSPlugin +from .obs_widget import OBSWidget + + +class SwitchSceneConfig(WidgetConfig): + """Configuration for SwitchScene widget.""" + + scene: str = Field(..., description="Scene name to switch to") + icon: str = Field( + default="󰏜", # nf-md-panorama + description="Scene icon (unicode character or codepoint)", + ) + active_color: str = Field(default="red", description="Icon/text color when scene is active") + inactive_color: str | None = Field( + default=None, description="Icon/text color when scene is inactive (defaults to base color)" + ) + + +class SwitchScene(OBSWidget[SwitchSceneConfig]): + """Switch to a specific OBS scene.""" + + name = "OBSSwitchScene" + + relevant_events = [ + "ConnectionEstablished", + "ConnectionLost", + "SwitchScenes", + ] + + def __init__(self, config: SwitchSceneConfig, plugin: OBSPlugin) -> None: + super().__init__(config, plugin) + + async def update(self, renderer: Renderer) -> UpdateResult: + if not self.plugin.obs.connected: + color = self.plugin.disconnected_color + elif self.plugin.obs.current_scene == self.config.scene: + color = self.config.active_color + else: + color = self.config.inactive_color or self.config.color + + renderer.clear() + renderer.icon_and_text( + self.config.icon, + self.config.scene, + icon_size=64, + text_size=16, + icon_color=color, + text_color=color, + ) + + return UpdateResult.UPDATED + + async def triggered(self, long_press: bool = False) -> None: + if self.plugin.obs.connected: + await self.plugin.obs.set_scene(self.config.scene) diff --git a/plugins/obs/tests/__init__.py b/plugins/obs/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/obs/tests/test_base.py b/plugins/obs/tests/test_base.py new file mode 100644 index 0000000..add2046 --- /dev/null +++ b/plugins/obs/tests/test_base.py @@ -0,0 +1,133 @@ +from unittest.mock import AsyncMock, Mock, patch + +from knoepfe.config.widget import WidgetConfig +from knoepfe.rendering import Renderer +from knoepfe.widgets.widget import UpdateResult +from pytest import fixture + +from knoepfe_obs_plugin.config import OBSPluginConfig +from knoepfe_obs_plugin.plugin import OBSPlugin +from knoepfe_obs_plugin.widgets.obs_widget import TASK_EVENT_LISTENER, OBSWidget + + +class MockWidgetConfig(WidgetConfig): + """Minimal widget config for testing.""" + + pass + + +class MockOBSWidget(OBSWidget[MockWidgetConfig]): + """Test implementation of OBSWidget for testing purposes.""" + + relevant_events = ["TestEvent"] + + async def update(self, renderer: Renderer) -> UpdateResult: + return UpdateResult.UPDATED + + async def triggered(self, long_press=False): + pass + + +@fixture +def mock_plugin(): + return OBSPlugin(OBSPluginConfig()) + + +@fixture +def obs_widget(mock_plugin): + widget = MockOBSWidget(MockWidgetConfig(), mock_plugin) + + # Mock the TaskManager to avoid pytest warnings about unawaited tasks + def mock_start_task(name, coro): + # Close the coroutine to prevent "never awaited" warnings + coro.close() + return Mock() + + widget.tasks = Mock() + widget.tasks.start_task = Mock(side_effect=mock_start_task) + widget.tasks.stop_task = Mock() + widget.tasks.is_running = Mock(return_value=False) + widget.tasks.cleanup = AsyncMock() + return widget + + +def test_obs_widget_init(mock_plugin): + widget = MockOBSWidget(MockWidgetConfig(), mock_plugin) + assert widget.relevant_events == ["TestEvent"] + assert widget.tasks is not None + + +async def test_obs_widget_activate(obs_widget): + """Test widget activation starts listener. + + Note: OBS connection is managed by the plugin lifecycle hooks, not by individual widget activation. + """ + await obs_widget.activate() + + obs_widget.tasks.start_task.assert_called_once() + # Verify the task name is correct + call_args = obs_widget.tasks.start_task.call_args + assert call_args[0][0] == TASK_EVENT_LISTENER + + +async def test_obs_widget_deactivate(obs_widget): + """Test widget deactivation - tasks are cleaned up by Deck automatically.""" + # Simulate that a task is running + obs_widget.tasks.is_running.return_value = True + + # Deactivate should not stop tasks (Deck handles cleanup) + await obs_widget.deactivate() + + # Verify stop_task was NOT called (cleanup is handled by Deck) + obs_widget.tasks.stop_task.assert_not_called() + + +async def test_obs_widget_listener_relevant_event(obs_widget): + with patch.object(obs_widget, "request_update") as mock_request_update: + with patch.object(obs_widget.plugin, "obs") as mock_obs: + # Mock async iterator + async def mock_listen(): + yield "TestEvent" + + mock_obs.listen.return_value = mock_listen() + + # Run one iteration of the listener + async for event in mock_obs.listen(): + if event in obs_widget.relevant_events: + obs_widget.request_update() + break + + mock_request_update.assert_called_once() + + +async def test_obs_widget_listener_connection_events(obs_widget): + with ( + patch.object(obs_widget, "acquire_wake_lock") as mock_acquire, + patch.object(obs_widget, "release_wake_lock") as mock_release, + patch.object(obs_widget.plugin, "obs") as mock_obs, + ): + # Test ConnectionEstablished + async def mock_listen_established(): + yield "ConnectionEstablished" + + mock_obs.listen.return_value = mock_listen_established() + + async for event in mock_obs.listen(): + if event == "ConnectionEstablished": + obs_widget.acquire_wake_lock() + break + + mock_acquire.assert_called_once() + + # Test ConnectionLost + async def mock_listen_lost(): + yield "ConnectionLost" + + mock_obs.listen.return_value = mock_listen_lost() + + async for event in mock_obs.listen(): + if event == "ConnectionLost": + obs_widget.release_wake_lock() + break + + mock_release.assert_called_once() diff --git a/plugins/obs/tests/test_current_scene.py b/plugins/obs/tests/test_current_scene.py new file mode 100644 index 0000000..e987813 --- /dev/null +++ b/plugins/obs/tests/test_current_scene.py @@ -0,0 +1,119 @@ +from unittest.mock import MagicMock, patch + +from pytest import fixture + +from knoepfe_obs_plugin.config import OBSPluginConfig +from knoepfe_obs_plugin.plugin import OBSPlugin +from knoepfe_obs_plugin.widgets.current_scene import CurrentScene, CurrentSceneConfig + + +@fixture +def mock_plugin(): + return OBSPlugin(OBSPluginConfig()) + + +@fixture +def current_scene_widget(mock_plugin): + return CurrentScene(CurrentSceneConfig(), mock_plugin) + + +def test_current_scene_init(mock_plugin): + """Test CurrentScene widget initialization.""" + widget = CurrentScene(CurrentSceneConfig(), mock_plugin) + assert widget.relevant_events == [ + "ConnectionEstablished", + "ConnectionLost", + "CurrentProgramSceneChanged", + ] + + +async def test_current_scene_update_connected_with_scene(current_scene_widget): + """Test update when connected with a current scene.""" + with patch.object(current_scene_widget.plugin, "obs") as mock_obs: + mock_obs.connected = True + mock_obs.current_scene = "Gaming" + renderer = MagicMock() + + await current_scene_widget.update(renderer) + + renderer.clear.assert_called_once() + renderer.icon_and_text.assert_called_with( + "󰏜", # nf-md-panorama + "Gaming", + icon_size=64, + text_size=16, + icon_color="white", + text_color="white", + ) + + +async def test_current_scene_update_connected_no_scene(current_scene_widget): + """Test update when connected but no scene is set.""" + with patch.object(current_scene_widget.plugin, "obs") as mock_obs: + mock_obs.connected = True + mock_obs.current_scene = None + renderer = MagicMock() + + await current_scene_widget.update(renderer) + + renderer.clear.assert_called_once() + renderer.icon_and_text.assert_called_with( + "󰏜", # nf-md-panorama + "[none]", + icon_size=64, + text_size=16, + icon_color="white", + text_color="white", + ) + + +async def test_current_scene_update_disconnected(current_scene_widget): + """Test update when disconnected.""" + with patch.object(current_scene_widget.plugin, "obs") as mock_obs: + mock_obs.connected = False + renderer = MagicMock() + + await current_scene_widget.update(renderer) + + renderer.clear.assert_called_once() + renderer.icon.assert_called_with( + "󰏜", # nf-md-panorama + color="#202020", + ) + + +async def test_current_scene_update_with_custom_config(mock_plugin): + """Test update with custom configuration.""" + config = CurrentSceneConfig(icon="🎬", connected_color="cyan") + widget = CurrentScene(config, mock_plugin) + + with patch.object(widget.plugin, "obs") as mock_obs: + mock_obs.connected = True + mock_obs.current_scene = "Chatting" + renderer = MagicMock() + + await widget.update(renderer) + + renderer.clear.assert_called_once() + renderer.icon_and_text.assert_called_with( + "🎬", + "Chatting", + icon_size=64, + text_size=16, + icon_color="cyan", + text_color="cyan", + ) + + +def test_current_scene_config(): + """Test that CurrentSceneConfig validates correctly.""" + # Test with defaults + config = CurrentSceneConfig() + assert config.icon == "󰏜" # nf-md-panorama + assert config.connected_color is None + assert config.color == "white" + + # Test with custom values + config = CurrentSceneConfig(icon="🎬", connected_color="cyan") + assert config.icon == "🎬" + assert config.connected_color == "cyan" diff --git a/plugins/obs/tests/test_recording.py b/plugins/obs/tests/test_recording.py new file mode 100644 index 0000000..3c7fe48 --- /dev/null +++ b/plugins/obs/tests/test_recording.py @@ -0,0 +1,123 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +from pytest import fixture + +from knoepfe_obs_plugin.config import OBSPluginConfig +from knoepfe_obs_plugin.plugin import OBSPlugin +from knoepfe_obs_plugin.widgets.recording import Recording, RecordingConfig + + +@fixture +def mock_plugin(): + return OBSPlugin(OBSPluginConfig()) + + +@fixture +def recording_widget(mock_plugin): + return Recording(RecordingConfig(), mock_plugin) + + +def test_recording_init(mock_plugin): + widget = Recording(RecordingConfig(), mock_plugin) + assert not widget.recording + assert not widget.show_help + assert not widget.show_loading + + +async def test_recording_update_disconnected(recording_widget): + with patch.object(recording_widget.plugin, "obs") as mock_obs: + mock_obs.connected = False + renderer = MagicMock() + + await recording_widget.update(renderer) + + renderer.clear.assert_called_once() + renderer.icon.assert_called_with( + "󰕨", # nf-md-video_off + color="#202020", + ) + + +async def test_recording_update_not_recording(recording_widget): + with patch.object(recording_widget.plugin, "obs") as mock_obs: + mock_obs.connected = True + mock_obs.recording = False + renderer = MagicMock() + + await recording_widget.update(renderer) + + renderer.clear.assert_called_once() + renderer.icon.assert_called_with( + "󰕨", # nf-md-video_off + color="white", + ) + + +async def test_recording_update_recording(recording_widget): + with patch.object(recording_widget.plugin, "obs") as mock_obs: + mock_obs.connected = True + mock_obs.recording = True + mock_obs.get_recording_timecode = AsyncMock(return_value="00:01:23.456") + recording_widget.recording = True + renderer = MagicMock() + + await recording_widget.update(renderer) + + # Check icon_and_text call for the recording state + renderer.clear.assert_called_once() + renderer.icon_and_text.assert_called_with( + "󰕧", # nf-md-video + "00:01:23", # timecode without milliseconds + icon_color="red", + text_color="red", + ) + + +async def test_recording_update_show_help(recording_widget): + with patch.object(recording_widget.plugin, "obs") as mock_obs: + mock_obs.recording = False + mock_obs.connected = True + recording_widget.show_help = True + renderer = MagicMock() + + await recording_widget.update(renderer) + + renderer.clear.assert_called_once() + renderer.text_multiline.assert_called_with("long press\nto toggle", size=16) + + +async def test_recording_update_show_loading(recording_widget): + with patch.object(recording_widget.plugin, "obs") as mock_obs: + mock_obs.recording = False + recording_widget.show_loading = True + renderer = MagicMock() + + await recording_widget.update(renderer) + + renderer.clear.assert_called_once() + renderer.icon.assert_called_with( + "󰔟", # nf-md-timer_sand + ) + assert not recording_widget.show_loading + + +def test_recording_config(): + """Test that RecordingConfig validates correctly.""" + # Test with defaults + config = RecordingConfig() + assert config.recording_icon == "󰕧" # nf-md-video + assert config.stopped_icon == "󰕨" # nf-md-video_off + assert config.loading_icon == "󰔟" # nf-md-timer_sand + assert config.recording_color == "red" + assert config.stopped_color is None + assert config.color == "white" + + # Test with custom values + config = RecordingConfig( + recording_icon="🔴", stopped_icon="⏹️", loading_icon="⏳", recording_color="green", stopped_color="blue" + ) + assert config.recording_icon == "🔴" + assert config.stopped_icon == "⏹️" + assert config.loading_icon == "⏳" + assert config.recording_color == "green" + assert config.stopped_color == "blue" diff --git a/plugins/obs/tests/test_streaming.py b/plugins/obs/tests/test_streaming.py new file mode 100644 index 0000000..349d43d --- /dev/null +++ b/plugins/obs/tests/test_streaming.py @@ -0,0 +1,209 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +from pytest import fixture + +from knoepfe_obs_plugin.config import OBSPluginConfig +from knoepfe_obs_plugin.plugin import OBSPlugin +from knoepfe_obs_plugin.widgets.streaming import Streaming, StreamingConfig + + +@fixture +def mock_plugin(): + return OBSPlugin(OBSPluginConfig()) + + +@fixture +def streaming_widget(mock_plugin): + return Streaming(StreamingConfig(), mock_plugin) + + +def test_streaming_init(mock_plugin): + widget = Streaming(StreamingConfig(), mock_plugin) + assert not widget.streaming + assert not widget.show_help + assert not widget.show_loading + + +async def test_streaming_update_disconnected(streaming_widget): + with patch.object(streaming_widget.plugin, "obs") as mock_obs: + mock_obs.connected = False + renderer = MagicMock() + + await streaming_widget.update(renderer) + + renderer.clear.assert_called_once() + renderer.icon.assert_called_with( + "󰄘", # nf-md-cast + color="#202020", + ) + + +async def test_streaming_update_not_streaming(streaming_widget): + with patch.object(streaming_widget.plugin, "obs") as mock_obs: + mock_obs.connected = True + mock_obs.streaming = False + renderer = MagicMock() + + await streaming_widget.update(renderer) + + renderer.clear.assert_called_once() + renderer.icon.assert_called_with( + "󰄘", # nf-md-cast + color="white", + ) + + +async def test_streaming_update_streaming(streaming_widget): + with patch.object(streaming_widget.plugin, "obs") as mock_obs: + mock_obs.connected = True + mock_obs.streaming = True + mock_obs.get_streaming_timecode = AsyncMock(return_value="00:01:23.456") + streaming_widget.streaming = True + renderer = MagicMock() + + await streaming_widget.update(renderer) + + # Check icon_and_text call for the streaming state + renderer.clear.assert_called_once() + renderer.icon_and_text.assert_called_with( + "󰄘", # nf-md-cast + "00:01:23", # timecode without milliseconds + icon_color="red", + text_color="red", + ) + + +async def test_streaming_update_show_help(streaming_widget): + with patch.object(streaming_widget.plugin, "obs") as mock_obs: + mock_obs.streaming = False + mock_obs.connected = True + streaming_widget.show_help = True + renderer = MagicMock() + + await streaming_widget.update(renderer) + + renderer.clear.assert_called_once() + renderer.text_multiline.assert_called_with("long press\nto toggle", size=16) + + +async def test_streaming_update_show_loading(streaming_widget): + with patch.object(streaming_widget.plugin, "obs") as mock_obs: + mock_obs.streaming = False + streaming_widget.show_loading = True + renderer = MagicMock() + + await streaming_widget.update(renderer) + + renderer.clear.assert_called_once() + renderer.icon.assert_called_with( + "󰔟", # nf-md-timer_sand + ) + assert not streaming_widget.show_loading + + +async def test_streaming_triggered_long_press_start(streaming_widget): + """Test long press starts streaming when not streaming.""" + with patch.object(streaming_widget.plugin, "obs") as mock_obs: + mock_obs.connected = True + mock_obs.streaming = False + mock_obs.start_streaming = AsyncMock() + + await streaming_widget.triggered(long_press=True) + + mock_obs.start_streaming.assert_called_once() + assert streaming_widget.show_loading + + +async def test_streaming_triggered_long_press_stop(streaming_widget): + """Test long press stops streaming when streaming.""" + with patch.object(streaming_widget.plugin, "obs") as mock_obs: + mock_obs.connected = True + mock_obs.streaming = True + mock_obs.stop_streaming = AsyncMock() + + await streaming_widget.triggered(long_press=True) + + mock_obs.stop_streaming.assert_called_once() + assert streaming_widget.show_loading + + +async def test_streaming_triggered_long_press_disconnected(streaming_widget): + """Test long press does nothing when disconnected.""" + with patch.object(streaming_widget.plugin, "obs") as mock_obs: + mock_obs.connected = False + mock_obs.start_streaming = AsyncMock() + mock_obs.stop_streaming = AsyncMock() + + await streaming_widget.triggered(long_press=True) + + mock_obs.start_streaming.assert_not_called() + mock_obs.stop_streaming.assert_not_called() + + +async def test_streaming_triggered_short_press(streaming_widget): + """Test short press shows help text.""" + streaming_widget.request_update = MagicMock() + + with patch("knoepfe_obs_plugin.widgets.streaming.sleep", AsyncMock()): + await streaming_widget.triggered(long_press=False) + + # Should set show_help and request updates + assert streaming_widget.request_update.call_count == 2 + + +async def test_streaming_update_starts_periodic_update(streaming_widget): + """Test that update starts periodic updates when streaming starts.""" + streaming_widget.request_periodic_update = MagicMock() + streaming_widget.stop_periodic_update = MagicMock() + + with patch.object(streaming_widget.plugin, "obs") as mock_obs: + mock_obs.connected = True + mock_obs.streaming = True + mock_obs.get_streaming_timecode = AsyncMock(return_value="00:00:00.000") + key = MagicMock() + + await streaming_widget.update(key) + + streaming_widget.request_periodic_update.assert_called_once_with(1.0) + + +async def test_streaming_update_stops_periodic_update(streaming_widget): + """Test that update stops periodic updates when streaming stops.""" + streaming_widget.streaming = True # Widget thinks it's streaming + streaming_widget.request_periodic_update = MagicMock() + streaming_widget.stop_periodic_update = MagicMock() + + with patch.object(streaming_widget.plugin, "obs") as mock_obs: + mock_obs.connected = True + mock_obs.streaming = False # But OBS says it's not + key = MagicMock() + + await streaming_widget.update(key) + + streaming_widget.stop_periodic_update.assert_called_once() + + +def test_streaming_config(): + """Test that StreamingConfig validates correctly.""" + # Test with defaults + config = StreamingConfig() + assert config.streaming_icon == "󰄘" # nf-md-cast + assert config.stopped_icon == "󰄘" # nf-md-cast + assert config.loading_icon == "󰔟" # nf-md-timer_sand + assert config.streaming_color == "red" + assert config.stopped_color is None + assert config.color == "white" + + # Test with custom values + config = StreamingConfig( + streaming_icon="📡", + stopped_icon="🚫", + loading_icon="⏳", + streaming_color="green", + stopped_color="blue", + ) + assert config.streaming_icon == "📡" + assert config.stopped_icon == "🚫" + assert config.loading_icon == "⏳" + assert config.streaming_color == "green" + assert config.stopped_color == "blue" diff --git a/plugins/obs/tests/test_switch_scene.py b/plugins/obs/tests/test_switch_scene.py new file mode 100644 index 0000000..c09a05f --- /dev/null +++ b/plugins/obs/tests/test_switch_scene.py @@ -0,0 +1,178 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from pydantic import ValidationError + +from knoepfe_obs_plugin.config import OBSPluginConfig +from knoepfe_obs_plugin.plugin import OBSPlugin +from knoepfe_obs_plugin.widgets.switch_scene import SwitchScene, SwitchSceneConfig + + +@pytest.fixture +def mock_plugin(): + return OBSPlugin(OBSPluginConfig()) + + +@pytest.fixture +def switch_scene_widget(mock_plugin): + return SwitchScene(SwitchSceneConfig(scene="Gaming"), mock_plugin) + + +def test_switch_scene_init(mock_plugin): + """Test SwitchScene widget initialization.""" + widget = SwitchScene(SwitchSceneConfig(scene="Gaming"), mock_plugin) + assert widget.config.scene == "Gaming" + assert widget.relevant_events == [ + "ConnectionEstablished", + "ConnectionLost", + "SwitchScenes", + ] + + +async def test_switch_scene_update_disconnected(switch_scene_widget): + """Test update when disconnected.""" + with patch.object(switch_scene_widget.plugin, "obs") as mock_obs: + mock_obs.connected = False + renderer = MagicMock() + + await switch_scene_widget.update(renderer) + + renderer.clear.assert_called_once() + renderer.icon_and_text.assert_called_with( + "󰏜", # nf-md-panorama + "Gaming", + icon_size=64, + text_size=16, + icon_color="#202020", + text_color="#202020", + ) + + +async def test_switch_scene_update_active(switch_scene_widget): + """Test update when the configured scene is active.""" + with patch.object(switch_scene_widget.plugin, "obs") as mock_obs: + mock_obs.connected = True + mock_obs.current_scene = "Gaming" + renderer = MagicMock() + + await switch_scene_widget.update(renderer) + + renderer.clear.assert_called_once() + renderer.icon_and_text.assert_called_with( + "󰏜", # nf-md-panorama + "Gaming", + icon_size=64, + text_size=16, + icon_color="red", + text_color="red", + ) + + +async def test_switch_scene_update_inactive(switch_scene_widget): + """Test update when a different scene is active.""" + with patch.object(switch_scene_widget.plugin, "obs") as mock_obs: + mock_obs.connected = True + mock_obs.current_scene = "Chatting" + renderer = MagicMock() + + await switch_scene_widget.update(renderer) + + renderer.clear.assert_called_once() + renderer.icon_and_text.assert_called_with( + "󰏜", # nf-md-panorama + "Gaming", + icon_size=64, + text_size=16, + icon_color="white", + text_color="white", + ) + + +async def test_switch_scene_triggered_connected(switch_scene_widget): + """Test triggered when connected switches to the scene.""" + with patch.object(switch_scene_widget.plugin, "obs") as mock_obs: + mock_obs.connected = True + mock_obs.set_scene = AsyncMock() + + await switch_scene_widget.triggered() + + mock_obs.set_scene.assert_called_once_with("Gaming") + + +async def test_switch_scene_triggered_disconnected(switch_scene_widget): + """Test triggered when disconnected does nothing.""" + with patch.object(switch_scene_widget.plugin, "obs") as mock_obs: + mock_obs.connected = False + mock_obs.set_scene = AsyncMock() + + await switch_scene_widget.triggered() + + mock_obs.set_scene.assert_not_called() + + +async def test_switch_scene_triggered_long_press(switch_scene_widget): + """Test triggered with long press (should behave the same as short press).""" + with patch.object(switch_scene_widget.plugin, "obs") as mock_obs: + mock_obs.connected = True + mock_obs.set_scene = AsyncMock() + + await switch_scene_widget.triggered(long_press=True) + + mock_obs.set_scene.assert_called_once_with("Gaming") + + +async def test_switch_scene_update_with_custom_config(mock_plugin): + """Test update with custom configuration.""" + config = SwitchSceneConfig( + scene="Chatting", + icon="🎮", + active_color="green", + inactive_color="gray", + ) + widget = SwitchScene(config, mock_plugin) + + with patch.object(widget.plugin, "obs") as mock_obs: + mock_obs.connected = True + mock_obs.current_scene = "Chatting" + renderer = MagicMock() + + await widget.update(renderer) + + renderer.clear.assert_called_once() + renderer.icon_and_text.assert_called_with( + "🎮", + "Chatting", + icon_size=64, + text_size=16, + icon_color="green", + text_color="green", + ) + + +def test_switch_scene_config(): + """Test that SwitchSceneConfig validates correctly.""" + # Test with required scene parameter + config = SwitchSceneConfig(scene="Gaming") + assert config.scene == "Gaming" + assert config.icon == "󰏜" # nf-md-panorama + assert config.active_color == "red" + assert config.inactive_color is None + assert config.color == "white" + + # Test with custom values + config = SwitchSceneConfig( + scene="Chatting", + icon="🎮", + active_color="green", + inactive_color="gray", + ) + assert config.scene == "Chatting" + assert config.icon == "🎮" + assert config.active_color == "green" + assert config.inactive_color == "gray" + + +def test_switch_scene_config_requires_scene(): + """Test that SwitchSceneConfig requires scene parameter.""" + with pytest.raises(ValidationError): + SwitchSceneConfig() # type: ignore[call-arg] diff --git a/pyproject.toml b/pyproject.toml index 556658a..2395a5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,72 +1,117 @@ [project] name = "knoepfe" -version = "0.1.1" +dynamic = ["version"] description = "Connect and control Elgato Stream Decks" authors = [ { name = "Simon Hayessen", email = "simon@lnqs.io" }, - { name = "Simon Brakhane", email = "simon@brakhane.net" } + { name = "Simon Brakhane", email = "simon@brakhane.net" }, ] -requires-python = ">=3.10" +requires-python = ">=3.11" readme = "README.md" license = "GPL-3.0-or-later" +keywords = ["streamdeck", "elgato"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia", + "Topic :: System :: Hardware", +] dependencies = [ - "schema>=0.7.7,<0.8", - "appdirs>=1.4.4,<2", - "docopt>=0.6.2,<0.7", - "streamdeck>=0.9.5,<0.10", - "Pillow>=10.4.0,<11", - "aiorun>=2024.8.1,<2025", - "pulsectl-asyncio>=1.2.1,<2", - "pulsectl>=24.8.0,<25", - "simpleobsws>=1.4.0", + "streamdeck>=0.9.5", + "Pillow>=10.4.0", + "platformdirs>=4.4.0", + "click>=8.2.1", + "aiorun>=2025.1.1", + "hidapi>=0.14.0.post4", + "python-fontconfig>=0.6.2.post1", + "pydantic>=2.11.9", + "pydantic-settings>=2.11.0", ] +# Optional dependencies for different widget groups +[project.optional-dependencies] +obs = ["knoepfe-obs-plugin"] +audio = ["knoepfe-audio-plugin"] +all = ["knoepfe-audio-plugin", "knoepfe-obs-plugin"] + [project.urls] Homepage = "https://github.com/lnqs/knoepfe" Repository = "https://github.com/lnqs/knoepfe" [project.scripts] -knoepfe = "knoepfe.__main__:main" +knoepfe = "knoepfe.cli:main" -[dependency-groups] -dev = [ - "pre-commit>=3.8.0,<4", - "pytest>=8.3.3,<9", - "pytest-asyncio>=0.24.0,<0.25", - "pytest-cov>=5.0.0,<6", - "types-appdirs>=1.4.3.5,<2", - "types-docopt>=0.6.11.4,<0.7", - "types-pillow>=10.2.0.20240822,<11", -] +# Plugin entry points +[project.entry-points."knoepfe.plugins"] +builtin = "knoepfe.plugins.builtin:BuiltinPluginDescriptor" + +# Workspace configuration for development +[tool.uv.workspace] +members = ["plugins/*"] + +[tool.uv.sources] +knoepfe-audio-plugin = { workspace = true } +knoepfe-obs-plugin = { workspace = true } +knoepfe-example-plugin = { workspace = true } [build-system] requires = ["hatchling"] build-backend = "hatchling.build" -[tool.isort] -profile = "black" -extend_skip_glob = [".venv/*"] - -[tool.black] -extend-exclude = ''' -( - \.venv/ # exclude .venv directory -) -''' - -[tool.mypy] -strict = true - -[[tool.mypy.overrides]] -module = [ - "StreamDeck.*", - "pulsectl.*", - "pulsectl_asyncio.*", - "aiorun.*", - "schema.*", +[tool.hatch.version] +path = "src/knoepfe/__init__.py" + +[dependency-groups] +dev = [ + "pytest>=8.4.2", + "pytest-asyncio>=0.24.0", + "pytest-cov>=5.0.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/knoepfe"] + +[tool.hatch.build.targets.sdist] +include = [ + "src/", + "tests/", + "README.md", + "LICENSE.md", + "CHANGELOG.md", + "pyproject.toml", ] -ignore_missing_imports = true + +[tool.ruff] +lint.select = ["B", "D", "F", "I", "T", "Q"] +lint.ignore = ["D100", "D101", "D102", "D103", "D104", "D107", "D415"] +line-length = 120 +include = ["src/**/*.py", "tests/**/*.py", "plugins/**/*.py"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["D"] + +[tool.ruff.format] +docstring-code-format = true + +[tool.pyright] +include = ["src", "tests"] [tool.pytest.ini_options] -filterwarnings = "ignore::DeprecationWarning:pulsectl_asyncio" -addopts = "--cov=knoepfe --cov-report=term-missing --asyncio-mode=auto" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.coverage.report] +fail_under = 100 +exclude_lines = ["if __name__ == .__main__.:", "if sys.platform"] +exclude_also = [ + "no cover: start(?s:.)*?no cover: stop", + "\\A(?s:.*# pragma: exclude file.*)\\Z", +] diff --git a/scripts/test-all.sh b/scripts/test-all.sh new file mode 100755 index 0000000..824b90a --- /dev/null +++ b/scripts/test-all.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -e + +echo "Running core tests..." +uv run pytest tests/ -v + +echo -e "\nRunning audio plugin tests..." +pushd plugins/audio +uv run pytest tests/ -v +popd + +echo -e "\nRunning example plugin tests..." +pushd plugins/example +uv run pytest tests/ -v +popd + +echo -e "\nRunning OBS plugin tests..." +pushd plugins/obs +uv run pytest tests/ -v +popd + +echo -e "\nAll tests passed!" \ No newline at end of file diff --git a/src/knoepfe/__init__.py b/src/knoepfe/__init__.py new file mode 100644 index 0000000..d3ec452 --- /dev/null +++ b/src/knoepfe/__init__.py @@ -0,0 +1 @@ +__version__ = "0.2.0" diff --git a/src/knoepfe/cli.py b/src/knoepfe/cli.py new file mode 100644 index 0000000..d5e23c7 --- /dev/null +++ b/src/knoepfe/cli.py @@ -0,0 +1,153 @@ +"""CLI commands and main entry point for knoepfe.""" + +import json +import logging +from pathlib import Path + +import click + +from . import __version__ +from .core.app import Knoepfe +from .plugins import PluginManager +from .transport import apply_transport_patches +from .utils.logging import configure_logging + +logger = logging.getLogger(__name__) + + +@click.group(invoke_without_command=True) +@click.option("-v", "--verbose", is_flag=True, help="Print debug information.") +@click.option("--config", type=click.Path(exists=True, path_type=Path), help="Config file to use.") +@click.option("--mock-device", is_flag=True, help="Don't connect to a real device. Mainly useful for debugging.") +@click.option("--no-cython-hid", is_flag=True, help="Disable experimental CythonHIDAPI transport.") +@click.version_option(version=__version__) +@click.pass_context +def main(ctx: click.Context, verbose: bool, config: Path | None, mock_device: bool, no_cython_hid: bool) -> None: + """Connect and control Elgato Stream Decks.""" + # Apply transport patches and optionally enable CythonHIDAPI + apply_transport_patches(enable_cython_hid=not no_cython_hid) + + # Configure logging based on verbose flag + configure_logging(verbose=verbose) + + # Store options in context for subcommands + ctx.ensure_object(dict) + ctx.obj["verbose"] = verbose + ctx.obj["config"] = config + ctx.obj["mock_device"] = mock_device + ctx.obj["no_cython_hid"] = no_cython_hid + + # If no subcommand is provided, run the main application + if ctx.invoked_subcommand is None: + knoepfe = Knoepfe() + knoepfe.run_sync(config, mock_device) + + +@main.group() +def widgets() -> None: + """Manage and inspect widgets.""" + pass + + +@widgets.command("list") +def widgets_list() -> None: + """List all available widgets.""" + plugin_manager = PluginManager() + + if not plugin_manager.widgets: + click.echo("No widgets available. Install widget packages like 'knoepfe[obs]'") + return + + click.echo("Available widgets:") + for widget_info in sorted(plugin_manager.widgets.values(), key=lambda w: w.name): + try: + doc = widget_info.description or widget_info.widget_class.__doc__ or "No description" + click.echo(f" {widget_info.name}: {doc}") + except Exception as e: + click.echo(f" {widget_info.name}: Error getting info - {e}", err=True) + + +@widgets.command("info") +@click.argument("widget_name") +def widgets_info(widget_name: str) -> None: + """Show detailed information about a widget.""" + plugin_manager = PluginManager() + + if widget_name not in plugin_manager.widgets: + click.echo(f"Error: Widget '{widget_name}' not found", err=True) + click.echo("Try 'knoepfe widgets list' to see available widgets") + return + + widget = plugin_manager.widgets[widget_name] + click.echo(f"Name: {widget.name}") + click.echo(f"Class: {widget.widget_class.__name__}") + click.echo(f"Module: {widget.widget_class.__module__}") + click.echo(f"Description: {widget.description or widget.widget_class.__doc__ or 'No description available'}") + click.echo(f"Plugin: {widget.plugin_info.name} v{widget.plugin_info.version}") + + # Get configuration schema from the widget's config type + try: + schema = widget.config_type.model_json_schema() + click.echo("\nConfiguration Schema:") + click.echo(json.dumps(schema, indent=2)) + except Exception as e: + click.echo(f"Error getting configuration schema: {e}", err=True) + + +@main.group() +def plugins() -> None: + """Manage and inspect plugins.""" + pass + + +@plugins.command("list") +def plugins_list() -> None: + """List all available plugins.""" + plugin_manager = PluginManager() + + # Filter out builtin plugin + plugins = {name: info for name, info in plugin_manager.plugins.items() if name != "builtin"} + + if not plugins: + click.echo("No plugins available.") + return + + click.echo("Available plugins:") + for plugin_info in sorted(plugins.values(), key=lambda p: p.name): + widget_count = len(plugin_info.widgets) + click.echo(f" {plugin_info.name} v{plugin_info.version}: {plugin_info.description} ({widget_count} widgets)") + + +@plugins.command("info") +@click.argument("plugin_name") +def plugins_info(plugin_name: str) -> None: + """Show detailed information about a plugin.""" + plugin_manager = PluginManager() + + if plugin_name not in plugin_manager.plugins: + click.echo(f"Error: Plugin '{plugin_name}' not found", err=True) + click.echo("Try 'knoepfe plugins list' to see available plugins") + return + + plugin = plugin_manager.plugins[plugin_name] + click.echo(f"Name: {plugin.name}") + click.echo(f"Version: {plugin.version}") + click.echo(f"Description: {plugin.description}") + click.echo(f"Class: {plugin.descriptor_class.__name__}") + click.echo(f"Module: {plugin.descriptor_class.__module__}") + + click.echo(f"\nWidgets ({len(plugin.widgets)}):") + if plugin.widgets: + for widget in sorted(plugin.widgets, key=lambda w: w.name): + desc = widget.description or "No description" + click.echo(f" {widget.name}: {desc}") + else: + click.echo(" No widgets provided") + + # Show configuration schema + try: + schema = plugin.config.model_json_schema() + click.echo("\nConfiguration Schema:") + click.echo(json.dumps(schema, indent=2)) + except Exception as e: + click.echo(f"Error getting configuration schema: {e}", err=True) diff --git a/src/knoepfe/config/__init__.py b/src/knoepfe/config/__init__.py new file mode 100644 index 0000000..6aaa2de --- /dev/null +++ b/src/knoepfe/config/__init__.py @@ -0,0 +1,21 @@ +"""Configuration system for knoepfe using Pydantic models and Python DSL.""" + +from knoepfe.config.base import BaseConfig +from knoepfe.config.loader import ConfigError, create_decks, create_widget, load_config +from knoepfe.config.models import DeckConfig, DeviceConfig, GlobalConfig, WidgetSpec +from knoepfe.config.plugin import PluginConfig +from knoepfe.config.widget import WidgetConfig + +__all__ = [ + "BaseConfig", + "ConfigError", + "DeckConfig", + "DeviceConfig", + "GlobalConfig", + "PluginConfig", + "WidgetConfig", + "WidgetSpec", + "create_decks", + "create_widget", + "load_config", +] diff --git a/src/knoepfe/config/base.py b/src/knoepfe/config/base.py new file mode 100644 index 0000000..e7c77d7 --- /dev/null +++ b/src/knoepfe/config/base.py @@ -0,0 +1,15 @@ +"""Base configuration class for all Pydantic models.""" + +from pydantic import BaseModel, ConfigDict + + +class BaseConfig(BaseModel): + """Base class for all configuration models. + + Provides strict validation and forbids extra fields by default. + """ + + model_config = ConfigDict( + extra="forbid", # Strict by default - no extra fields allowed + validate_assignment=True, # Validate on attribute assignment + ) diff --git a/src/knoepfe/config/loader.py b/src/knoepfe/config/loader.py new file mode 100644 index 0000000..57d5bdc --- /dev/null +++ b/src/knoepfe/config/loader.py @@ -0,0 +1,165 @@ +"""Configuration loading and processing functions.""" + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +import platformdirs +from pydantic import ValidationError +from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, TomlConfigSettingsSource + +from ..config.models import GlobalConfig, WidgetSpec +from ..utils.exceptions import WidgetNotFoundError + +if TYPE_CHECKING: + from ..core.deck import Deck + from ..plugins.manager import PluginManager + from ..widgets.widget import Widget + +logger = logging.getLogger(__name__) + + +class ConfigError(Exception): + """Configuration-related errors.""" + + pass + + +def _create_config_with_file(config_path: Path) -> GlobalConfig: + """Create GlobalConfig with explicit file path. + + This creates a custom settings source that loads from the specified file path. + """ + + class ConfigWithFile(GlobalConfig): + """GlobalConfig with custom file path.""" + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + """Customize settings sources with explicit file path.""" + # Return sources in priority order: env vars > TOML file > init + return ( + env_settings, + TomlConfigSettingsSource(settings_cls, config_path), + init_settings, + ) + + return ConfigWithFile() + + +def load_config(path: Path | None = None) -> GlobalConfig: + """Load configuration from TOML file with environment variable support. + + Args: + path: Optional path to config file. If None, uses default locations. + + Returns: + Loaded and validated GlobalConfig + + Raises: + ConfigError: If configuration is invalid or cannot be loaded + """ + # Resolve config file path + if path: + logger.info(f"Using config file: {path}") + config_path = path + else: + # Check user config directory + config_dir = Path(platformdirs.user_config_dir("knoepfe")) + user_config = config_dir / "knoepfe.toml" + + if user_config.exists(): + logger.info(f"Using user config: {user_config}") + config_path = user_config + else: + # No default config - user must create one + raise ConfigError( + f"No configuration file found. Please create a config file at {user_config}\n" + "See the documentation for examples." + ) + + # Check if file exists before attempting to load + if not config_path.exists(): + raise ConfigError(f"Configuration file not found: {config_path}") + + try: + # Create GlobalConfig instance with explicit file path + # The custom settings_customise_sources method will: + # 1. Load from TOML file via TomlConfigSettingsSource with explicit path + # 2. Override with environment variables (KNOEPFE_ prefix) + config = _create_config_with_file(config_path) + return config + + except ValidationError as e: + raise ConfigError("Configuration validation failed") from e + except Exception as e: + raise ConfigError(f"Failed to load configuration: {e}") from e + + +def create_decks(config: GlobalConfig, plugin_manager: "PluginManager") -> list["Deck"]: + """Create deck instances from configuration. + + Args: + config: Global configuration + plugin_manager: Plugin manager for widget creation + + Returns: + List of all decks + + Raises: + ConfigError: If deck creation fails + """ + # Late import to avoid circular dependency + from ..core.deck import Deck + + decks = [] + + for deck_config in config.decks: + widgets = [] + + for widget_spec in deck_config.widgets: + try: + widget = create_widget(widget_spec, plugin_manager) + widgets.append(widget) + except ValidationError as e: + raise ConfigError(f"Invalid config for widget {widget_spec.type} in deck {deck_config.name}") from e + except Exception as e: + raise ConfigError(f"Failed to create widget {widget_spec.type} in deck {deck_config.name}") from e + + deck = Deck(deck_config.name, widgets, config) + decks.append(deck) + + return decks + + +def create_widget(spec: WidgetSpec, plugin_manager: "PluginManager") -> "Widget": + """Create a single widget instance. + + Args: + spec: Widget specification from config + plugin_manager: Plugin manager for widget lookup + + Returns: + Instantiated widget + + Raises: + WidgetNotFoundError: If widget type is not found + ValidationError: If widget config is invalid + """ + if spec.type not in plugin_manager.widgets: + raise WidgetNotFoundError(spec.type) + + widget_info = plugin_manager.widgets[spec.type] + + # Create and validate typed config from spec + config = widget_info.config_type(**spec.config) + + # Instantiate widget with validated config and plugin instance + return widget_info.widget_class(config, widget_info.plugin_info.plugin) diff --git a/src/knoepfe/config/models.py b/src/knoepfe/config/models.py new file mode 100644 index 0000000..36b6ffd --- /dev/null +++ b/src/knoepfe/config/models.py @@ -0,0 +1,141 @@ +"""Core configuration models for knoepfe.""" + +from typing import Any + +from pydantic import Field, model_validator +from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict + +from knoepfe.config.base import BaseConfig + + +class DeviceConfig(BaseConfig): + """Stream Deck device configuration.""" + + brightness: int = Field(default=100, ge=0, le=100, description="Display brightness percentage") + sleep_timeout: float | None = Field(default=10.0, ge=0, description="Seconds until sleep, 0 or None to disable") + device_poll_frequency: int = Field(default=5, ge=1, le=1000, description="Hardware polling rate in Hz") + default_text_font: str = Field(default="Roboto", description="Default font for text rendering") + default_icons_font: str = Field(default="RobotoMono Nerd Font", description="Default font for icons rendering") + serial_number: str | None = Field( + default=None, description="Device serial number to connect to, None for first available" + ) + + +class WidgetSpec(BaseConfig): + """Specification for a widget instance. + + Supports flattened configuration where widget properties can be specified + at the top level alongside 'type', making TOML configs more concise. + + Example TOML (flattened): + [[decks.widgets]] + type = "Clock" + font = "Roboto" + color = "#fefefe" + + This is automatically converted to: + type = "Clock" + config = { font = "Roboto", color = "#fefefe" } + """ + + model_config = {"extra": "allow"} # Allow extra fields for flattening + + type: str = Field(..., description="Widget type name") + config: dict[str, Any] = Field(default_factory=dict, description="Widget-specific configuration") + + @model_validator(mode="before") + @classmethod + def flatten_config(cls, data: Any) -> Any: + """Move all non-'type' fields into the 'config' dict for cleaner TOML syntax. + + This allows users to write: + [[decks.widgets]] + type = "Clock" + font = "Roboto" + + Instead of: + [[decks.widgets]] + type = "Clock" + [decks.widgets.config] + font = "Roboto" + """ + if not isinstance(data, dict): + return data + + # If 'config' key already exists, merge with top-level fields + existing_config = data.get("config", {}) + + # Extract 'type' field + widget_type = data.get("type") + if not widget_type: + return data + + # Move all other fields into config + flattened_config = {} + for key, value in data.items(): + if key not in ("type", "config"): + flattened_config[key] = value + + # Merge with existing config (existing config takes precedence) + flattened_config.update(existing_config) + + return {"type": widget_type, "config": flattened_config} + + +class DeckConfig(BaseConfig): + """Configuration for a deck of widgets.""" + + name: str = Field(..., description="Unique deck identifier") + widgets: list[WidgetSpec] = Field(default_factory=list, description="Widgets in this deck") + + +class GlobalConfig(BaseSettings): + """Root configuration object using pydantic-settings for TOML support. + + This class loads configuration from TOML files and supports environment variable + overrides with the KNOEPFE_ prefix. + + Decks are specified using table syntax: [deck.main], [deck.scenes], etc. + """ + + model_config = SettingsConfigDict( + env_prefix="KNOEPFE_", + env_nested_delimiter="__", + extra="forbid", + validate_assignment=True, + ) + + device: DeviceConfig = Field(default_factory=DeviceConfig) + plugins: dict[str, dict[str, Any]] = Field(default_factory=dict, description="Raw plugin configs") + deck: dict[str, list[WidgetSpec]] = Field(default_factory=dict, description="Deck configurations by name") + + @model_validator(mode="after") + def validate_and_convert_decks(self) -> "GlobalConfig": + """Ensure a 'main' deck is defined and convert deck dict to list format.""" + if "main" not in self.deck: + raise ValueError("A 'main' deck is required") + return self + + @property + def decks(self) -> list[DeckConfig]: + """Convert deck dictionary to list of DeckConfig objects.""" + return [DeckConfig(name=name, widgets=widgets) for name, widgets in self.deck.items()] + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + """Customize settings sources to load TOML file with env var overrides.""" + from pydantic_settings import TomlConfigSettingsSource + + # Return sources in priority order: env vars > TOML file > init + return ( + env_settings, + TomlConfigSettingsSource(settings_cls), + init_settings, + ) diff --git a/src/knoepfe/config/plugin.py b/src/knoepfe/config/plugin.py new file mode 100644 index 0000000..359a306 --- /dev/null +++ b/src/knoepfe/config/plugin.py @@ -0,0 +1,20 @@ +"""Base configuration class for plugins.""" + +from pydantic import Field + +from knoepfe.config.base import BaseConfig + + +class PluginConfig(BaseConfig): + """Base class for plugin configurations. + + All plugin-specific configuration classes should inherit from this. + """ + + enabled: bool = Field(default=True, description="Whether plugin is enabled") + + +class EmptyPluginConfig(PluginConfig): + """Empty configuration for plugins that don't need additional config fields.""" + + pass diff --git a/src/knoepfe/config/widget.py b/src/knoepfe/config/widget.py new file mode 100644 index 0000000..8877f66 --- /dev/null +++ b/src/knoepfe/config/widget.py @@ -0,0 +1,23 @@ +"""Base configuration class for widgets.""" + +from pydantic import Field + +from knoepfe.config.base import BaseConfig + + +class WidgetConfig(BaseConfig): + """Base class for widget configurations. + + Widgets define their own fields by subclassing this class. + """ + + index: int | None = Field(default=None, description="Display position index (None = next available position)") + switch_deck: str | None = Field(default=None, description="Deck to switch to when widget is pressed") + font: str | None = Field(default=None, description="Font family and style (e.g., 'sans:style=Bold')") + color: str = Field(default="white", description="Primary color for text/icons") + + +class EmptyConfig(WidgetConfig): + """Empty configuration for widgets that don't need additional config fields.""" + + pass diff --git a/src/knoepfe/core/__init__.py b/src/knoepfe/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/knoepfe/core/actions.py b/src/knoepfe/core/actions.py new file mode 100644 index 0000000..435eb7d --- /dev/null +++ b/src/knoepfe/core/actions.py @@ -0,0 +1,28 @@ +"""Actions that widgets can request from the deck management system.""" + +from dataclasses import dataclass +from enum import Enum + + +class WidgetActionType(Enum): + """Types of actions a widget can request.""" + + SWITCH_DECK = "switch_deck" + + +@dataclass +class WidgetAction: + """Base class for widget actions.""" + + action_type: WidgetActionType + + +@dataclass +class SwitchDeckAction(WidgetAction): + """Action to switch to a different deck.""" + + target_deck: str + + def __init__(self, target_deck: str): + super().__init__(WidgetActionType.SWITCH_DECK) + self.target_deck = target_deck diff --git a/src/knoepfe/core/app.py b/src/knoepfe/core/app.py new file mode 100644 index 0000000..e1d6628 --- /dev/null +++ b/src/knoepfe/core/app.py @@ -0,0 +1,139 @@ +"""Main application class for knoepfe.""" + +import logging +from asyncio import sleep +from pathlib import Path + +from aiorun import run +from StreamDeck.DeviceManager import DeviceManager +from StreamDeck.Devices.StreamDeck import StreamDeck +from StreamDeck.Transport.Transport import TransportError + +from ..config.loader import create_decks, load_config +from ..plugins import PluginManager +from .deckmanager import DeckManager + +logger = logging.getLogger(__name__) + + +class Knoepfe: + """Main application class for Knoepfe Stream Deck control.""" + + def __init__(self) -> None: + self.device = None + self.plugin_manager = None + + async def run(self, config_path: Path | None, mock_device: bool = False) -> None: + """Run the main application loop. + + Args: + config_path: Path to configuration file, or None to use default + mock_device: If True, use a mock device instead of real hardware + """ + logger.debug("Loading configuration") + config = load_config(config_path) + + logger.debug("Initializing plugin manager with configuration") + self.plugin_manager = PluginManager(config.plugins) + + logger.debug("Creating decks") + decks = create_decks(config, self.plugin_manager) + + while True: + device = await self.connect_device(config, mock_device) + + try: + deck_manager = DeckManager(decks, config, device) + await deck_manager.run() + except TransportError: + logger.debug("Transport error, trying to reconnect") + continue + + async def connect_device(self, config, mock_device: bool = False) -> StreamDeck: + """Connect to a Stream Deck device. + + Args: + config: Global configuration containing device settings + mock_device: If True, use a mock device instead of real hardware + + Returns: + Connected StreamDeck device + """ + target_serial = config.device.serial_number + + if mock_device: + logger.info("Using mock device with dummy transport") + device_manager = DeviceManager(transport="dummy") + devices = device_manager.enumerate() + device = devices[0] # Use the first dummy device + else: + if target_serial: + logger.info(f"Searching for device with serial number: {target_serial}") + else: + logger.info("Searching for devices") + device = None + + while True: + devices = DeviceManager().enumerate() + + if target_serial: + # Filter devices by serial number + for d in devices: + try: + d.open() + serial = d.get_serial_number() + d.close() + if serial == target_serial: + device = d + break + except Exception as e: + logger.debug(f"Error checking device serial: {e}") + continue + + if device: + break + + if len(devices) > 0: + logger.debug(f"Found {len(devices)} device(s), but none match serial {target_serial}") + else: + # Use first available device (default behavior) + if len(devices): + device = devices[0] + break + + await sleep(1.0) + + device.open() + device.reset() + + logger.info( + f"Connected to {device.deck_type()} {device.get_serial_number()} " + f"(Firmware {device.get_firmware_version()}, {device.key_layout()[0]}x{device.key_layout()[1]} keys)" + ) + + return device + + def shutdown(self) -> None: + """Shutdown the application and clean up resources.""" + if self.device: + logger.debug("Closing device") + self.device.reset() + self.device.close() + + # Shutdown all plugins + if self.plugin_manager: + logger.debug("Shutting down plugins") + self.plugin_manager.shutdown_all() + + def run_sync(self, config_path: Path | None, mock_device: bool = False) -> None: + """Synchronous wrapper for running the application. + + Args: + config_path: Path to configuration file, or None to use default + mock_device: If True, use a mock device instead of real hardware + """ + run( + self.run(config_path, mock_device), + stop_on_unhandled_errors=True, + shutdown_callback=lambda _: self.shutdown(), + ) diff --git a/src/knoepfe/core/deck.py b/src/knoepfe/core/deck.py new file mode 100644 index 0000000..ddeffad --- /dev/null +++ b/src/knoepfe/core/deck.py @@ -0,0 +1,143 @@ +import asyncio +import logging +from asyncio import Event + +from StreamDeck.Devices.StreamDeck import StreamDeck +from StreamDeck.ImageHelpers import PILHelper + +from ..config import ConfigError +from ..config.models import GlobalConfig +from ..rendering import Renderer +from ..utils.wakelock import WakeLock +from ..widgets.widget import UpdateResult, Widget +from .actions import WidgetAction + +logger = logging.getLogger(__name__) + + +class Deck: + def __init__(self, id: str, widgets: list[Widget], global_config: GlobalConfig) -> None: + self.id = id + self.global_config = global_config + # Assign widgets to indices based on their config.index + self.widgets = self._assign_indices(widgets) + + def _assign_indices(self, widgets: list[Widget]) -> list[Widget]: + """Assign widgets to physical key indices. + + Widgets with explicit indices are placed at those positions. + Widgets without indices fill in the gaps starting from 0. + + Args: + widgets: List of widgets from config + + Returns: + Sparse list with widgets at their assigned indices, None for empty positions + """ + # Step 1: Separate widgets with explicit indices from those without + explicit: dict[int, Widget] = {} + none_list: list[Widget] = [] + + for widget in widgets: + if widget.config.index is not None: + index = widget.config.index + # Step 2: Validate indices + if index < 0: + raise ConfigError(f"Widget index must be non-negative, got {index} in deck '{self.id}'") + if index in explicit: + raise ConfigError(f"Duplicate widget index {index} in deck '{self.id}'") + explicit[index] = widget + else: + none_list.append(widget) + + # Step 3: Build the ordered list and assign indices to unindexed widgets + # Determine the range we need to cover (highest explicit index or enough for all widgets) + if explicit: + max_explicit = max(explicit.keys()) + # We need at least enough positions for all widgets + max_pos = max(max_explicit, len(widgets) - 1) + else: + max_pos = len(widgets) - 1 + + result = [] + none_i = 0 + + for pos in range(max_pos + 1): + if pos in explicit: + # Place widget with explicit index + result.append(explicit[pos]) + elif none_i < len(none_list): + # Fill gap with next unindexed widget and assign it this index + widget = none_list[none_i] + widget.config.index = pos + result.append(widget) + none_i += 1 + + return result + + async def activate(self, device: StreamDeck, update_requested_event: Event, wake_lock: WakeLock) -> None: + # Check if any widgets exceed device capacity and log warning once + if len(self.widgets) > device.key_count(): + logger.info( + f"Deck '{self.id}' has {len(self.widgets)} widgets but device only has {device.key_count()} keys. " + f"Widgets at positions {device.key_count()} and above will not be displayed." + ) + + for i in range(device.key_count()): + device.set_key_image(i, b"") + + for widget in self.widgets: + widget.update_requested_event = update_requested_event + widget.wake_lock = wake_lock + + # Notify plugins before widget activation + await asyncio.gather(*[w.plugin.on_widget_activate(w) for w in self.widgets]) + + # Activate widgets + await asyncio.gather(*[w.activate() for w in self.widgets]) + await self.update(device, True) + + async def deactivate(self, device: StreamDeck) -> None: + # Cleanup tasks for all widgets before deactivating + for widget in self.widgets: + widget.tasks.cleanup() + + # Deactivate widgets first + await asyncio.gather(*[w.deactivate() for w in self.widgets]) + + # Notify plugins after widget deactivation + await asyncio.gather(*[w.plugin.on_widget_deactivate(w) for w in self.widgets]) + + async def update(self, device: StreamDeck, force: bool = False) -> None: + async def update_widget(w: Widget, i: int) -> None: + # Only update widgets that fit on the device + if i < device.key_count() and (force or w.needs_update): + logger.debug(f"Updating widget on key {i}") + + # Create renderer and let widget draw + renderer = Renderer( + self.global_config.device.default_text_font, + self.global_config.device.default_icons_font, + ) + result = await w.update(renderer) + + # Only push to device if widget actually rendered + if result != UpdateResult.UPDATED: + return + + image = PILHelper.to_native_format(device, renderer.canvas) + device.set_key_image(i, image) + + w.needs_update = False + + await asyncio.gather(*[update_widget(widget, index) for index, widget in enumerate(self.widgets)]) + + async def handle_key(self, index: int, pressed: bool) -> WidgetAction | None: + if index < len(self.widgets): + widget = self.widgets[index] + if pressed: + await widget.pressed() + return None + else: + return await widget.released() + return None diff --git a/knoepfe/deckmanager.py b/src/knoepfe/core/deckmanager.py similarity index 56% rename from knoepfe/deckmanager.py rename to src/knoepfe/core/deckmanager.py index 06d183e..b822275 100644 --- a/knoepfe/deckmanager.py +++ b/src/knoepfe/core/deckmanager.py @@ -1,42 +1,44 @@ +import logging import time from asyncio import Event, TimeoutError, sleep, wait_for -from typing import Any, Dict, List +from typing import cast -from StreamDeck.Devices import StreamDeck +from StreamDeck.Devices.StreamDeck import StreamDeck -from knoepfe.deck import Deck, SwitchDeckException -from knoepfe.log import debug, error -from knoepfe.wakelock import WakeLock +from ..config.models import GlobalConfig +from ..utils.wakelock import WakeLock +from .actions import SwitchDeckAction, WidgetActionType +from .deck import Deck + +logger = logging.getLogger(__name__) class DeckManager: def __init__( self, - active_deck: Deck, - decks: List[Deck], - global_config: Dict[str, Any], + decks: list[Deck], + global_config: GlobalConfig, device: StreamDeck, ) -> None: - self.active_deck = active_deck self.decks = decks - device_config = global_config.get("knoepfe.config.device", {}) - self.brightness = device_config.get("brightness", 100) - self.device_poll_frequency = device_config.get("device_poll_frequency", 5) - self.sleep_timeout = device_config.get("sleep_timeout", None) + self.active_deck = next((deck for deck in decks if deck.id == "main")) + self.brightness = global_config.device.brightness + self.device_poll_frequency = global_config.device.device_poll_frequency + self.sleep_timeout = global_config.device.sleep_timeout self.device = device self.update_requested_event = Event() self.wake_lock = WakeLock(self.update_requested_event) self.sleeping = False self.last_action = time.monotonic() - device.set_key_callback_async(self.key_callback) + # StreamDeck library has incorrect type annotation for set_key_callback_async. + # It expects KeyCallback (sync) but actually accepts async callbacks and wraps them internally. + device.set_key_callback_async(self.key_callback) # type: ignore[arg-type] async def run(self) -> None: self.device.set_brightness(self.brightness) self.device.set_poll_frequency(self.device_poll_frequency) self.last_action = time.monotonic() - await self.active_deck.activate( - self.device, self.update_requested_event, self.wake_lock - ) + await self.active_deck.activate(self.device, self.update_requested_event, self.wake_lock) while True: now = time.monotonic() @@ -55,22 +57,18 @@ async def run(self) -> None: await self.active_deck.update(self.device) self.update_requested_event.clear() - debug("Waiting for update request") + logger.debug("Waiting for update request") try: timeout = None - if ( - self.sleep_timeout - and not self.sleeping - and not self.wake_lock.held() - ): + if self.sleep_timeout and not self.sleeping and not self.wake_lock.held(): timeout = self.sleep_timeout - (now - self.last_action) await wait_for(self.update_requested_event.wait(), timeout) except TimeoutError: pass async def key_callback(self, device: StreamDeck, index: int, pressed: bool) -> None: - debug(f'Key {index} {"pressed" if pressed else "released"}') + logger.debug(f"Key {index} {'pressed' if pressed else 'released'}") self.last_action = time.monotonic() @@ -81,30 +79,30 @@ async def key_callback(self, device: StreamDeck, index: int, pressed: bool) -> N return try: - await self.active_deck.handle_key(index, pressed) - except SwitchDeckException as e: - try: - await self.switch_deck(e.new_deck) - except Exception as e: - error(str(e)) + action = await self.active_deck.handle_key(index, pressed) + if action: + if action.action_type == WidgetActionType.SWITCH_DECK: + switch_action = cast(SwitchDeckAction, action) + try: + await self.switch_deck(switch_action.target_deck) + except Exception as e: + logger.error(str(e)) except Exception as e: - error(str(e)) + logger.error(str(e)) async def switch_deck(self, new_deck: str) -> None: - debug(f"Switching to deck {new_deck}") + logger.debug(f"Switching to deck {new_deck}") for deck in self.decks: if deck.id == new_deck: await self.active_deck.deactivate(self.device) self.active_deck = deck - await self.active_deck.activate( - self.device, self.update_requested_event, self.wake_lock - ) + await self.active_deck.activate(self.device, self.update_requested_event, self.wake_lock) break else: raise RuntimeError(f"No deck with id {new_deck}") async def sleep(self) -> None: - debug("Going to sleep") + logger.debug("Going to sleep") with self.device: for i in range(self.brightness - 10, -10, -10): self.device.set_brightness(i) @@ -112,7 +110,7 @@ async def sleep(self) -> None: self.sleeping = True async def wake_up(self) -> None: - debug("Waking up") + logger.debug("Waking up") with self.device: self.device.set_brightness(self.brightness) self.sleeping = False diff --git a/src/knoepfe/data/clocks.toml b/src/knoepfe/data/clocks.toml new file mode 100644 index 0000000..cc61168 --- /dev/null +++ b/src/knoepfe/data/clocks.toml @@ -0,0 +1,190 @@ +# ============================================================================ +# Clock Widget Examples +# ============================================================================ +# Demonstrates various clock configurations using the segment-based layout system + +# ============================================================================ +# DEVICE SETTINGS +# ============================================================================ + +[device] +brightness = 100 +# Set sleep_timeout to 0 to disable sleep +sleep_timeout = 0 + +# ============================================================================ +# MAIN DECK - 6 Different Clock Examples +# ============================================================================ + +# ---------------------------------------------------------------------------- +# Example 1: Time display (HH:MM:SS) stacked vertically, centered with spacing +# ---------------------------------------------------------------------------- +# Layout: 3*24px segments + 2*6px spacing = 84px total, 6px margins top/bottom +[[deck.main]] +type = "Clock" +font = "Roboto" +color = "#fefefe" +interval = 0.2 + +[[deck.main.segments]] +format = "%H" +x = 12 +y = 6 +width = 72 +height = 24 +font = "Roboto:style=Bold" + +[[deck.main.segments]] +format = "%M" +x = 12 +y = 36 +width = 72 +height = 24 + +[[deck.main.segments]] +format = "%S" +x = 12 +y = 66 +width = 72 +height = 24 +font = "Roboto:style=Thin" + +# ---------------------------------------------------------------------------- +# Example 2: Date display (DD/Mon/YYYY) stacked vertically, centered with spacing +# ---------------------------------------------------------------------------- +# Layout: 3*24px segments + 2*6px spacing = 84px total, 6px margins top/bottom +[[deck.main]] +type = "Clock" +font = "Roboto" +color = "#fefefe" +interval = 10.0 + +[[deck.main.segments]] +format = "%d" +x = 12 +y = 6 +width = 72 +height = 24 +font = "Roboto:style=Bold" + +[[deck.main.segments]] +format = "%b" +x = 12 +y = 36 +width = 72 +height = 24 + +[[deck.main.segments]] +format = "%Y" +x = 12 +y = 66 +width = 72 +height = 24 +font = "Roboto:style=Thin" + +# ---------------------------------------------------------------------------- +# Example 3: Large hours with small minutes/seconds, centered with spacing +# ---------------------------------------------------------------------------- +# Layout: 54px hours + 6px spacing + 24px bottom row = 84px total, 6px margins +# Horizontal spacing: 2px between minutes and seconds +[[deck.main]] +type = "Clock" +color = "#fefefe" +interval = 0.2 + +[[deck.main.segments]] +format = "%H" +x = 0 +y = 6 +width = 96 +height = 54 +font = "Roboto:style=Bold" + +[[deck.main.segments]] +format = "%M" +x = 0 +y = 66 +width = 47 +height = 24 + +[[deck.main.segments]] +format = "%S" +x = 49 +y = 66 +width = 47 +height = 24 +font = "Roboto:style=Thin" + +# ---------------------------------------------------------------------------- +# Example 4: Horizontal time display with colorful segments +# ---------------------------------------------------------------------------- +# Height: 48, centered at y=24 +# Width with 2px spacing: 3*30 + 2*2 = 94, centered at x=1 +[[deck.main]] +type = "Clock" +font = "Roboto:style=Bold" +interval = 0.2 + +[[deck.main.segments]] +format = "%H" +x = 1 +y = 24 +width = 30 +height = 48 +color = "#ff6b6b" # Coral red + +[[deck.main.segments]] +format = "%M" +x = 33 +y = 24 +width = 30 +height = 48 +color = "#4ecdc4" # Turquoise + +[[deck.main.segments]] +format = "%S" +x = 65 +y = 24 +width = 30 +height = 48 +color = "#ffe66d" # Soft yellow + +# ---------------------------------------------------------------------------- +# Example 5: 12-hour format with AM/PM, with spacing +# ---------------------------------------------------------------------------- +# Total height: 38 + 2 + 28 + 2 + 24 = 94, centered at y=1 +[[deck.main]] +type = "Clock" +color = "#fefefe" + +[[deck.main.segments]] +format = "%I" +x = 0 +y = 1 +width = 96 +height = 38 +font = "Roboto:style=Bold" + +[[deck.main.segments]] +format = "%M" +x = 0 +y = 41 +width = 96 +height = 28 + +[[deck.main.segments]] +format = "%p" +x = 0 +y = 71 +width = 96 +height = 24 +font = "Roboto:style=Thin" + +# ---------------------------------------------------------------------------- +# Example 6: Simple HH:MM with subtle color +# ---------------------------------------------------------------------------- +[[deck.main]] +type = "Clock" +font = "Roboto:style=Bold" +color = "#a29bfe" # Soft lavender purple +# Uses default: single segment with format '%H:%M' covering full key \ No newline at end of file diff --git a/src/knoepfe/data/default.toml b/src/knoepfe/data/default.toml new file mode 100644 index 0000000..ff7e87a --- /dev/null +++ b/src/knoepfe/data/default.toml @@ -0,0 +1,142 @@ +# ============================================================================ +# Knoepfe Configuration +# ============================================================================ +# This file uses TOML format with pydantic-settings for validation +# Environment variables can override any setting using KNOEPFE_ prefix +# Example: KNOEPFE_DEVICE__BRIGHTNESS=50 + +# ============================================================================ +# DEVICE SETTINGS +# ============================================================================ + +[device] +# Display brightness in percent (0-100) +brightness = 100 + +# Time in seconds until the device goes to sleep (0 or omit to disable) +sleep_timeout = 10.0 + +# Hardware polling rate in Hz (1-1000) +# Higher values = more responsive but higher CPU usage +device_poll_frequency = 5 + +# Default font for text rendering (e.g., 'Roboto', 'sans:style=Bold') +# default_text_font = "Roboto" + +# Default font for icon rendering (e.g., 'RobotoMono Nerd Font') +# default_icons_font = "RobotoMono Nerd Font" + +# Serial number of the device to connect to (omit for first available) +# serial_number = "ABC123" + +# ============================================================================ +# MAIN DECK - Displayed on startup (required) +# ============================================================================ +# Widgets are displayed in the order they appear below +# Each [[deck.main]] entry represents one button on the Stream Deck +# +# Widget Positioning: +# - By default, widgets are placed in order of appearance (0, 1, 2, ...) +# - Use 'index' parameter to explicitly position widgets (0-based) +# - Widgets can be defined out of order when using 'index' +# - Unindexed widgets fill remaining positions automatically + +# ---------------------------------------------------------------------------- +# Widget 1: Current Time Clock +# ---------------------------------------------------------------------------- +[[deck.main]] +type = "Clock" +[[deck.main.segments]] +format = "%H:%M" +x = 0 +y = 0 +width = 96 +height = 96 + +# ---------------------------------------------------------------------------- +# Widget 2: Timer +# ---------------------------------------------------------------------------- +[[deck.main]] +type = "Timer" + +# ---------------------------------------------------------------------------- +# Widget 3: Greeting Text +# ---------------------------------------------------------------------------- +[[deck.main]] +type = "Text" +text = "Hello\nWorld" + +# ---------------------------------------------------------------------------- +# Widget 4: Current Date Clock +# ---------------------------------------------------------------------------- +[[deck.main]] +type = "Clock" +interval = 60.0 +[[deck.main.segments]] +format = "%d.%m.%Y" +x = 0 +y = 0 +width = 96 +height = 96 + +# ---------------------------------------------------------------------------- +# Widget 5: Custom Text +# ---------------------------------------------------------------------------- +[[deck.main]] +type = "Text" +text = "Knöpfe" + +# ---------------------------------------------------------------------------- +# Widget 6: Another Timer (with explicit index) +# ---------------------------------------------------------------------------- +# This demonstrates explicit positioning - this widget will be at position 5 +# even though it's defined last in the file +[[deck.main]] +type = "Timer" +index = 5 + +# ============================================================================ +# UTILITIES DECK - Additional functionality +# ============================================================================ +# Switch to this deck from main using a widget with switch_deck = "utilities" + +# ---------------------------------------------------------------------------- +# Widget 1: Clock with Seconds +# ---------------------------------------------------------------------------- +[[deck.utilities]] +type = "Clock" +interval = 0.5 +[[deck.utilities.segments]] +format = "%H:%M:%S" +x = 0 +y = 0 +width = 96 +height = 96 + +# ---------------------------------------------------------------------------- +# Widget 2: Back to Main Deck Button +# ---------------------------------------------------------------------------- +[[deck.utilities]] +type = "Text" +text = "Back to\nMain" +switch_deck = "main" + +# ---------------------------------------------------------------------------- +# Widget 3: Date with Day Name +# ---------------------------------------------------------------------------- +[[deck.utilities]] +type = "Clock" +interval = 60.0 +[[deck.utilities.segments]] +format = "%A\n%B %d" +x = 0 +y = 0 +width = 96 +height = 96 + +# ---------------------------------------------------------------------------- +# Widget 4: Custom Button +# ---------------------------------------------------------------------------- +[[deck.utilities]] +type = "Text" +text = "Custom\nButton" \ No newline at end of file diff --git a/src/knoepfe/data/streaming.toml b/src/knoepfe/data/streaming.toml new file mode 100644 index 0000000..e7296ea --- /dev/null +++ b/src/knoepfe/data/streaming.toml @@ -0,0 +1,117 @@ +# ============================================================================ +# Knoepfe Configuration for Streaming Setup +# ============================================================================ +# This configuration includes OBS plugin widgets for streaming control +# All OBS widgets prevent the device from sleeping while connected to OBS + +# ============================================================================ +# DEVICE SETTINGS +# ============================================================================ + +[device] +# Device brightness in percent (0-100) +brightness = 100 + +# Time in seconds until the device goes to sleep +sleep_timeout = 10.0 + +# Frequency to poll the hardware state in Hz (1-1000) +device_poll_frequency = 5 + +# ============================================================================ +# PLUGIN CONFIGURATION - OBS WebSocket +# ============================================================================ +# obs-websocket (https://github.com/obsproject/obs-websocket) needs to be +# installed and activated for this to work + +[plugins.obs] +enabled = true + +# Host OBS is running on (usually 'localhost') +host = "localhost" + +# Port obs-websocket is listening on (default: 4455) +port = 4455 + +# Password for obs-websocket authentication +# You can use environment variables: password = "${OBS_PASSWORD}" +# Or set via: export KNOEPFE_PLUGINS__OBS__PASSWORD="your_password" +password = "supersecret" + +# ============================================================================ +# MAIN DECK - Streaming Controls +# ============================================================================ + +# ---------------------------------------------------------------------------- +# Widget 1: Microphone Mute Toggle +# ---------------------------------------------------------------------------- +# Toggles mute state of a pulseaudio source (i.e. microphone) +# If no device is specified, the default source is used +[[deck.main]] +type = "MicMute" + +# ---------------------------------------------------------------------------- +# Widget 2: Timer +# ---------------------------------------------------------------------------- +# Simple timer widget (acquires wake lock while running) +[[deck.main]] +type = "Timer" + +# ---------------------------------------------------------------------------- +# Widget 3: Current Time Clock +# ---------------------------------------------------------------------------- +[[deck.main]] +type = "Clock" +[[deck.main.segments]] +format = "%H:%M" +x = 0 +y = 0 +width = 96 +height = 96 + +# ---------------------------------------------------------------------------- +# Widget 4: OBS Recording Toggle +# ---------------------------------------------------------------------------- +# Shows and toggles the OBS recording state +[[deck.main]] +type = "OBSRecording" + +# ---------------------------------------------------------------------------- +# Widget 5: OBS Streaming Toggle +# ---------------------------------------------------------------------------- +# Shows and toggles the OBS streaming state +[[deck.main]] +type = "OBSStreaming" + +# ---------------------------------------------------------------------------- +# Widget 6: Current OBS Scene Display +# ---------------------------------------------------------------------------- +# Shows the currently active OBS scene +# Switches to 'scenes' deck when pressed to select a different scene +[[deck.main]] +type = "OBSCurrentScene" +switch_deck = "scenes" + +# ============================================================================ +# SCENES DECK - OBS Scene Selection +# ============================================================================ + +# ---------------------------------------------------------------------------- +# Widget 1: Switch to 'Scene' +# ---------------------------------------------------------------------------- +# Shows if 'Scene' is active and activates it on press +# Returns to main deck after selection +[[deck.scenes]] +type = "OBSSwitchScene" +scene = "Scene" +switch_deck = "main" + +# ---------------------------------------------------------------------------- +# Widget 2: Switch to 'Other Scene' +# ---------------------------------------------------------------------------- +# Shows if 'Other Scene' is active and activates it on press +# Returns to main deck after selection +[[deck.scenes]] +type = "OBSSwitchScene" +scene = "Other Scene" +switch_deck = "main" \ No newline at end of file diff --git a/src/knoepfe/plugins/__init__.py b/src/knoepfe/plugins/__init__.py new file mode 100644 index 0000000..0871e2a --- /dev/null +++ b/src/knoepfe/plugins/__init__.py @@ -0,0 +1,13 @@ +"""Plugin system for knoepfe.""" + +from knoepfe.plugins.descriptor import PluginDescriptor +from knoepfe.plugins.manager import PluginInfo, PluginManager, WidgetInfo +from knoepfe.plugins.plugin import Plugin + +__all__ = [ + "Plugin", + "PluginDescriptor", + "PluginManager", + "PluginInfo", + "WidgetInfo", +] diff --git a/src/knoepfe/plugins/builtin.py b/src/knoepfe/plugins/builtin.py new file mode 100644 index 0000000..11a4bb1 --- /dev/null +++ b/src/knoepfe/plugins/builtin.py @@ -0,0 +1,20 @@ +"""Built-in widgets plugin descriptor.""" + +from typing import Type + +from ..config.plugin import EmptyPluginConfig +from ..widgets.builtin.clock import Clock +from ..widgets.builtin.text import Text +from ..widgets.builtin.timer import Timer +from ..widgets.widget import Widget +from .descriptor import PluginDescriptor +from .plugin import Plugin + + +class BuiltinPluginDescriptor(PluginDescriptor[EmptyPluginConfig, Plugin]): + """Plugin descriptor providing built-in widgets.""" + + @classmethod + def widgets(cls) -> list[Type[Widget]]: + """Return built-in widgets.""" + return [Clock, Text, Timer] diff --git a/src/knoepfe/plugins/descriptor.py b/src/knoepfe/plugins/descriptor.py new file mode 100644 index 0000000..2966195 --- /dev/null +++ b/src/knoepfe/plugins/descriptor.py @@ -0,0 +1,70 @@ +"""Plugin descriptor system for knoepfe.""" + +from abc import ABC, abstractmethod +from typing import Generic, Type, TypeVar + +from ..config.plugin import PluginConfig +from ..utils.type_utils import extract_generic_arg +from ..widgets.widget import Widget +from .plugin import Plugin + +TPluginConfig = TypeVar("TPluginConfig", bound=PluginConfig) +TPlugin = TypeVar("TPlugin", bound=Plugin) + + +class PluginDescriptor(ABC, Generic[TPluginConfig, TPlugin]): + """Base class for all knoepfe plugin descriptors. + + Plugin descriptors are pure type containers that declare their configuration schema, + plugin type, and provided widgets. Descriptors are never instantiated - the + PluginManager uses them only to extract type information and widget lists. + + Type Parameters: + TPluginConfig: The plugin's configuration type (subclass of PluginConfig) + TPlugin: The plugin instance type (subclass of Plugin) + + Example: + class AudioPluginDescriptor(PluginDescriptor[AudioPluginConfig, AudioPlugin]): + '''Audio control plugin for knoepfe.''' + + @classmethod + def widgets(cls) -> list[Type[Widget]]: + return [MicMute, VolumeControl] + """ + + @classmethod + def get_config_type(cls) -> Type[PluginConfig]: + """Extract the config type from the first generic parameter. + + Returns: + The PluginConfig subclass specified as the first type parameter + + Raises: + TypeError: If the descriptor doesn't specify a valid PluginConfig type + """ + return extract_generic_arg(cls, PluginConfig, 0) + + @classmethod + def get_plugin_type(cls) -> Type["Plugin"]: + """Extract the plugin type from the second generic parameter. + + Returns: + The Plugin subclass specified as the second type parameter + + Raises: + TypeError: If the descriptor doesn't specify a valid Plugin type + """ + return extract_generic_arg(cls, Plugin, 1) + + @classmethod + @abstractmethod + def widgets(cls) -> list[Type["Widget"]]: + """Return list of widget classes provided by this plugin descriptor. + + This method must be implemented by subclasses to declare + which widgets they provide. + + Returns: + List of widget classes + """ + pass diff --git a/src/knoepfe/plugins/manager.py b/src/knoepfe/plugins/manager.py new file mode 100644 index 0000000..f585008 --- /dev/null +++ b/src/knoepfe/plugins/manager.py @@ -0,0 +1,206 @@ +import inspect +import logging +from dataclasses import dataclass, field +from importlib.metadata import entry_points +from typing import Type + +from ..config.plugin import PluginConfig +from ..config.widget import WidgetConfig +from ..widgets.widget import Widget +from .descriptor import PluginDescriptor +from .plugin import Plugin + +logger = logging.getLogger(__name__) + + +@dataclass +class PluginInfo: + """Information about a loaded plugin descriptor and its instance.""" + + name: str + descriptor_class: Type[PluginDescriptor] + config: PluginConfig + plugin: Plugin + version: str + description: str | None + widgets: list["WidgetInfo"] = field(default_factory=list) + + +@dataclass +class WidgetInfo: + """Information about a discovered widget.""" + + name: str + description: str | None + widget_class: Type[Widget] + config_type: Type[WidgetConfig] + plugin_info: PluginInfo + + +class PluginManager: + """Manages plugin lifecycle and widget discovery. + + The PluginManager is responsible for: + - Loading plugin descriptor classes from entry points + - Instantiating plugin configs and plugin instances based on descriptor type parameters + - Registering widgets provided by plugin descriptors + - Providing access to plugin instances for widgets + """ + + def __init__(self, plugin_configs: dict[str, dict] | None = None): + """Initialize the plugin manager. + + Args: + plugin_configs: Optional dictionary mapping plugin names to their configuration dicts + """ + self._plugins: dict[str, PluginInfo] = {} + self._widgets: dict[str, WidgetInfo] = {} + self._plugin_configs: dict[str, dict] = plugin_configs or {} + self._load_plugins() + + def _load_plugins(self): + """Load all registered plugin descriptors via entry points.""" + for ep in entry_points(group="knoepfe.plugins"): + try: + plugin_name = ep.name + dist_name = ep.dist.name if ep.dist else plugin_name + + logger.debug(f"Loading plugin '{plugin_name}' from {dist_name}") + + # Load the plugin descriptor class (not instantiated!) + descriptor_class = ep.load() + + # Validate that it's actually a PluginDescriptor subclass + if not (inspect.isclass(descriptor_class) and issubclass(descriptor_class, PluginDescriptor)): + logger.error( + f"Entry point '{plugin_name}' does not point to a PluginDescriptor subclass: {descriptor_class}" + ) + continue + + # Load the plugin with its metadata + version = ep.dist.version if ep.dist else "unknown" + # Get description from descriptor class docstring + description = inspect.getdoc(descriptor_class) + + self._load_plugin(plugin_name, descriptor_class, version, description) + + except Exception: + logger.exception(f"Failed to load plugin {ep.name}") + + def _load_plugin( + self, plugin_name: str, descriptor_class: Type[PluginDescriptor], version: str, description: str | None + ): + """Load and register a plugin descriptor. + + Args: + plugin_name: Name of the plugin + descriptor_class: The plugin descriptor class to load + version: Plugin version string + description: Plugin description from descriptor class attribute + """ + # Get plugin config dict from stored configs + plugin_config_dict = self._plugin_configs.get(plugin_name, {}) + + # Extract config and plugin types from the descriptor class + config_type = descriptor_class.get_config_type() + plugin_type = descriptor_class.get_plugin_type() + + # Instantiate config (validates automatically via Pydantic) + plugin_config = config_type(**plugin_config_dict) + + # Check if plugin is enabled + if not plugin_config.enabled: + logger.info(f"Plugin '{plugin_name}' is disabled in config, skipping") + return + + # Instantiate plugin with the config + plugin_instance = plugin_type(plugin_config) + + # Create plugin info first (widgets will be added later) + plugin_info = PluginInfo( + name=plugin_name, + descriptor_class=descriptor_class, + config=plugin_config, + plugin=plugin_instance, + version=version, + description=description, + ) + + # Store plugin info + self._plugins[plugin_name] = plugin_info + + # Get widgets from the descriptor class (classmethod, no instance needed) + widget_classes = descriptor_class.widgets() + + # Register widgets with reference to plugin info + # This also populates plugin_info.widgets + widget_infos = self._register_widgets(widget_classes, plugin_info) + + widget_names = ", ".join(w.name for w in widget_infos) + logger.debug(f"Loaded {len(widget_infos)} widgets from plugin '{plugin_name}': {widget_names}") + logger.debug(f"Successfully loaded plugin '{plugin_name}' v{plugin_info.version}") + + def _register_widgets(self, widget_classes: list[Type[Widget]], plugin_info: PluginInfo) -> list[WidgetInfo]: + """Register widgets from a plugin. + + Args: + widget_classes: List of widget classes to register + plugin_info: Plugin info for the plugin providing the widgets + + Returns: + List of successfully registered widget infos + """ + widget_infos = [] + for widget_class in widget_classes: + # Extract config type from widget class + try: + config_type = widget_class.get_config_type() + except TypeError as e: + logger.warning(f"Could not extract config type for widget '{widget_class.name}': {e}") + continue + + widget_info = WidgetInfo( + name=widget_class.name, + description=inspect.getdoc(widget_class), + widget_class=widget_class, + config_type=config_type, + plugin_info=plugin_info, + ) + + if widget_info.name in self._widgets: + logger.warning(f"Widget name '{widget_info.name}' already registered, skipping") + continue + + self._widgets[widget_info.name] = widget_info + widget_infos.append(widget_info) + # Also add to plugin's widget list + plugin_info.widgets.append(widget_info) + logger.debug(f"Registered widget '{widget_info.name}' from plugin '{plugin_info.name}'") + + return widget_infos + + @property + def widgets(self) -> dict[str, WidgetInfo]: + """Get all registered widgets. + + Returns: + Dictionary mapping widget names to WidgetInfo objects + """ + return self._widgets + + @property + def plugins(self) -> dict[str, PluginInfo]: + """Get all registered plugins. + + Returns: + Dictionary mapping plugin names to PluginInfo objects + """ + return self._plugins + + def shutdown_all(self) -> None: + """Shutdown all plugins by calling shutdown on their plugin instances.""" + for plugin_info in self._plugins.values(): + try: + plugin_info.plugin.shutdown() + except Exception: + logger.exception(f"Error shutting down plugin {plugin_info.name}") diff --git a/src/knoepfe/plugins/plugin.py b/src/knoepfe/plugins/plugin.py new file mode 100644 index 0000000..3b13f93 --- /dev/null +++ b/src/knoepfe/plugins/plugin.py @@ -0,0 +1,67 @@ +"""Base class for plugin instances.""" + +from typing import TYPE_CHECKING + +from ..config.plugin import PluginConfig +from ..utils.task_manager import TaskManager + +if TYPE_CHECKING: + from ..widgets.widget import Widget + + +class Plugin: + """Base class for plugin instances. + + This class holds shared state and resources that can be accessed by all widgets + belonging to a plugin. Plugin instances can be subclassed to add custom state + and implement cleanup logic in the shutdown method. + + The plugin provides a TaskManager for managing plugin-wide background tasks + that are shared across all widgets of the plugin. + + Lifecycle Hooks: + Subclasses can override on_widget_activate() and on_widget_deactivate() + to be notified when widgets using this plugin are activated or deactivated. + This enables lazy initialization of resources and proper cleanup. + """ + + def __init__(self, config: PluginConfig): + """Initialize plugin instance with configuration. + + Args: + config: Typed plugin configuration object + """ + self.config = config + self.tasks = TaskManager() + + async def on_widget_activate(self, widget: "Widget") -> None: + """Called when a widget using this plugin is activated. + + This is called BEFORE the widget's activate() method. + Use this to initialize shared resources lazily when the first + widget is activated. + + Args: + widget: The widget instance being activated + """ + pass + + async def on_widget_deactivate(self, widget: "Widget") -> None: + """Called when a widget using this plugin is deactivated. + + This is called AFTER the widget's deactivate() method. + Use this to clean up shared resources when the last widget + is deactivated. + + Args: + widget: The widget instance being deactivated + """ + pass + + def shutdown(self) -> None: + """Called when the plugin is being unloaded. + + Override this method to clean up any resources, close connections, + stop background tasks, etc. Tasks are automatically cleaned up. + """ + self.tasks.cleanup() diff --git a/src/knoepfe/rendering/__init__.py b/src/knoepfe/rendering/__init__.py new file mode 100644 index 0000000..474282d --- /dev/null +++ b/src/knoepfe/rendering/__init__.py @@ -0,0 +1,9 @@ +"""Rendering utilities for knoepfe.""" + +from knoepfe.rendering.font_manager import FontManager +from knoepfe.rendering.renderer import Renderer + +__all__ = [ + "FontManager", + "Renderer", +] diff --git a/src/knoepfe/rendering/font_manager.py b/src/knoepfe/rendering/font_manager.py new file mode 100644 index 0000000..3926d9f --- /dev/null +++ b/src/knoepfe/rendering/font_manager.py @@ -0,0 +1,34 @@ +"""Font management using python-fontconfig for system font access.""" + +from functools import lru_cache + +import fontconfig +from PIL import ImageFont + + +class FontManager: + """Manages system fonts using python-fontconfig.""" + + @classmethod + @lru_cache() + def get_font(cls, pattern: str | None, size: int = 24) -> ImageFont.FreeTypeFont: + """Get a font from a fontconfig pattern string. + + Examples: + "Ubuntu" - Ubuntu family + "Ubuntu:style=Bold" - Ubuntu Bold + "Roboto" - default Roboto font + "DejaVu Sans" - DejaVu Sans font + "monospace" - default monospace font + """ + # Query fontconfig directly with the pattern + fonts = fontconfig.query(pattern) + + if not fonts: + raise ValueError(f"No font found for pattern: {pattern}") + + # Use the first matching font + font_path = fonts[0] + + # Load font + return ImageFont.truetype(font_path, size) diff --git a/src/knoepfe/rendering/renderer.py b/src/knoepfe/rendering/renderer.py new file mode 100644 index 0000000..65c02dc --- /dev/null +++ b/src/knoepfe/rendering/renderer.py @@ -0,0 +1,347 @@ +"""Renderer for Stream Deck key displays.""" + +from pathlib import Path +from typing import Union + +from PIL import Image, ImageDraw, ImageFont + +from .font_manager import FontManager + + +class Renderer: + """Renderer with both primitive operations and convenience methods.""" + + def __init__(self, default_text_font: str, default_icons_font: str) -> None: + """Initialize renderer with default fonts. + + Args: + default_text_font: Default font pattern for text (e.g., "Roboto") + default_icons_font: Default font pattern for icons (e.g., "RobotoMono Nerd Font") + """ + self.canvas = Image.new("RGB", (96, 96), color="black") + self._draw = ImageDraw.Draw(self.canvas) + self.default_text_font = default_text_font + self.default_icons_font = default_icons_font + + # ========== Primitive Operations ========== + + def text( + self, + position: tuple[int, int], + text: str, + font: Union[ImageFont.FreeTypeFont, str] | None = None, + size: int = 24, + color: str = "white", + anchor: str = "la", + ) -> "Renderer": + """Draw text at specific position. + + Args: + position: (x, y) coordinates + text: Text to draw + font: Font name/pattern or ImageFont instance (defaults to config default_text_font) + size: Font size (ignored if font is ImageFont instance) + color: Text color + anchor: Text anchor (e.g., "mm" for middle-middle) + """ + if font is None: + font = self.default_text_font + if isinstance(font, str): + font = FontManager.get_font(font, size) + self._draw.text(position, text, font=font, fill=color, anchor=anchor) + return self + + def draw_image( + self, + img: Union[Image.Image, str, Path], + position: tuple[int, int] = (0, 0), + size: tuple[int, int] | None = None, + ) -> "Renderer": + """Draw an image at position, optionally resizing. + + Args: + img: PIL Image, file path, or Path object + position: (x, y) coordinates for top-left corner + size: Optional (width, height) to resize to + """ + if isinstance(img, (str, Path)): + img = Image.open(img) + + if size: + img = img.resize(size, Image.Resampling.LANCZOS) + + # Handle palette mode images with transparency + if img.mode == "P" and "transparency" in img.info: + img = img.convert("RGBA") + + if img.mode in ("RGBA", "LA"): + self.canvas.paste(img, position, img) + else: + self.canvas.paste(img, position) + return self + + @property + def draw(self) -> ImageDraw.ImageDraw: + """Direct access to PIL ImageDraw for custom drawing.""" + return self._draw + + def clear(self, color: str = "black") -> "Renderer": + """Clear canvas with solid color.""" + self._draw.rectangle([0, 0, 96, 96], fill=color) + return self + + def measure_text( + self, text: str, font: Union[ImageFont.FreeTypeFont, str] | None = None, size: int = 24 + ) -> tuple[int, int]: + """Get text dimensions without drawing. + + Returns: + (width, height) of the text + """ + if font is None: + font = self.default_text_font + if isinstance(font, str): + font = FontManager.get_font(font, size) + bbox = self._draw.textbbox((0, 0), text, font=font) + return int(bbox[2] - bbox[0]), int(bbox[3] - bbox[1]) + + def _text_centered_visual( + self, + text: str, + font: ImageFont.FreeTypeFont, + center: tuple[int, int], + color: str = "white", + ) -> "Renderer": + """Draw text centered based on visual glyph bounds. + + This helper ensures text is truly centered by calculating the actual glyph + bounds, which is important for monospace fonts where glyphs may not be + centered within their character cell. + + Args: + text: Text to draw + font: PIL ImageFont instance (must be loaded) + center: (x, y) coordinates for the visual center + color: Text color + """ + # Get bounding box to measure actual glyph dimensions + # Use anchor='lt' (left-top) at origin to get true glyph bounds + bbox = self._draw.textbbox((0, 0), text, font=font, anchor="lt") + glyph_width = bbox[2] - bbox[0] + glyph_height = bbox[3] - bbox[1] + + # Calculate position to center the glyph visually + # Account for glyph offset from anchor point + glyph_left_offset = bbox[0] + glyph_top_offset = bbox[1] + + # Adjust position so glyph center aligns with target center + adjusted_x = center[0] - glyph_left_offset - glyph_width / 2 + adjusted_y = center[1] - glyph_top_offset - glyph_height / 2 + + # Draw with 'lt' anchor at calculated position + self._draw.text((adjusted_x, adjusted_y), text, font=font, fill=color, anchor="lt") + return self + + # ========== Convenience Methods ========== + + def icon( + self, + icon: str, + size: int = 86, + color: str = "white", + position: tuple[int, int] | None = None, + font: str | None = None, + ) -> "Renderer": + """Render an icon (Unicode character) centered or at position. + + This method ensures the icon is visually centered by calculating the actual + glyph bounds, which is important for monospace fonts where glyphs may not + be centered within their character cell. + + Args: + icon: Unicode character (e.g., "\ue029" or "🎤") + size: Icon size + color: Icon color + position: Optional (x, y) position, defaults to center (48, 48) + font: Font to use for icon (defaults to config default_icons_font) + """ + if font is None: + font = self.default_icons_font + if position is None: + position = (48, 48) + + # Load font if it's a string pattern + if isinstance(font, str): + font_obj = FontManager.get_font(font, size) + else: + font_obj = font + + # Use visual centering helper for accurate positioning + return self._text_centered_visual(icon, font_obj, position, color) + + def image( + self, + image_path: Union[str, Path, Image.Image], + size: int = 86, + position: tuple[int, int] | None = None, + ) -> "Renderer": + """Render an image at a centered position with automatic sizing. + + Displays an image at the specified size, centered at the given position. + By default, renders an 86x86 image centered on the key (matching typical + icon sizes). The image is resized to fit within the specified dimensions + while maintaining its aspect ratio. + + Args: + image_path: Path to image file or PIL Image object + size: Target size in pixels (image scaled to fit within size×size) + position: Center point (x, y) for the image, defaults to (48, 48) + """ + if position is None: + position = (48, 48) + + # Calculate top-left position to center the image at the target position + x = position[0] - size // 2 + y = position[1] - size // 2 + + return self.draw_image(image_path, (x, y), (size, size)) + + def icon_and_text( + self, + icon: str, + text: str, + icon_size: int = 64, + text_size: int = 16, + icon_color: str = "white", + text_color: str = "white", + icon_font: str | None = None, + text_font: str | None = None, + spacing: int = 8, + ) -> "Renderer": + """Render icon with text below it. + + Args: + icon: Unicode character for icon + text: Text to display below icon + icon_size: Size of icon + text_size: Size of text + icon_color: Color of icon + text_color: Color of text + icon_font: Font for icon (defaults to config default_icons_font) + text_font: Font for text (defaults to config default_text_font) + spacing: Pixels between icon and text + """ + if icon_font is None: + icon_font = self.default_icons_font + if text_font is None: + text_font = self.default_text_font + + # Calculate vertical positions + total_height = icon_size + spacing + text_size + start_y = (96 - total_height) // 2 + + icon_y = start_y + icon_size // 2 + text_y = start_y + icon_size + spacing + + # Render icon + self.icon(icon, icon_size, icon_color, (48, icon_y), icon_font) + + # Render text + return self.text((48, text_y), text, font=text_font, size=text_size, color=text_color, anchor="mt") + + def image_and_text( + self, + image_path: Union[str, Path, Image.Image], + text: str, + image_size: int = 64, + text_size: int = 16, + text_color: str = "white", + text_font: str | None = None, + spacing: int = 8, + ) -> "Renderer": + """Render image with text below it. + + Args: + image_path: Path to image or PIL Image + text: Text to display below image + image_size: Maximum size for image + text_size: Size of text + text_color: Color of text + text_font: Font for text (defaults to config default_text_font) + spacing: Pixels between image and text + """ + if text_font is None: + text_font = self.default_text_font + + # Load and prepare image + if isinstance(image_path, (str, Path)): + img = Image.open(image_path) + else: + img = image_path + + img.thumbnail((image_size, image_size), Image.Resampling.LANCZOS) + + # Calculate layout + total_height = img.height + spacing + text_size + start_y = (96 - total_height) // 2 + + # Render image + self.draw_image(img, ((96 - img.width) // 2, start_y)) + + # Render text + text_y = start_y + img.height + spacing + return self.text((48, text_y), text, font=text_font, size=text_size, color=text_color, anchor="mt") + + def text_multiline( + self, + text: str, + size: int = 16, + color: str = "white", + font: str | None = None, + line_spacing: int | None = None, + ) -> "Renderer": + r"""Render multi-line text centered on the key. + + Splits text on explicit newlines (\\n) and renders each line separately, + centered both horizontally and vertically on the key. + + Args: + text: Text to display (supports \\n for line breaks) + size: Font size + color: Text color + font: Font name/pattern (defaults to config default_text_font) + line_spacing: Pixels between lines (defaults to 20% of font size if not specified) + """ + if font is None: + font = self.default_text_font + + # Calculate line spacing automatically if not provided + if line_spacing is None: + # Use 20% of font size as default spacing + line_spacing = int(size * 0.2) + + # Split on explicit newlines only + lines = text.split("\n") + + # Early return if all lines are empty + if all(not line for line in lines): + return self + + # Get the actual font to measure line height + pil_font = FontManager.get_font(font, size) + + # Get font metrics: (ascent, descent) from baseline + ascent, descent = pil_font.getmetrics() + line_height = ascent + descent + + # Calculate starting position for vertical centering + total_height = len(lines) * line_height + (len(lines) - 1) * line_spacing + y = (96 - total_height) // 2 + + # Render each line centered horizontally + for i, line in enumerate(lines): + line_y = int(y + i * (line_height + line_spacing)) + self.text((48, line_y), line, font=font, size=size, color=color, anchor="mt") + + return self diff --git a/src/knoepfe/transport/README.md b/src/knoepfe/transport/README.md new file mode 100644 index 0000000..fc0751e --- /dev/null +++ b/src/knoepfe/transport/README.md @@ -0,0 +1,87 @@ +# Transport Patches + +Knoepfe applies patches to the upstream `python-elgato-streamdeck` library to fix bugs and improve performance. + +## Patches Applied + +### 1. Dummy Transport Fix +The Dummy transport's `read()` method returns `bytearray(length)` instead of `None` when no data is available. This causes the StreamDeck polling loop to never sleep, resulting in 100% CPU usage. + +**Fix:** Patch the Dummy transport to return `None` when no data is available, matching LibUSBHIDAPI's behavior. + +### 2. CythonHIDAPI Transport (Enabled by Default but optional) + +Knoepfe includes a Cython-based HID transport implementation for improved performance and reliability when communicating with StreamDeck devices. + +## Why CythonHIDAPI? + +The Cython implementation provides significant advantages over the ctypes-based approach: + +- **Compiled Performance**: Native C code instead of ctypes marshalling overhead +- **True Parallelism**: GIL release during I/O operations for better concurrency +- **Optimized Memory**: Stack allocation for small buffers, efficient dynamic allocation for large ones +- **Proper Resource Management**: Uses `weakref.finalize()` for safe cleanup order +- **Full Compatibility**: Drop-in replacement for LibUSBHIDAPI + +## Critical Bug in Upstream LibUSBHIDAPI + +The upstream `python-elgato-streamdeck` library's ctypes-based transport has a shutdown race condition that can cause crashes: + +**Problem:** +```python +# In Library._load_hidapi_library() (line 143): +atexit.register(self.HIDAPI_INSTANCE.hid_exit) + +# In Device.__del__() (line 360): +def __del__(self): + self.close() # May access already-cleaned-up library! +``` + +The `atexit` registration causes `hid_exit()` to be called before device destructors run. When `Device.__del__()` tries to close devices during shutdown, it accesses an already-cleaned-up library, causing crashes. + +**Fix for Upstream:** +```python +# Replace atexit with weakref.finalize for proper cleanup order +import weakref +import sys + +# In Library._load_hidapi_library(), replace line 143: +# OLD: atexit.register(self.HIDAPI_INSTANCE.hid_exit) +# NEW: +weakref.finalize(sys.modules[__name__], self.HIDAPI_INSTANCE.hid_exit) + +# In Device.__del__(), add error handling: +def __del__(self): + try: + self.close() + except: + # Ignore errors during destruction to avoid shutdown crashes + pass +``` + +This ensures devices are closed before the library is cleaned up, preventing shutdown crashes. + +## Usage + +CythonHIDAPI is enabled by default. To disable it: + +```bash +knoepfe --no-cython-hid +``` + +Or in code: + +```python +from knoepfe.transport import apply_transport_patches + +# Use CythonHIDAPI (default) +apply_transport_patches(enable_cython_hid=True) + +# Use upstream LibUSBHIDAPI +apply_transport_patches(enable_cython_hid=False) +``` + +## Requirements + +- `hidapi` package (installed automatically with knoepfe) +- `StreamDeck` library for base classes \ No newline at end of file diff --git a/src/knoepfe/transport/__init__.py b/src/knoepfe/transport/__init__.py new file mode 100644 index 0000000..cbe6bb3 --- /dev/null +++ b/src/knoepfe/transport/__init__.py @@ -0,0 +1,11 @@ +"""Transport layer implementations for knoepfe. + +This module provides alternative transport implementations for the StreamDeck library, +including a cython-hidapi based transport that offers better performance and resource +management compared to the default ctypes implementation. +""" + +from .cython_hidapi import CythonHIDAPI +from .patches import apply_transport_patches + +__all__ = ["CythonHIDAPI", "apply_transport_patches"] diff --git a/src/knoepfe/transport/cython_hidapi.py b/src/knoepfe/transport/cython_hidapi.py new file mode 100644 index 0000000..d76d9b8 --- /dev/null +++ b/src/knoepfe/transport/cython_hidapi.py @@ -0,0 +1,229 @@ +"""Cython-HIDAPI based transport for StreamDeck devices. + +This transport implementation uses the cython-hidapi library for HID device +communication, providing compiled performance, proper resource management, +and safe shutdown handling. +""" + +import platform +import threading +from contextlib import contextmanager + +import hid + +# Import from the elgato-stream-deck library +from StreamDeck.Transport.Transport import Transport, TransportError + + +@contextmanager +def _handle_hid_errors(operation_name: str): + """Context manager to handle HID operation errors consistently.""" + try: + yield + except Exception as e: + if "not open" in str(e).lower(): + raise TransportError("Device not open") from e + raise TransportError(f"Failed to {operation_name}: {e}") from e + + +class CythonHIDAPI(Transport): + """USB HID transport layer using the cython-hidapi library. + + Provides compiled performance with proper resource management and + safe shutdown handling through weakref finalizers. + """ + + class Library: + """Compatibility wrapper to match LibUSBHIDAPI.Library interface.""" + + def __init__(self): + """Initialize the library wrapper.""" + # Test that hidapi is functional + with _handle_hid_errors("initialize cython-hidapi"): + hid.enumerate() + + def enumerate(self, vendor_id=None, product_id=None): + """Enumerate devices using cython-hidapi.""" + vendor_id = vendor_id or 0 + product_id = product_id or 0 + + with _handle_hid_errors("enumerate devices"): + devices = hid.enumerate(vendor_id, product_id) + + # Convert to the expected format + device_list = [] + for device_info in devices: + # Ensure path is properly handled + path = device_info["path"] + if isinstance(path, bytes): + path = path.decode("utf-8") + + device_list.append( + { + "path": path, + "vendor_id": device_info["vendor_id"], + "product_id": device_info["product_id"], + } + ) + + return device_list + + class Device(Transport.Device): + """HID device instance using cython-hidapi. + + Provides thread-safe access to HID device operations with proper + resource management and platform-specific workarounds. + """ + + def __init__(self, library, device_info: dict): + """Initialize a device instance. + + :param library: Library instance (for compatibility with LibUSBHIDAPI interface) + :param device_info: Dictionary containing device information from hid.enumerate() + """ + self.library = library + self.device_info = device_info + self._hid_device = None + self._mutex = threading.Lock() + self._platform_name = platform.system() + + def __del__(self): + """Ensure device is closed on destruction.""" + try: + self.close() + except: + # Ignore errors during destruction to avoid shutdown issues + pass + + def open(self) -> None: + """Opens the device for input/output.""" + with self._mutex: + if self._hid_device is not None: + return + + with _handle_hid_errors("open HID device"): + self._hid_device = hid.device() + # Open by path for exact device matching + path = self.device_info["path"] + if isinstance(path, str): + path = path.encode("utf-8") + + self._hid_device.open_path(path) + # Set non-blocking mode to match expected behavior + self._hid_device.set_nonblocking(1) + + def close(self) -> None: + """Closes the device for input/output.""" + with self._mutex: + if self._hid_device is not None: + try: + self._hid_device.close() + except: + # Ignore errors during close to avoid shutdown issues + pass + finally: + self._hid_device = None + + def is_open(self) -> bool: + """Indicates if the device is open.""" + with self._mutex: + return self._hid_device is not None + + def connected(self) -> bool: + """Indicates if the device is still connected.""" + with self._mutex: + # Check if device is still in enumeration list + try: + devices = hid.enumerate(self.device_info["vendor_id"], self.device_info["product_id"]) + return any(d["path"] == self.device_info["path"] for d in devices) + except: + return False + + def path(self) -> str: + """Retrieves the logical path of the device.""" + path = self.device_info["path"] + if isinstance(path, bytes): + return path.decode("utf-8") + return path + + def vendor_id(self) -> int: + """Retrieves the vendor ID of the device.""" + return self.device_info["vendor_id"] + + def product_id(self) -> int: + """Retrieves the product ID of the device.""" + return self.device_info["product_id"] + + def write_feature(self, payload: bytes) -> int: + """Sends a HID Feature report to the device.""" + with self._mutex: + if self._hid_device is None: + raise TransportError("Device not open") + + with _handle_hid_errors("write feature report"): + result = self._hid_device.send_feature_report(payload) + if result < 0: + raise TransportError(f"Failed to write feature report ({result})") + return result + + def read_feature(self, report_id: int, length: int) -> bytes: + """Reads a HID Feature report from the device.""" + with self._mutex: + if self._hid_device is None: + raise TransportError("Device not open") + + with _handle_hid_errors("read feature report"): + # Apply macOS HIDAPI 0.9.0 bug workaround if needed + read_length = (length + 1) if self._platform_name == "Darwin" else length + + result = self._hid_device.get_feature_report(report_id, read_length) + if not result: + raise TransportError("Failed to read feature report") + + # Handle macOS bug workaround + if self._platform_name == "Darwin" and length < read_length and len(result) == read_length: + # Mac HIDAPI 0.9.0 bug: we read one less than expected + return bytes(result) + + # Return the requested length + return bytes(result[:length]) + + def write(self, payload: bytes) -> int: + """Sends a HID Out report to the device.""" + with self._mutex: + if self._hid_device is None: + raise TransportError("Device not open") + + with _handle_hid_errors("write out report"): + result = self._hid_device.write(payload) + if result < 0: + raise TransportError(f"Failed to write out report ({result})") + return result + + def read(self, length: int) -> bytes | None: # type: ignore[override] + """Performs a non-blocking read of a HID In report. + + Returns None when no data is available (matching LibUSBHIDAPI behavior). + The base class signature is incorrect - it should allow None returns. + """ + with self._mutex: + if self._hid_device is None: + raise TransportError("Device not open") + + with _handle_hid_errors("read in report"): + result = self._hid_device.read(length) + if not result: + return None # Return None to match LibUSBHIDAPI behavior + return bytes(result[:length]) + + @staticmethod + def probe() -> None: + """Attempts to determine if the cython-hidapi backend is available.""" + CythonHIDAPI.Library() + + def enumerate(self, vid: int, pid: int) -> list[Transport.Device]: + """Enumerates all available devices using cython-hidapi.""" + library = CythonHIDAPI.Library() + devices = library.enumerate(vendor_id=vid, product_id=pid) + + return [CythonHIDAPI.Device(library, d) for d in devices] diff --git a/src/knoepfe/transport/patches.py b/src/knoepfe/transport/patches.py new file mode 100644 index 0000000..79a3fb0 --- /dev/null +++ b/src/knoepfe/transport/patches.py @@ -0,0 +1,71 @@ +"""Monkey patches for StreamDeck library transports. + +This module contains fixes for bugs in the upstream StreamDeck library's +transport implementations that cause performance issues, and provides +optional performance enhancements. +""" + +import logging + + +def apply_transport_patches(enable_cython_hid: bool = True) -> None: + """Apply all transport monkey patches and optional enhancements. + + This function: + 1. Patches bugs in the upstream StreamDeck library transports: + - Dummy transport: Fix read() to return None instead of bytearray + 2. Optionally replaces LibUSBHIDAPI with CythonHIDAPI for better performance + + Args: + enable_cython_hid: If True, replace LibUSBHIDAPI with CythonHIDAPI + for compiled performance and better resource management. + Default is True. + + These patches prevent 100% CPU usage in the device polling loop. + """ + _patch_dummy_transport() + + if enable_cython_hid: + _enable_cython_hidapi() + + +def _patch_dummy_transport() -> None: + """Fix Dummy transport to return None when no data available. + + The Dummy transport's read() method returns bytearray(length) instead of None + when no data is available. This causes the StreamDeck polling loop to never + sleep, resulting in 100% CPU usage. + + This patch fixes the return value to match LibUSBHIDAPI's behavior. + """ + import StreamDeck.Transport.Dummy as Dummy_module + from StreamDeck.Transport.Transport import TransportError + + def fixed_dummy_read(self, length: int): + """Fixed read that returns None instead of empty bytearray.""" + if not self.is_open: + raise TransportError("Deck read while deck not open.") + + logging.info("Deck report read (length %s)", length) + return None # Return None instead of bytearray(length) + + Dummy_module.Dummy.Device.read = fixed_dummy_read # type: ignore[assignment] + + +def _enable_cython_hidapi() -> None: + """Replace LibUSBHIDAPI with CythonHIDAPI for better performance. + + CythonHIDAPI provides: + - Compiled performance using cython-hidapi instead of ctypes + - Proper resource management with weakref finalizers + - Safe shutdown handling without race conditions + - GIL release for true parallelism in I/O operations + + This is a drop-in replacement that maintains full compatibility with + the StreamDeck library's expected interface. + """ + import StreamDeck.Transport.LibUSBHIDAPI as LibUSBHIDAPI_module + + from .cython_hidapi import CythonHIDAPI + + LibUSBHIDAPI_module.LibUSBHIDAPI = CythonHIDAPI diff --git a/src/knoepfe/utils/__init__.py b/src/knoepfe/utils/__init__.py new file mode 100644 index 0000000..4382532 --- /dev/null +++ b/src/knoepfe/utils/__init__.py @@ -0,0 +1,14 @@ +"""Utility functions and helpers for knoepfe.""" + +from knoepfe.utils.exceptions import PluginNotFoundError, WidgetNotFoundError +from knoepfe.utils.logging import configure_logging +from knoepfe.utils.type_utils import extract_generic_arg +from knoepfe.utils.wakelock import WakeLock + +__all__ = [ + "extract_generic_arg", + "WakeLock", + "configure_logging", + "PluginNotFoundError", + "WidgetNotFoundError", +] diff --git a/src/knoepfe/utils/exceptions.py b/src/knoepfe/utils/exceptions.py new file mode 100644 index 0000000..99af36e --- /dev/null +++ b/src/knoepfe/utils/exceptions.py @@ -0,0 +1,17 @@ +"""Custom exceptions for knoepfe.""" + + +class PluginNotFoundError(Exception): + """Raised when a required plugin cannot be found or imported.""" + + def __init__(self, plugin_name: str): + self.plugin_name = plugin_name + super().__init__(f"Plugin '{plugin_name}' not found. Use 'knoepfe plugins list' to see available plugins.") + + +class WidgetNotFoundError(Exception): + """Raised when a required widget cannot be found or imported.""" + + def __init__(self, widget_name: str): + self.widget_name = widget_name + super().__init__(f"Widget '{widget_name}' not found. Use 'knoepfe widgets list' to see available widgets.") diff --git a/src/knoepfe/utils/logging.py b/src/knoepfe/utils/logging.py new file mode 100644 index 0000000..dab6398 --- /dev/null +++ b/src/knoepfe/utils/logging.py @@ -0,0 +1,21 @@ +"""Logging configuration utilities for knoepfe.""" + +import logging +import sys + + +def configure_logging(verbose: bool = False) -> None: + """Configure logging for the application. + + Args: + verbose: If True, set log level to DEBUG, otherwise INFO. + """ + level = logging.DEBUG if verbose else logging.INFO + + # Configure root logger with logger name prefix + logging.basicConfig( + level=level, + format="[%(name)s] %(levelname)s: %(message)s", + stream=sys.stderr, + force=True, # Override any existing configuration + ) diff --git a/src/knoepfe/utils/task_manager.py b/src/knoepfe/utils/task_manager.py new file mode 100644 index 0000000..408f126 --- /dev/null +++ b/src/knoepfe/utils/task_manager.py @@ -0,0 +1,154 @@ +"""Background task management for widgets and plugins.""" + +from asyncio import Task, get_event_loop +from logging import getLogger +from typing import Any, Coroutine + +logger = getLogger(__name__) + + +class TaskManager: + """Manages background tasks with automatic lifecycle management. + + This class provides a unified API for creating and managing background tasks + in both widgets (per-widget tasks) and plugins (plugin-wide tasks). + + Tasks are automatically cleaned up when cleanup() is called: + - For widgets: cleanup() is called in widget.deactivate() + - For plugins: cleanup() is called in plugin.shutdown() + + Features: + - Named tasks for easy identification + - Automatic cleanup on deactivate/shutdown + - Safe task restart and cancellation + + Example (Widget): + # In widget __init__ + self.tasks = TaskManager() + + # In widget activate() + async def my_task(): + while True: + await sleep(1.0) + self.request_update() + self.tasks.start_task("my_task", my_task()) + + # Automatic cleanup on deactivate - no code needed! + + Example (Plugin): + # In plugin __init__ + self.tasks = TaskManager() + + # In connector connect() + self.tasks.start_task("event_watcher", self._watch_events()) + + # Automatic cleanup on shutdown - no code needed! + """ + + def __init__(self): + """Initialize task manager.""" + self._tasks: dict[str, Task[None]] = {} + + def start_task( + self, + name: str, + coro: Coroutine[Any, Any, None], + restart_if_running: bool = False, + ) -> Task[None]: + """Start a named background task. + + The task will be automatically cleaned up based on the scope set during + TaskManager initialization. + + Args: + name: Unique identifier for this task + coro: Coroutine to run as a background task + restart_if_running: If True, cancel and restart if task already exists + + Returns: + The created Task object + + Example: + # Start a task that monitors external events + self.tasks.start_task("event_watcher", self._watch_events()) + """ + if name in self._tasks: + if restart_if_running: + self.stop_task(name) + else: + return self._tasks[name] + + task: Task[None] = get_event_loop().create_task(coro) + self._tasks[name] = task + + # Auto-cleanup when task completes + def cleanup(t: Task[None]) -> None: + self._tasks.pop(name, None) + + task.add_done_callback(cleanup) + logger.debug(f"Started task '{name}'") + return task + + def stop_task(self, name: str) -> bool: + """Stop a named background task. + + Args: + name: Task identifier + + Returns: + True if task was stopped, False if task not found + + Example: + self.tasks.stop_task("periodic_update") + """ + if task := self._tasks.pop(name, None): + task.cancel() + logger.debug(f"Stopped task '{name}'") + return True + return False + + def is_running(self, name: str) -> bool: + """Check if a named task is currently running. + + Args: + name: Task identifier + + Returns: + True if task exists and is running + + Example: + if not self.tasks.is_running("event_watcher"): + self.tasks.start_task("event_watcher", self._watch_events()) + """ + if name in self._tasks: + task = self._tasks[name] + return not task.done() + return False + + def cleanup(self) -> None: + """Stop all tasks managed by this TaskManager. + + This is called automatically: + - For widgets: When widget.deactivate() is called + - For plugins: When plugin.shutdown() is called + + Example: + # Called automatically by Widget.deactivate() + self.tasks.cleanup() + """ + task_count = len(self._tasks) + if task_count > 0: + for name in list(self._tasks.keys()): + self.stop_task(name) + logger.debug(f"Cleaned up {task_count} tasks") + + def stop_all(self) -> None: + """Stop all tasks regardless of scope. + + This is useful for emergency cleanup or testing. + + Example: + self.tasks.stop_all() + """ + for name in list(self._tasks.keys()): + self.stop_task(name) diff --git a/src/knoepfe/utils/type_utils.py b/src/knoepfe/utils/type_utils.py new file mode 100644 index 0000000..58ecdd8 --- /dev/null +++ b/src/knoepfe/utils/type_utils.py @@ -0,0 +1,41 @@ +"""Utilities for extracting generic type parameters at runtime.""" + +from typing import Type, TypeVar, get_args + +T = TypeVar("T") + + +def extract_generic_arg(cls: type, base_class: Type[T], arg_index: int = 0) -> Type[T]: + """Extract a generic type argument from a class's base classes. + + Args: + cls: The class to extract the type from + base_class: The base class type to match against + arg_index: Which generic argument to extract (0-based) + + Returns: + The extracted type argument + + Raises: + TypeError: If the type argument cannot be found or is invalid + + Example: + class MyWidget(Widget[TextConfig, Plugin]): + pass + + config_type = extract_generic_arg(MyWidget, WidgetConfig, 0) # Returns TextConfig + plugin_type = extract_generic_arg(MyWidget, Plugin, 1) # Returns Plugin + """ + if hasattr(cls, "__orig_bases__"): + for base in cls.__orig_bases__: # type: ignore + args = get_args(base) + if args and len(args) > arg_index: + try: + if issubclass(args[arg_index], base_class): + return args[arg_index] # type: ignore + except TypeError: + pass + + raise TypeError( + f"Class {cls.__name__} must specify a {base_class.__name__} type as generic parameter at index {arg_index}" + ) diff --git a/knoepfe/wakelock.py b/src/knoepfe/utils/wakelock.py similarity index 100% rename from knoepfe/wakelock.py rename to src/knoepfe/utils/wakelock.py diff --git a/src/knoepfe/widgets/__init__.py b/src/knoepfe/widgets/__init__.py new file mode 100644 index 0000000..ff66bfe --- /dev/null +++ b/src/knoepfe/widgets/__init__.py @@ -0,0 +1,3 @@ +from .widget import UpdateResult, Widget + +__all__ = ["UpdateResult", "Widget"] diff --git a/src/knoepfe/widgets/builtin/__init__.py b/src/knoepfe/widgets/builtin/__init__.py new file mode 100644 index 0000000..d6b12bb --- /dev/null +++ b/src/knoepfe/widgets/builtin/__init__.py @@ -0,0 +1,5 @@ +from .clock import Clock +from .text import Text +from .timer import Timer + +__all__ = ["Text", "Clock", "Timer"] diff --git a/src/knoepfe/widgets/builtin/clock.py b/src/knoepfe/widgets/builtin/clock.py new file mode 100644 index 0000000..71fd32b --- /dev/null +++ b/src/knoepfe/widgets/builtin/clock.py @@ -0,0 +1,107 @@ +from datetime import datetime + +from pydantic import Field + +from ...config.base import BaseConfig +from ...config.widget import WidgetConfig +from ...plugins.plugin import Plugin +from ...rendering import Renderer +from ..widget import UpdateResult, Widget + + +class ClockSegment(BaseConfig): + """Configuration for a single clock segment.""" + + format: str = Field(..., description="Time format string (Python strftime format)") + x: int = Field(..., description="X position of segment") + y: int = Field(..., description="Y position of segment") + width: int = Field(..., description="Width of segment area") + height: int = Field(..., description="Height of segment area") + font: str | None = Field(default=None, description="Font for this segment (inherits from widget if None)") + color: str | None = Field(default=None, description="Color for this segment (inherits from widget if None)") + anchor: str = Field(default="mm", description="Text anchor point (e.g., 'mm' for middle-middle)") + + +class ClockConfig(WidgetConfig): + """Configuration for Clock widget.""" + + segments: list[ClockSegment] = Field( + default_factory=lambda: [ClockSegment(format="%H:%M", x=0, y=0, width=96, height=96)], + description="List of clock segments to render", + ) + interval: float = Field(default=1.0, description="Update interval in seconds") + + +class Clock(Widget[ClockConfig, Plugin]): + """Display current time with flexible segment-based layout.""" + + name = "Clock" + + def __init__(self, config: ClockConfig, plugin: Plugin) -> None: + super().__init__(config, plugin) + self.last_time = "" + + async def activate(self) -> None: + self.request_periodic_update(self.config.interval) + + async def deactivate(self) -> None: + self.last_time = "" + + def _calculate_font_size(self, text: str, font: str | None, width: int, height: int, renderer) -> int: + """Calculate the largest font size that fits within the given bounds.""" + # Binary search for optimal font size + min_size, max_size = 8, 72 + best_size = min_size + + while min_size <= max_size: + mid_size = (min_size + max_size) // 2 + text_width, text_height = renderer.measure_text(text, font=font, size=mid_size) + + if text_width <= width and text_height <= height: + best_size = mid_size + min_size = mid_size + 1 + else: + max_size = mid_size - 1 + + return best_size + + async def update(self, renderer: Renderer) -> UpdateResult: + now = datetime.now() + + # Generate current time string for all segments to check if update needed + current_time = "".join(now.strftime(seg.format) for seg in self.config.segments) + + # Skip rendering if time hasn't changed + if current_time == self.last_time: + return UpdateResult.UNCHANGED + + self.last_time = current_time + + renderer.clear() + + for segment in self.config.segments: + # Get text for this segment + text = now.strftime(segment.format) + + # Determine font and color (segment-specific or widget default) + font = segment.font or self.config.font + color = segment.color or self.config.color + + # Calculate optimal font size to fit within segment bounds + font_size = self._calculate_font_size(text, font, segment.width, segment.height, renderer) + + # Calculate center position of segment + center_x = segment.x + segment.width // 2 + center_y = segment.y + segment.height // 2 + + # Render text at segment position + renderer.text( + (center_x, center_y), + text, + font=font, + size=font_size, + color=color, + anchor=segment.anchor, + ) + + return UpdateResult.UPDATED diff --git a/src/knoepfe/widgets/builtin/text.py b/src/knoepfe/widgets/builtin/text.py new file mode 100644 index 0000000..59e1860 --- /dev/null +++ b/src/knoepfe/widgets/builtin/text.py @@ -0,0 +1,30 @@ +from pydantic import Field + +from ...config.widget import WidgetConfig +from ...plugins.plugin import Plugin +from ...rendering import Renderer +from ..widget import UpdateResult, Widget + + +class TextConfig(WidgetConfig): + """Configuration for Text widget.""" + + text: str = Field(..., description="Text to display") + + +class Text(Widget[TextConfig, Plugin]): + """Display static text.""" + + name = "Text" + + def __init__(self, config: TextConfig, plugin: Plugin) -> None: + super().__init__(config, plugin) + + async def update(self, renderer: Renderer) -> UpdateResult: + renderer.clear() + renderer.text_multiline( + self.config.text, + font=self.config.font, + color=self.config.color, + ) + return UpdateResult.UPDATED diff --git a/src/knoepfe/widgets/builtin/timer.py b/src/knoepfe/widgets/builtin/timer.py new file mode 100644 index 0000000..38a16a7 --- /dev/null +++ b/src/knoepfe/widgets/builtin/timer.py @@ -0,0 +1,91 @@ +import time +from datetime import timedelta + +from pydantic import Field + +from ...config.widget import WidgetConfig +from ...plugins.plugin import Plugin +from ...rendering import Renderer +from ..widget import UpdateResult, Widget + + +class TimerConfig(WidgetConfig): + """Configuration for Timer widget.""" + + icon: str = Field( + default="󱎫", # nf-md-timer + description="Icon to display when timer is idle (unicode character or codepoint)", + ) + running_color: str | None = Field( + default=None, description="Text color when timer is running (defaults to base color)" + ) + stopped_color: str = Field(default="red", description="Text color when timer is stopped") + + +class Timer(Widget[TimerConfig, Plugin]): + """Start/stop timer with elapsed time display.""" + + name = "Timer" + + def __init__(self, config: TimerConfig, plugin: Plugin) -> None: + super().__init__(config, plugin) + self.start: float | None = None + self.stop: float | None = None + + async def activate(self) -> None: + """Restart periodic update if timer is running.""" + if self.start and not self.stop: + # Timer is running, restart periodic update + self.request_periodic_update(1.0) + + async def deactivate(self) -> None: + """Periodic update is stopped automatically by Deck cleanup.""" + # Keep timer state (start/stop) so it persists across deck switches + # Only release wake lock if timer is not running + if not (self.start and not self.stop): + self.release_wake_lock() + + async def update(self, renderer: Renderer) -> UpdateResult: + renderer.clear() + if self.start and not self.stop: + # Timer is running + elapsed = f"{timedelta(seconds=time.monotonic() - self.start)}".rsplit(".", 1)[0] + renderer.text( + (48, 48), + elapsed, + anchor="mm", + font=self.config.font, + color=self.config.running_color or self.config.color, + ) + elif self.start and self.stop: + # Timer is stopped + elapsed = f"{timedelta(seconds=self.stop - self.start)}".rsplit(".", 1)[0] + renderer.text( + (48, 48), + elapsed, + anchor="mm", + font=self.config.font, + color=self.config.stopped_color, + ) + else: + # Timer is idle + renderer.icon(self.config.icon, color=self.config.color) + + return UpdateResult.UPDATED + + async def triggered(self, long_press: bool = False) -> None: + if not self.start: + self.start = time.monotonic() + self.request_periodic_update(1.0) + self.request_update() + self.acquire_wake_lock() + elif self.start and not self.stop: + self.stop = time.monotonic() + self.stop_periodic_update() + self.request_update() + self.release_wake_lock() + else: + self.stop_periodic_update() + self.start = None + self.stop = None + self.request_update() diff --git a/src/knoepfe/widgets/widget.py b/src/knoepfe/widgets/widget.py new file mode 100644 index 0000000..eb15ac0 --- /dev/null +++ b/src/knoepfe/widgets/widget.py @@ -0,0 +1,160 @@ +from abc import ABC, abstractmethod +from asyncio import Event, sleep +from enum import Enum +from typing import TYPE_CHECKING, Generic, TypeVar + +from ..config.widget import WidgetConfig +from ..core.actions import SwitchDeckAction, WidgetAction +from ..rendering import Renderer +from ..utils.task_manager import TaskManager +from ..utils.type_utils import extract_generic_arg +from ..utils.wakelock import WakeLock + +if TYPE_CHECKING: + from ..plugins.plugin import Plugin + +TPlugin = TypeVar("TPlugin", bound="Plugin") +TConfig = TypeVar("TConfig", bound="WidgetConfig") + +# Task name constants +TASK_PERIODIC_UPDATE = "periodic_update" +TASK_LONG_PRESS = "long_press" + + +class UpdateResult(Enum): + """Result of a widget update operation indicating whether the renderer's canvas should be used.""" + + UPDATED = "updated" # Widget updated the canvas, push to device + UNCHANGED = "unchanged" # Widget didn't update canvas, keep current display + + +class Widget(ABC, Generic[TConfig, TPlugin]): + """Base widget class with strongly typed configuration. + + Widgets should specify their config type as the first generic parameter + and their plugin instance type as the second generic parameter. + """ + + name: str + + def __init__(self, config: TConfig, plugin: TPlugin) -> None: + """Initialize widget with typed configuration. + + Args: + config: Validated widget configuration + plugin: Plugin instance + """ + self.config = config + self.plugin = plugin + + # Runtime state + self.update_requested_event: Event | None = None + self.wake_lock: WakeLock | None = None + self.holds_wait_lock = False + self.needs_update = False + + # Task management + self.tasks = TaskManager() + + @classmethod + def get_config_type(cls) -> type: + """Extract the config type from the generic parameter. + + Returns: + The WidgetConfig subclass specified as the first type parameter + + Raises: + TypeError: If the widget doesn't specify a valid WidgetConfig type + """ + return extract_generic_arg(cls, WidgetConfig, 0) + + async def activate(self) -> None: # pragma: no cover + """Called when widget becomes active on the deck.""" + return + + async def deactivate(self) -> None: # pragma: no cover + """Called when widget is deactivated (e.g., deck switch).""" + return + + @abstractmethod + async def update(self, renderer: Renderer) -> UpdateResult: + """Update the widget display using the provided renderer. + + Args: + renderer: Renderer instance to draw the widget display + + Returns: + UpdateResult.UPDATED if the widget drew to the canvas and it should be pushed to device + UpdateResult.UNCHANGED if the widget didn't draw and the device should keep current display + """ + pass + + async def pressed(self) -> None: + """Called when key is pressed.""" + + async def maybe_trigger_longpress() -> None: + await sleep(1.0) + await self.triggered(True) + + self.tasks.start_task("long_press", maybe_trigger_longpress()) + + async def released(self) -> WidgetAction | None: + """Called when key is released.""" + if self.tasks.is_running("long_press"): + self.tasks.stop_task("long_press") + action = await self.triggered(False) + if action: + return action + + if self.config.switch_deck: + return SwitchDeckAction(self.config.switch_deck) + + return None + + async def triggered(self, long_press: bool = False) -> WidgetAction | None: + return None + + def request_update(self) -> None: + """Request an update for this widget. + + Sets the needs_update flag and signals the shared update event. + This will cause the DeckManager to update this widget on the next cycle. + """ + self.needs_update = True + if self.update_requested_event: + self.update_requested_event.set() + + def request_periodic_update(self, interval: float) -> None: + """Request periodic updates at the specified interval. + + This is a convenience method that creates a background task to call + request_update() at regular intervals. The task will be automatically + cleaned up when the widget is deactivated. + + Args: + interval: Time in seconds between updates + """ + + async def periodic_loop() -> None: + while True: + await sleep(interval) + self.request_update() + + self.tasks.start_task("periodic_update", periodic_loop()) + + def stop_periodic_update(self) -> None: + """Stop periodic updates. + + This is a convenience method that stops the periodic update task. + """ + self.tasks.stop_task("periodic_update") + + def acquire_wake_lock(self) -> None: + if self.wake_lock and not self.holds_wait_lock: + self.wake_lock.acquire() + self.holds_wait_lock = True + + def release_wake_lock(self) -> None: + if self.wake_lock and self.holds_wait_lock: + self.wake_lock.release() + self.holds_wait_lock = False diff --git a/stubs/schema.pyi b/stubs/schema.pyi deleted file mode 100644 index 50cc021..0000000 --- a/stubs/schema.pyi +++ /dev/null @@ -1,95 +0,0 @@ -from typing import Any, Callable, List - -class SchemaError(Exception): - autos: List[str | None] - errors: List[str | None] | None - def __init__( - self, autos: List[str | None], errors: List[str | None] | None = ... - ) -> None: ... - @property - def code(self) -> str: ... - -class SchemaWrongKeyError(SchemaError): ... -class SchemaMissingKeyError(SchemaError): ... -class SchemaOnlyOneAllowedError(SchemaError): ... -class SchemaForbiddenKeyError(SchemaError): ... -class SchemaUnexpectedTypeError(SchemaError): ... - -class And: - def __init__(self, *args: Any, **kw: Any) -> None: ... - @property - def args(self) -> Any: ... - def validate(self, data: Any, **kwargs: Any) -> Any: ... - -class Or(And): - only_one: Any - match_count: int - def __init__(self, *args: Any, **kwargs: Any) -> None: ... - def reset(self) -> None: ... - def validate(self, data: Any, **kwargs: Any) -> Any: ... - -class Regex: - NAMES: Any - def __init__( - self, pattern_str: str, flags: int = ..., error: Any | None = ... - ) -> None: ... - @property - def pattern_str(self) -> str: ... - def validate(self, data: Any, **kwargs: Any) -> Any: ... - -class Use: - def __init__( - self, callable_: Callable[[Any], Any], error: Any | None = ... - ) -> None: ... - def validate(self, data: Any, **kwargs: Any) -> Any: ... - -class Schema: - as_reference: Any - def __init__( - self, - schema: Any, - error: Any | None = ..., - ignore_extra_keys: bool = ..., - name: Any | None = ..., - description: Any | None = ..., - as_reference: bool = ..., - ) -> None: ... - @property - def schema(self) -> Any: ... - @property - def description(self) -> Any | None: ... - @property - def name(self) -> Any: ... - @property - def ignore_extra_keys(self) -> bool: ... - def is_valid(self, data: Any, **kwargs: Any) -> bool: ... - def validate(self, data: Any, **kwargs: Any) -> Any: ... - def json_schema( - self, schema_id: str, use_refs: bool = ..., **kwargs: Any - ) -> Any: ... - -class Optional(Schema): - default: Any - key: Any - def __init__(self, *args: Any, **kwargs: Any) -> None: ... - def __hash__(self) -> int: ... - def __eq__(self, other: Any) -> bool: ... - def reset(self) -> None: ... - -class Hook(Schema): - handler: Any - key: Any - def __init__(self, *args: Any, **kwargs: Any) -> None: ... - -class Forbidden(Hook): - def __init__(self, *args: Any, **kwargs: Any) -> None: ... - -class Literal: - def __init__(self, value: Any, description: Any | None = ...) -> None: ... - @property - def description(self) -> Any | None: ... - @property - def schema(self) -> Any: ... - -class Const(Schema): - def validate(self, data: Any, **kwargs: Any) -> Any: ... diff --git a/tests/test_config.py b/tests/test_config.py index fc3f605..2fbed96 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,109 +1,136 @@ from pathlib import Path -from unittest.mock import Mock, mock_open, patch - -from pytest import raises -from schema import Schema - -from knoepfe.config import ( - create_deck, - create_widget, - exec_config, - get_config_path, - process_config, -) -from knoepfe.widgets.base import Widget - -test_config = """ -deck({ 'widgets': [widget({'type': 'test'})] }) -default_deck({ 'widgets': [widget({'type': 'test'})] }) -""" - -test_config_multiple_config = """ -config({ 'type': 'knoepfe.config.device', 'brightness': 100 }) -config({ 'type': 'knoepfe.config.device', 'brightness': 90 }) -""" -test_config_no_default = """ -deck({ 'widgets': [widget({'type': 'test'})] }) -""" +import pytest +from pydantic import ValidationError -test_config_multiple_default = """ -default_deck({ 'widgets': [widget({'type': 'test'})] }) -default_deck({ 'widgets': [widget({'type': 'test'})] }) -""" +from knoepfe.config.loader import ConfigError, load_config +from knoepfe.config.models import DeviceConfig, GlobalConfig -def test_config_path() -> None: - assert get_config_path(Path("path")) == Path("path") +def test_load_config_valid(tmp_path): + """Test loading a valid configuration.""" + config_content = """ +[device] +brightness = 80 +sleep_timeout = 30.0 - with patch("pathlib.Path.exists", return_value=True): - assert str(get_config_path()).endswith(".config/knoepfe/knoepfe.cfg") +[plugins.obs] +host = "localhost" +port = 4455 - with patch("pathlib.Path.exists", return_value=False): - assert str(get_config_path()).endswith("knoepfe/default.cfg") +[[deck.main]] +type = "Clock" +[[deck.main.segments]] +format = "%H:%M" +x = 0 +y = 0 +width = 96 +height = 96 +[[deck.main]] +type = "Text" +text = "Hello" +""" -def test_exec_config_success() -> None: - with ( - patch("knoepfe.config.create_deck") as create_deck, - patch("knoepfe.config.create_widget") as create_widget, - ): - exec_config(test_config) - assert create_deck.called - assert create_widget.called + config_file = tmp_path / "test.toml" + config_file.write_text(config_content) + + config = load_config(config_file) + + assert isinstance(config, GlobalConfig) + assert config.device.brightness == 80 + assert config.device.sleep_timeout == 30.0 + assert "obs" in config.plugins + assert config.plugins["obs"]["host"] == "localhost" + assert len(config.decks) == 1 + assert config.decks[0].name == "main" + assert len(config.decks[0].widgets) == 2 + + +def test_load_config_validation_error(tmp_path): + """Test that invalid config raises ConfigError.""" + config_content = """ +[device] +brightness = 150 + +[[deck.main]] +type = "Clock" +[[deck.main.segments]] +format = "%H:%M" +x = 0 +y = 0 +width = 96 +height = 96 +""" + config_file = tmp_path / "test.toml" + config_file.write_text(config_content) -def test_exec_config_multiple_config() -> None: - with raises(RuntimeError): - exec_config(test_config_multiple_config) + with pytest.raises(ConfigError, match="validation failed"): + load_config(config_file) -def test_exec_config_multiple_default() -> None: - with patch("knoepfe.config.create_deck"), patch("knoepfe.config.create_widget"): - with raises(RuntimeError): - exec_config(test_config_multiple_default) +def test_load_config_no_main_deck(tmp_path): + """Test that missing main deck raises ConfigError.""" + config_content = """ +[[deck.other]] +type = "Clock" +[[deck.other.segments]] +format = "%H:%M" +x = 0 +y = 0 +width = 96 +height = 96 +""" + config_file = tmp_path / "test.toml" + config_file.write_text(config_content) -def test_exec_config_invalid_global() -> None: - with patch("knoepfe.config.import_module", return_value=Mock(Class=int)): - with raises(RuntimeError): - exec_config(test_config_multiple_config) + with pytest.raises(ConfigError, match="validation failed"): + load_config(config_file) -def test_exec_config_no_default() -> None: - with patch("knoepfe.config.create_deck"), patch("knoepfe.config.create_widget"): - with raises(RuntimeError): - exec_config(test_config_no_default) +def test_load_config_file_not_found(): + """Test that missing file raises ConfigError.""" + with pytest.raises(ConfigError, match="not found"): + load_config(Path("nonexistent.toml")) -def test_process_config() -> None: - with ( - patch( - "knoepfe.config.exec_config", return_value=(Mock(), [Mock()]) - ) as exec_config, - patch("builtins.open", mock_open(read_data=test_config)), - ): - process_config(Path("file")) - assert exec_config.called +def test_global_config_device_defaults(): + """Test that device config has proper defaults.""" + config = GlobalConfig(deck={"main": []}) + assert config.device.brightness == 100 + assert config.device.sleep_timeout == 10.0 + assert config.device.device_poll_frequency == 5 + assert config.device.serial_number is None -def test_create_deck() -> None: - with patch("knoepfe.config.Deck") as deck: - create_deck({"id": "id", "widgets": []}) - assert deck.called +def test_device_config_with_serial_number(): + """Test that device config accepts serial number.""" + config = GlobalConfig( + device=DeviceConfig(serial_number="ABC123"), + deck={"main": []}, + ) + assert config.device.serial_number == "ABC123" -def test_create_widget_success() -> None: - class TestWidget(Widget): - def get_schema(self) -> Schema: - return Schema({}) - with patch("knoepfe.config.import_module", return_value=Mock(Class=TestWidget)): - w = create_widget({"type": "a.b.c.Class"}, {}) - assert isinstance(w, TestWidget) +def test_global_config_validation(): + """Test GlobalConfig validation.""" + # Valid config + config = GlobalConfig( + device=DeviceConfig(brightness=50), + deck={"main": []}, + ) + assert config.device.brightness == 50 + # Invalid brightness + with pytest.raises(ValidationError): + GlobalConfig( + device=DeviceConfig(brightness=150), + deck={"main": []}, + ) -def test_create_widget_invalid_type() -> None: - with patch("knoepfe.config.import_module", return_value=Mock(Class=int)): - with raises(RuntimeError): - create_widget({"type": "a.b.c.Class"}, {}) + # Missing main deck + with pytest.raises(ValidationError): + GlobalConfig(deck={"other": []}) diff --git a/tests/test_deck.py b/tests/test_deck.py index 87187c7..6d502ed 100644 --- a/tests/test_deck.py +++ b/tests/test_deck.py @@ -1,23 +1,38 @@ from typing import List -from unittest.mock import AsyncMock, MagicMock, Mock +from unittest.mock import AsyncMock, MagicMock, Mock, patch -from pytest import raises -from StreamDeck.Devices import StreamDeck +from StreamDeck.Devices.StreamDeck import StreamDeck -from knoepfe.deck import Deck -from knoepfe.widgets.base import Widget +from knoepfe.config.models import DeviceConfig, GlobalConfig +from knoepfe.core.deck import Deck +from knoepfe.widgets.widget import Widget + + +def create_mock_widget(index: int | None = None) -> Mock: + """Helper to create a properly mocked widget with config.index.""" + widget = Mock(spec=Widget) + widget.config = Mock() + widget.config.index = index + # Add mock plugin with lifecycle methods + widget.plugin = Mock() + widget.plugin.on_widget_activate = AsyncMock() + widget.plugin.on_widget_deactivate = AsyncMock() + return widget def test_deck_init() -> None: - widgets: List[Widget | None] = [Mock()] - deck = Deck("id", widgets) - assert deck.widgets == widgets + widgets: List[Widget] = [create_mock_widget()] + global_config = GlobalConfig(device=DeviceConfig(), deck={"main": []}) + deck = Deck("id", widgets, global_config) + assert len(deck.widgets) == 1 async def test_deck_activate() -> None: device: StreamDeck = MagicMock(key_count=Mock(return_value=4)) - widget = Mock(spec=Widget) - deck = Deck("id", [widget]) + device.key_image_format.return_value = {"size": (96, 96), "format": "JPEG", "rotation": 0, "flip": (False, False)} + widget = create_mock_widget() + global_config = GlobalConfig(device=DeviceConfig(), deck={"main": []}) + deck = Deck("id", [widget], global_config) await deck.activate(device, Mock(), Mock()) assert device.set_key_image.called assert widget.activate.called @@ -25,33 +40,210 @@ async def test_deck_activate() -> None: async def test_deck_deactivate() -> None: device: StreamDeck = MagicMock(key_count=Mock(return_value=4)) - widget = Mock(spec=Widget) - deck = Deck("id", [widget]) + widget = create_mock_widget() + widget.tasks = Mock() # Add tasks mock + global_config = GlobalConfig(device=DeviceConfig(), deck={"main": []}) + deck = Deck("id", [widget], global_config) await deck.deactivate(device) + assert widget.tasks.cleanup.called # Verify cleanup was called assert widget.deactivate.called async def test_deck_update() -> None: - device: StreamDeck = MagicMock(key_count=Mock(return_value=1)) - deck = Deck("id", [Mock(), Mock()]) - - with raises(RuntimeError): - await deck.update(device) - - device = MagicMock(key_count=Mock(return_value=4)) - deck = Deck("id", [Mock(update=AsyncMock()), None, Mock(update=AsyncMock())]) + device: StreamDeck = MagicMock(key_count=Mock(return_value=4)) + device.key_image_format.return_value = {"size": (96, 96), "format": "JPEG", "rotation": 0, "flip": (False, False)} + mock_widget_0 = create_mock_widget() + mock_widget_0.update = AsyncMock() + mock_widget_0.needs_update = True + mock_widget_1 = create_mock_widget() + mock_widget_1.update = AsyncMock() + mock_widget_1.needs_update = True + global_config = GlobalConfig(device=DeviceConfig(), deck={"main": []}) + deck = Deck("id", [mock_widget_0, mock_widget_1], global_config) await deck.update(device) - assert deck.widgets[0].update.called # type: ignore - assert deck.widgets[2].update.called # type: ignore + assert mock_widget_0.update.called + assert mock_widget_1.update.called async def test_deck_handle_key() -> None: - deck = Deck( - "id", [Mock(pressed=AsyncMock(), released=AsyncMock()) for i in range(3)] - ) + mock_widgets = [] + for _ in range(3): + mock_widget = create_mock_widget() + mock_widget.pressed = AsyncMock() + mock_widget.released = AsyncMock() + mock_widgets.append(mock_widget) + + global_config = GlobalConfig(device=DeviceConfig(), deck={"main": []}) + deck = Deck("id", mock_widgets, global_config) await deck.handle_key(0, True) - assert deck.widgets[0].pressed.called # type: ignore - assert not deck.widgets[0].released.called # type: ignore + assert mock_widgets[0].pressed.called + assert not mock_widgets[0].released.called await deck.handle_key(0, False) - assert deck.widgets[0].released.called # type: ignore + assert mock_widgets[0].released.called + + +def test_deck_index_assignment_unindexed() -> None: + """Test that unindexed widgets are assigned sequential indices starting from 0.""" + widget_a = create_mock_widget(None) + widget_b = create_mock_widget(None) + widget_c = create_mock_widget(None) + + global_config = GlobalConfig(device=DeviceConfig(), deck={"main": []}) + deck = Deck("id", [widget_a, widget_b, widget_c], global_config) + + # Verify widgets are in order and have correct indices assigned + assert len(deck.widgets) == 3 + assert deck.widgets[0] == widget_a + assert deck.widgets[1] == widget_b + assert deck.widgets[2] == widget_c + assert widget_a.config.index == 0 + assert widget_b.config.index == 1 + assert widget_c.config.index == 2 + + +def test_deck_index_assignment_mixed_no_gaps() -> None: + """Test mixed explicit and auto-assigned indices without gaps.""" + widget_a = create_mock_widget(0) # Explicit index 0 + widget_b = create_mock_widget(None) # Should get index 1 + widget_c = create_mock_widget(2) # Explicit index 2 + widget_d = create_mock_widget(None) # Should get index 3 + + global_config = GlobalConfig(device=DeviceConfig(), deck={"main": []}) + deck = Deck("id", [widget_a, widget_b, widget_c, widget_d], global_config) + + # Verify correct ordering and index assignment + assert len(deck.widgets) == 4 + assert deck.widgets[0] == widget_a + assert deck.widgets[1] == widget_b + assert deck.widgets[2] == widget_c + assert deck.widgets[3] == widget_d + assert widget_a.config.index == 0 + assert widget_b.config.index == 1 + assert widget_c.config.index == 2 + assert widget_d.config.index == 3 + + +def test_deck_index_assignment_explicit_with_gaps() -> None: + """Test explicit indices with gaps (sparse list).""" + widget_a = create_mock_widget(0) + widget_b = create_mock_widget(3) + widget_c = create_mock_widget(5) + + global_config = GlobalConfig(device=DeviceConfig(), deck={"main": []}) + deck = Deck("id", [widget_a, widget_b, widget_c], global_config) + + # Verify widgets are at their explicit positions + assert len(deck.widgets) == 3 + assert deck.widgets[0] == widget_a + assert deck.widgets[1] == widget_b + assert deck.widgets[2] == widget_c + assert widget_a.config.index == 0 + assert widget_b.config.index == 3 + assert widget_c.config.index == 5 + + +def test_deck_index_assignment_mixed_with_gaps() -> None: + """Test mixed explicit and auto-assigned indices with gaps.""" + widget_a = create_mock_widget(0) # Explicit index 0 + widget_b = create_mock_widget(None) # Should fill gap at index 1 + widget_c = create_mock_widget(5) # Explicit index 5 (creates gap) + widget_d = create_mock_widget(None) # Should fill gap at index 2 + widget_e = create_mock_widget(None) # Should fill gap at index 3 + + global_config = GlobalConfig(device=DeviceConfig(), deck={"main": []}) + deck = Deck("id", [widget_a, widget_b, widget_c, widget_d, widget_e], global_config) + + # Verify correct ordering and gap filling + assert len(deck.widgets) == 5 + assert deck.widgets[0] == widget_a + assert deck.widgets[1] == widget_b + assert deck.widgets[2] == widget_d + assert deck.widgets[3] == widget_e + assert deck.widgets[4] == widget_c + assert widget_a.config.index == 0 + assert widget_b.config.index == 1 + assert widget_c.config.index == 5 + assert widget_d.config.index == 2 + assert widget_e.config.index == 3 + + +def test_deck_index_assignment_out_of_order() -> None: + """Test that widgets with out-of-order explicit indices are placed correctly.""" + widget_a = create_mock_widget(3) + widget_b = create_mock_widget(1) + widget_c = create_mock_widget(0) + widget_d = create_mock_widget(2) + + global_config = GlobalConfig(device=DeviceConfig(), deck={"main": []}) + deck = Deck("id", [widget_a, widget_b, widget_c, widget_d], global_config) + + # Verify widgets are reordered by their indices + assert len(deck.widgets) == 4 + assert deck.widgets[0] == widget_c # index 0 + assert deck.widgets[1] == widget_b # index 1 + assert deck.widgets[2] == widget_d # index 2 + assert deck.widgets[3] == widget_a # index 3 + assert widget_a.config.index == 3 + assert widget_b.config.index == 1 + assert widget_c.config.index == 0 + assert widget_d.config.index == 2 + + +async def test_deck_update_respects_indices() -> None: + """Test that widgets are rendered to the correct physical keys based on their indices.""" + device: StreamDeck = MagicMock(key_count=Mock(return_value=10)) + device.key_image_format.return_value = {"size": (96, 96), "format": "JPEG", "rotation": 0, "flip": (False, False)} + + # Create widgets with specific indices + widget_at_0 = create_mock_widget(0) + widget_at_0.update = AsyncMock() + widget_at_0.needs_update = True + + widget_at_5 = create_mock_widget(5) + widget_at_5.update = AsyncMock() + widget_at_5.needs_update = True + + widget_auto = create_mock_widget(None) # Should get index 1 + widget_auto.update = AsyncMock() + widget_auto.needs_update = True + + global_config = GlobalConfig(device=DeviceConfig(), deck={"main": []}) + deck = Deck("id", [widget_at_0, widget_at_5, widget_auto], global_config) + await deck.update(device, force=True) + + # Verify each widget was rendered + assert widget_at_0.update.called + assert widget_at_5.update.called + assert widget_auto.update.called + + +async def test_deck_passes_default_font_to_renderer() -> None: + """Test that Deck.update() passes the default fonts from global config to Renderer instances.""" + device: StreamDeck = MagicMock(key_count=Mock(return_value=10)) + + # Create a global config with custom default fonts + custom_text_font = "CustomFont" + custom_icons_font = "CustomFont Nerd Font" + device_config = DeviceConfig(default_text_font=custom_text_font, default_icons_font=custom_icons_font) + global_config = GlobalConfig(device=device_config, deck={"main": []}) + + # Create a widget + widget = create_mock_widget(0) + widget.update = AsyncMock() + widget.needs_update = True + + deck = Deck("id", [widget], global_config) + + # Patch Renderer to capture its initialization + with patch("knoepfe.core.deck.Renderer") as MockRenderer: + mock_renderer_instance = MagicMock() + MockRenderer.return_value = mock_renderer_instance + + await deck.update(device, force=True) + + # Verify Renderer was created with the correct default fonts + MockRenderer.assert_called_once_with(custom_text_font, custom_icons_font) + + # Verify the widget's update method was called with the renderer + widget.update.assert_called_once_with(mock_renderer_instance) diff --git a/tests/test_deckmanager.py b/tests/test_deckmanager.py index 9f9dfed..b0a7f63 100644 --- a/tests/test_deckmanager.py +++ b/tests/test_deckmanager.py @@ -3,15 +3,20 @@ from pytest import raises -from knoepfe.deck import Deck, SwitchDeckException -from knoepfe.deckmanager import DeckManager +from knoepfe.config.models import DeviceConfig, GlobalConfig +from knoepfe.core.actions import SwitchDeckAction +from knoepfe.core.deck import Deck +from knoepfe.core.deckmanager import DeckManager + + +def make_global_config() -> GlobalConfig: + """Helper to create a minimal GlobalConfig for tests.""" + return GlobalConfig(deck={"main": []}) async def test_deck_manager_run() -> None: - deck = Mock( - activate=AsyncMock(), update=AsyncMock(side_effect=[None, SystemExit()]) - ) - deck_manager = DeckManager(deck, [deck], {}, Mock()) + deck = Mock(id="main", activate=AsyncMock(), update=AsyncMock(side_effect=[None, SystemExit()])) + deck_manager = DeckManager([deck], make_global_config(), Mock()) with patch.object(deck_manager.update_requested_event, "wait", AsyncMock()): with raises(SystemExit): @@ -19,31 +24,31 @@ async def test_deck_manager_run() -> None: async def test_deck_manager_key_callback() -> None: - deck = Mock(handle_key=AsyncMock(side_effect=SwitchDeckException("new_deck"))) - deck_manager = DeckManager(deck, [deck], {}, Mock()) + deck = Mock(id="main", handle_key=AsyncMock(return_value=SwitchDeckAction("new_deck"))) + deck_manager = DeckManager([deck], make_global_config(), Mock()) with patch.object(deck_manager, "switch_deck", AsyncMock()) as switch_deck: await deck_manager.key_callback(Mock(), 0, False) assert switch_deck.called + switch_deck.assert_called_with("new_deck") - deck = Mock(handle_key=AsyncMock(side_effect=Exception("Error"))) - deck_manager = DeckManager(deck, [deck], {}, Mock()) + deck = Mock(id="main", handle_key=AsyncMock(side_effect=Exception("Error"))) + deck_manager = DeckManager([deck], make_global_config(), Mock()) await deck_manager.key_callback(Mock(), 0, False) - deck = Mock(handle_key=AsyncMock(side_effect=SwitchDeckException("new_deck"))) - deck_manager = DeckManager(deck, [deck], {}, Mock()) + deck = Mock(id="main", handle_key=AsyncMock(return_value=SwitchDeckAction("new_deck"))) + deck_manager = DeckManager([deck], make_global_config(), Mock()) - with patch.object( - deck_manager, "switch_deck", AsyncMock(side_effect=Exception("Error")) - ) as switch_deck: + with patch.object(deck_manager, "switch_deck", AsyncMock(side_effect=Exception("Error"))) as switch_deck: await deck_manager.key_callback(Mock(), 0, False) assert switch_deck.called + switch_deck.assert_called_with("new_deck") async def test_deck_manager_switch_deck() -> None: deck1 = Mock( - id="deck", + id="main", activate=AsyncMock(), deactivate=AsyncMock(), ) @@ -52,7 +57,7 @@ async def test_deck_manager_switch_deck() -> None: activate=AsyncMock(), deactivate=AsyncMock(), ) - deck_manager = DeckManager(deck1, [deck1, deck2], {}, Mock()) + deck_manager = DeckManager([deck1, deck2], make_global_config(), Mock()) await deck_manager.switch_deck("other") assert deck_manager.active_deck == deck2 @@ -64,10 +69,12 @@ async def test_deck_manager_switch_deck() -> None: async def test_deck_manager_sleep_activation() -> None: - deck = Mock(spec=Deck) - deck_manager = DeckManager( - deck, [deck], {"knoepfe.config.device": {"sleep_timeout": 1.0}}, MagicMock() + deck = Mock(id="main", spec=Deck) + config = GlobalConfig( + device=DeviceConfig(sleep_timeout=1.0), + deck={"main": []}, ) + deck_manager = DeckManager([deck], config, MagicMock()) deck_manager.last_action = 0.0 with ( @@ -84,18 +91,20 @@ async def test_deck_manager_sleep_activation() -> None: async def test_deck_manager_sleep() -> None: - deck_manager = DeckManager(Mock(), [], {}, MagicMock()) - with patch("knoepfe.deckmanager.sleep", AsyncMock()): + deck = Mock(id="main") + deck_manager = DeckManager([deck], make_global_config(), MagicMock()) + with patch("knoepfe.core.deckmanager.sleep", AsyncMock()): await deck_manager.sleep() assert deck_manager.sleeping async def test_deck_wake_up() -> None: deck = Mock( + id="main", activate=AsyncMock(), - handle_key=AsyncMock(side_effect=SwitchDeckException("new_deck")), + handle_key=AsyncMock(return_value=SwitchDeckAction("new_deck")), ) - deck_manager = DeckManager(deck, [deck], {}, MagicMock()) + deck_manager = DeckManager([deck], make_global_config(), MagicMock()) deck_manager.sleeping = True with patch.object(deck_manager, "switch_deck", AsyncMock()) as switch_deck: @@ -103,8 +112,8 @@ async def test_deck_wake_up() -> None: assert not switch_deck.called assert not deck_manager.sleeping - deck = Mock(activate=AsyncMock()) - deck_manager = DeckManager(deck, [deck], {}, MagicMock()) + deck = Mock(id="main", activate=AsyncMock()) + deck_manager = DeckManager([deck], make_global_config(), MagicMock()) deck_manager.sleeping = True deck_manager.wake_lock.acquire() diff --git a/tests/test_env_vars.py b/tests/test_env_vars.py new file mode 100644 index 0000000..a4bbba4 --- /dev/null +++ b/tests/test_env_vars.py @@ -0,0 +1,87 @@ +"""Tests for environment variable support in configuration.""" + +from knoepfe.config.loader import load_config + + +def test_env_var_overrides_toml(tmp_path, monkeypatch): + """Test that environment variables override TOML values.""" + config_content = """ +[device] +brightness = 80 +sleep_timeout = 30.0 + +[[deck.main]] +type = "Clock" +""" + + config_file = tmp_path / "test.toml" + config_file.write_text(config_content) + + # Set environment variable to override brightness + monkeypatch.setenv("KNOEPFE_DEVICE__BRIGHTNESS", "50") + + config = load_config(config_file) + + # Environment variable should override TOML value + assert config.device.brightness == 50 + # Other values should remain from TOML + assert config.device.sleep_timeout == 30.0 + + +def test_env_var_nested_delimiter(tmp_path, monkeypatch): + """Test that nested environment variables work with __ delimiter.""" + config_content = """ +[[deck.main]] +type = "Clock" +""" + + config_file = tmp_path / "test.toml" + config_file.write_text(config_content) + + # Set nested environment variable + monkeypatch.setenv("KNOEPFE_DEVICE__SLEEP_TIMEOUT", "60.0") + + config = load_config(config_file) + + # Environment variable should set the nested value + assert config.device.sleep_timeout == 60.0 + + +def test_env_var_without_toml_value(tmp_path, monkeypatch): + """Test that environment variables can set values not in TOML.""" + config_content = """ +[[deck.main]] +type = "Clock" +""" + + config_file = tmp_path / "test.toml" + config_file.write_text(config_content) + + # Set environment variable for a value not in TOML + monkeypatch.setenv("KNOEPFE_DEVICE__SERIAL_NUMBER", "ABC123") + + config = load_config(config_file) + + # Environment variable should set the value + assert config.device.serial_number == "ABC123" + + +def test_no_env_vars_uses_toml_defaults(tmp_path): + """Test that without env vars, TOML values are used.""" + config_content = """ +[device] +brightness = 75 + +[[deck.main]] +type = "Clock" +""" + + config_file = tmp_path / "test.toml" + config_file.write_text(config_content) + + config = load_config(config_file) + + # Should use TOML value + assert config.device.brightness == 75 + # Should use default value for unspecified fields + assert config.device.sleep_timeout == 10.0 diff --git a/tests/test_key.py b/tests/test_key.py deleted file mode 100644 index 66a161f..0000000 --- a/tests/test_key.py +++ /dev/null @@ -1,66 +0,0 @@ -from unittest.mock import DEFAULT, MagicMock, Mock, patch - -from knoepfe.key import Key, Renderer - - -def test_renderer_text() -> None: - renderer = Renderer() - with patch.object(renderer, "_render_text") as draw_text: - renderer.text("Blubb") - assert draw_text.called - - -def test_renderer_icon() -> None: - renderer = Renderer() - with patch.object(renderer, "_render_text") as draw_text: - renderer.icon("mic") - assert draw_text.called - - -def test_renderer_icon_and_text() -> None: - renderer = Renderer() - with patch.object(renderer, "_render_text") as draw_text: - renderer.icon_and_text("mic", "text") - assert draw_text.call_count == 2 - - -def test_renderer_draw_text() -> None: - renderer = Renderer() - - with patch( - "knoepfe.key.ImageDraw.Draw", - return_value=Mock(textlength=Mock(return_value=0)), - ) as draw: - renderer._render_text("text", "Text", size=12, color=None, valign="top") - assert draw.return_value.text.call_args[0][0] == (48, 0) - - with patch( - "knoepfe.key.ImageDraw.Draw", - return_value=Mock(textlength=Mock(return_value=0)), - ) as draw: - renderer._render_text("text", "Text", size=12, color=None, valign="middle") - assert draw.return_value.text.call_args[0][0] == (48, 42) - - with patch( - "knoepfe.key.ImageDraw.Draw", - return_value=Mock(textlength=Mock(return_value=0)), - ) as draw: - renderer._render_text("text", "Text", size=12, color=None, valign="bottom") - assert draw.return_value.text.call_args[0][0] == (48, 78) - - -def test_key_render() -> None: - key = Key(MagicMock(), 0) - - with patch.multiple("knoepfe.key", PILHelper=DEFAULT, Renderer=DEFAULT): - with key.renderer(): - pass - - assert key.device.set_key_image.called - - -def test_key_aligned() -> None: - renderer = Renderer() - assert renderer._aligned(10, 10, "left", "top") == (0, 0) - assert renderer._aligned(10, 10, "center", "middle") == (43, 43) - assert renderer._aligned(10, 10, "right", "bottom") == (86, 80) diff --git a/tests/test_main.py b/tests/test_main.py index 3ac8db3..e93253b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -3,48 +3,130 @@ from pytest import raises from StreamDeck.Transport.Transport import TransportError -from knoepfe.__main__ import Knoepfe, main +from knoepfe.cli import main +from knoepfe.core.app import Knoepfe def test_main_success() -> None: - with patch("knoepfe.__main__.run"), patch("knoepfe.__main__.Knoepfe") as knoepfe: - main() - assert knoepfe.return_value.run.called + with ( + patch("knoepfe.cli.Knoepfe") as knoepfe, + patch("sys.argv", ["knoepfe"]), + ): + with raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + assert knoepfe.return_value.run_sync.called async def test_run() -> None: knoepfe = Knoepfe() - with patch("knoepfe.__main__.process_config", side_effect=RuntimeError("Error")): - with raises(RuntimeError): + # Test config loading error - should fail before trying to connect to device + # Patch where load_config is USED (in app.py), not where it's defined + with patch("knoepfe.core.app.load_config", side_effect=RuntimeError("Error")): + with raises(RuntimeError, match="Error"): await knoepfe.run(None) + # Test normal run with TransportError retry then SystemExit with ( patch.object(knoepfe, "connect_device", AsyncMock(return_value=Mock())), - patch.multiple( - "knoepfe.__main__", - process_config=Mock(return_value=({}, Mock(), [Mock()])), - DeckManager=Mock( - return_value=Mock( - run=Mock(side_effect=[TransportError(), SystemExit()]) - ) - ), - ), + patch("knoepfe.core.app.load_config") as mock_load_config, + patch("knoepfe.core.app.create_decks") as mock_create_decks, + patch("knoepfe.core.app.DeckManager") as MockDeckManager, ): + # Setup mocks + mock_load_config.return_value = Mock() + mock_create_decks.return_value = [Mock()] + + # Create mock DeckManager that raises exceptions on run() + # First run() raises TransportError (triggers retry), second raises SystemExit (exits loop) + mock_deck_manager = Mock() + mock_deck_manager.run = AsyncMock(side_effect=[TransportError(), SystemExit()]) + MockDeckManager.return_value = mock_deck_manager + with raises(SystemExit): await knoepfe.run(None) + # Verify DeckManager was instantiated twice (once for TransportError, once for SystemExit) + assert MockDeckManager.call_count == 2 + # Verify run() was called twice + assert mock_deck_manager.run.call_count == 2 + async def test_connect_device() -> None: knoepfe = Knoepfe() + mock_config = Mock() + mock_config.device.serial_number = None with ( patch( - "knoepfe.__main__.DeviceManager.enumerate", + "knoepfe.core.app.DeviceManager.enumerate", side_effect=([], [Mock(key_layout=Mock(return_value=(2, 2)))]), ) as device_manager_enumerate, - patch("knoepfe.__main__.sleep", AsyncMock()), + patch("knoepfe.core.app.sleep", AsyncMock()), ): - await knoepfe.connect_device() + await knoepfe.connect_device(mock_config) assert device_manager_enumerate.called + + +async def test_connect_device_with_serial_number() -> None: + """Test connecting to a device with a specific serial number.""" + knoepfe = Knoepfe() + mock_config = Mock() + mock_config.device.serial_number = "ABC123" + + # Create mock devices with different serial numbers + mock_device_1 = Mock() + mock_device_1.get_serial_number.return_value = "XYZ789" + mock_device_1.key_layout.return_value = (2, 2) + + mock_device_2 = Mock() + mock_device_2.get_serial_number.return_value = "ABC123" + mock_device_2.key_layout.return_value = (3, 5) + + with ( + patch( + "knoepfe.core.app.DeviceManager.enumerate", + return_value=[mock_device_1, mock_device_2], + ), + patch("knoepfe.core.app.sleep", AsyncMock()), + ): + device = await knoepfe.connect_device(mock_config) + + # Verify the correct device was selected + assert device == mock_device_2 + assert mock_device_1.open.called + assert mock_device_1.close.called + assert mock_device_2.open.call_count == 2 # Once for checking, once for final connection + + +async def test_connect_device_serial_not_found() -> None: + """Test that the app keeps searching when the specified serial is not found.""" + knoepfe = Knoepfe() + mock_config = Mock() + mock_config.device.serial_number = "NOTFOUND" + + # Create mock devices with different serial numbers + mock_device_1 = Mock() + mock_device_1.get_serial_number.return_value = "XYZ789" + + mock_device_2 = Mock() + mock_device_2.get_serial_number.return_value = "ABC123" + + # First call returns devices without target serial, second call includes it + target_device = Mock() + target_device.get_serial_number.return_value = "NOTFOUND" + target_device.key_layout.return_value = (3, 5) + + with ( + patch( + "knoepfe.core.app.DeviceManager.enumerate", + side_effect=[[mock_device_1, mock_device_2], [target_device]], + ), + patch("knoepfe.core.app.sleep", AsyncMock()), + ): + device = await knoepfe.connect_device(mock_config) + + # Verify the correct device was eventually found + assert device == target_device diff --git a/tests/test_plugin_lifecycle.py b/tests/test_plugin_lifecycle.py new file mode 100644 index 0000000..c3bc17d --- /dev/null +++ b/tests/test_plugin_lifecycle.py @@ -0,0 +1,231 @@ +"""Tests for plugin lifecycle hooks.""" + +from unittest.mock import AsyncMock, Mock + +from StreamDeck.Devices.StreamDeck import StreamDeck + +from knoepfe.config.models import DeviceConfig, GlobalConfig +from knoepfe.config.plugin import PluginConfig +from knoepfe.config.widget import WidgetConfig +from knoepfe.core.deck import Deck +from knoepfe.plugins.plugin import Plugin +from knoepfe.widgets.widget import UpdateResult, Widget + + +def make_global_config() -> GlobalConfig: + """Helper to create GlobalConfig for tests.""" + return GlobalConfig(device=DeviceConfig(), deck={"main": []}) + + +class MockPluginConfig(PluginConfig): + """Test plugin configuration.""" + + pass + + +class MockPlugin(Plugin): + """Test plugin with lifecycle tracking.""" + + def __init__(self, config: MockPluginConfig): + super().__init__(config) + self.activated_widgets = [] + self.deactivated_widgets = [] + + async def on_widget_activate(self, widget: Widget) -> None: + """Track widget activation.""" + await super().on_widget_activate(widget) + self.activated_widgets.append(widget) + + async def on_widget_deactivate(self, widget: Widget) -> None: + """Track widget deactivation.""" + self.deactivated_widgets.append(widget) + await super().on_widget_deactivate(widget) + + +class MockWidget(Widget[WidgetConfig, MockPlugin]): + """Test widget implementation.""" + + name = "MockWidget" + + async def update(self, renderer) -> UpdateResult: + """Dummy update implementation.""" + return UpdateResult.UPDATED + + +async def test_plugin_receives_widget_reference(): + """Test that plugin receives the correct widget reference.""" + config = MockPluginConfig() + plugin = MockPlugin(config) + widget_config = WidgetConfig() + widget = MockWidget(widget_config, plugin) + + # Activate widget + await plugin.on_widget_activate(widget) + assert len(plugin.activated_widgets) == 1 + assert plugin.activated_widgets[0] is widget + + # Deactivate widget + await plugin.on_widget_deactivate(widget) + assert len(plugin.deactivated_widgets) == 1 + assert plugin.deactivated_widgets[0] is widget + + +async def test_deck_calls_lifecycle_hooks_on_activate(): + """Test that Deck calls plugin lifecycle hooks on activation.""" + # Create mock plugin with lifecycle methods + plugin = Mock(spec=Plugin) + plugin.on_widget_activate = AsyncMock() + plugin.on_widget_deactivate = AsyncMock() + + # Create mock widget + widget = Mock(spec=Widget) + widget.plugin = plugin + widget.config = Mock() + widget.config.index = None + widget.activate = AsyncMock() + widget.update = AsyncMock() + widget.needs_update = False + + # Create deck and activate + deck = Deck("test", [widget], make_global_config()) + device = Mock(spec=StreamDeck) + device.key_count = Mock(return_value=4) + device.key_image_format = Mock( + return_value={"size": (96, 96), "format": "JPEG", "rotation": 0, "flip": (False, False)} + ) + device.__enter__ = Mock(return_value=device) + device.__exit__ = Mock(return_value=None) + device.set_key_image = Mock() + + await deck.activate(device, Mock(), Mock()) + + # Verify lifecycle hook was called before widget activation + plugin.on_widget_activate.assert_called_once_with(widget) + widget.activate.assert_called_once() + + +async def test_deck_calls_lifecycle_hooks_on_deactivate(): + """Test that Deck calls plugin lifecycle hooks on deactivation.""" + # Create mock plugin with lifecycle methods + plugin = Mock(spec=Plugin) + plugin.on_widget_activate = AsyncMock() + plugin.on_widget_deactivate = AsyncMock() + + # Create mock widget + widget = Mock(spec=Widget) + widget.plugin = plugin + widget.config = Mock() + widget.config.index = None + widget.deactivate = AsyncMock() + widget.tasks = Mock() + widget.tasks.cleanup = Mock() + + # Create deck and deactivate + deck = Deck("test", [widget], make_global_config()) + device = Mock(spec=StreamDeck) + + await deck.deactivate(device) + + # Verify lifecycle hook was called after widget deactivation + widget.deactivate.assert_called_once() + plugin.on_widget_deactivate.assert_called_once_with(widget) + + +async def test_deck_calls_lifecycle_hooks_for_all_widgets(): + """Test that Deck calls lifecycle hooks for all widgets.""" + # Create shared plugin + plugin = Mock(spec=Plugin) + plugin.on_widget_activate = AsyncMock() + plugin.on_widget_deactivate = AsyncMock() + + # Create multiple widgets + widgets = [] + for _ in range(3): + widget = Mock(spec=Widget) + widget.plugin = plugin + widget.config = Mock() + widget.config.index = None + widget.activate = AsyncMock() + widget.deactivate = AsyncMock() + widget.update = AsyncMock() + widget.needs_update = False + widget.tasks = Mock() + widget.tasks.cleanup = Mock() + widgets.append(widget) + + # Create deck + deck = Deck("test", widgets, make_global_config()) + device = Mock(spec=StreamDeck) + device.key_count = Mock(return_value=4) + device.key_image_format = Mock( + return_value={"size": (96, 96), "format": "JPEG", "rotation": 0, "flip": (False, False)} + ) + device.__enter__ = Mock(return_value=device) + device.__exit__ = Mock(return_value=None) + device.set_key_image = Mock() + + # Activate deck + await deck.activate(device, Mock(), Mock()) + assert plugin.on_widget_activate.call_count == 3 + + # Deactivate deck + await deck.deactivate(device) + assert plugin.on_widget_deactivate.call_count == 3 + + +async def test_lifecycle_hooks_called_in_correct_order(): + """Test that lifecycle hooks are called in the correct order relative to widget methods.""" + call_order = [] + + # Create plugin that tracks call order + plugin = Mock(spec=Plugin) + + async def track_activate(widget): + call_order.append("plugin.on_widget_activate") + + async def track_deactivate(widget): + call_order.append("plugin.on_widget_deactivate") + + plugin.on_widget_activate = AsyncMock(side_effect=track_activate) + plugin.on_widget_deactivate = AsyncMock(side_effect=track_deactivate) + + # Create widget that tracks call order + widget = Mock(spec=Widget) + widget.plugin = plugin + widget.config = Mock() + widget.config.index = None + widget.update = AsyncMock() + widget.needs_update = False + widget.tasks = Mock() + widget.tasks.cleanup = Mock() + + async def track_widget_activate(): + call_order.append("widget.activate") + + async def track_widget_deactivate(): + call_order.append("widget.deactivate") + + widget.activate = AsyncMock(side_effect=track_widget_activate) + widget.deactivate = AsyncMock(side_effect=track_widget_deactivate) + + # Create deck + deck = Deck("test", [widget], make_global_config()) + device = Mock(spec=StreamDeck) + device.key_count = Mock(return_value=4) + device.key_image_format = Mock( + return_value={"size": (96, 96), "format": "JPEG", "rotation": 0, "flip": (False, False)} + ) + device.__enter__ = Mock(return_value=device) + device.__exit__ = Mock(return_value=None) + device.set_key_image = Mock() + + # Activate and verify order + await deck.activate(device, Mock(), Mock()) + assert call_order[0] == "plugin.on_widget_activate" + assert call_order[1] == "widget.activate" + + # Deactivate and verify order + call_order.clear() + await deck.deactivate(device) + assert call_order[0] == "widget.deactivate" + assert call_order[1] == "plugin.on_widget_deactivate" diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py new file mode 100644 index 0000000..a24a0e8 --- /dev/null +++ b/tests/test_plugin_manager.py @@ -0,0 +1,562 @@ +"""Tests for plugin manager functionality.""" + +from unittest.mock import Mock, patch + +import pytest +from pydantic import Field + +from knoepfe.config.plugin import EmptyPluginConfig, PluginConfig +from knoepfe.config.widget import EmptyConfig +from knoepfe.plugins.descriptor import PluginDescriptor +from knoepfe.plugins.manager import PluginManager +from knoepfe.plugins.plugin import Plugin +from knoepfe.widgets.widget import UpdateResult, Widget + + +class MockWidgetConfig(EmptyConfig): + """Config for mock widget.""" + + pass + + +class MockWidget(Widget[MockWidgetConfig, Plugin]): + """A mock widget for testing.""" + + name = "MockWidget" + + async def update(self, renderer) -> UpdateResult: + return UpdateResult.UPDATED + + +class MockWidgetNoSchema(Widget[EmptyConfig, Plugin]): + name = "MockWidgetNoSchema" + + async def update(self, renderer) -> UpdateResult: + return UpdateResult.UPDATED + + +class MockPluginDescriptorConfig(PluginConfig): + """Config for mock plugin.""" + + test_config: str = Field(default="default", description="Test configuration") + + +class MockPluginDescriptor(PluginDescriptor[MockPluginDescriptorConfig, Plugin]): + """Mock plugin descriptor for testing.""" + + @classmethod + def widgets(cls) -> list[type[Widget]]: + return [MockWidget, MockWidgetNoSchema] + + +class MockPluginDescriptor1(PluginDescriptor[EmptyPluginConfig, Plugin]): + """First mock plugin descriptor for testing.""" + + @classmethod + def widgets(cls) -> list[type[Widget]]: + return [] + + +class MockPluginDescriptor2(PluginDescriptor[EmptyPluginConfig, Plugin]): + """Second mock plugin descriptor for testing.""" + + @classmethod + def widgets(cls) -> list[type[Widget]]: + return [] + + +def test_plugin_manager_init(): + """Test PluginManager initialization.""" + with patch("knoepfe.plugins.manager.entry_points") as mock_entry_points: + # Mock plugin entry points + mock_ep1 = Mock() + mock_ep1.name = "test" + mock_ep1.load.return_value = MockPluginDescriptor + # Mock the distribution object properly + mock_dist = Mock() + mock_dist.name = "test-package" + mock_dist.version = "1.0.0" + mock_dist.metadata = {"Summary": "Test plugin for testing"} + mock_ep1.dist = mock_dist + + mock_entry_points.return_value = [mock_ep1] + + # Create plugin manager with config + pm = PluginManager({"test": {"test_config": "value"}}) + + # Check that plugin is registered (name comes from entry point, not class) + assert "test" in pm._plugins + + # Check that plugin widgets are available from plugin manager + assert "MockWidget" in pm._widgets + assert "MockWidgetNoSchema" in pm._widgets + + +def test_plugin_manager_load_plugins_with_error(): + """Test PluginManager handles loading errors gracefully.""" + with patch("knoepfe.plugins.manager.entry_points") as mock_entry_points: + # Mock entry point that fails to load + mock_ep = Mock() + mock_ep.name = "failing_plugin" + mock_ep.load.side_effect = Exception("Failed to load") + + mock_entry_points.return_value = [mock_ep] + + with patch("knoepfe.plugins.manager.logger") as mock_logger: + pm = PluginManager() + + # Should not have registered the failing plugin + assert "failing_plugin" not in pm._plugins + mock_logger.exception.assert_called_once() + + +def test_plugin_manager_get_plugin(): + """Test getting plugin instance successfully.""" + with patch("knoepfe.plugins.manager.entry_points") as mock_entry_points: + # Mock plugin entry point + mock_ep = Mock() + mock_ep.name = "test_plugin" + mock_ep.load.return_value = MockPluginDescriptor + mock_dist = Mock() + mock_dist.name = "test-package" + mock_dist.version = "1.0.0" + mock_dist.metadata = {"Summary": "Test plugin"} + mock_ep.dist = mock_dist + mock_entry_points.return_value = [mock_ep] + + pm = PluginManager({"test_plugin": {"test_config": "value"}}) + + retrieved_plugin = pm.plugins["test_plugin"].plugin + assert isinstance(retrieved_plugin, Plugin) + + +def test_plugin_manager_get_nonexistent_plugin(): + """Test getting a non-existent plugin raises KeyError.""" + pm = PluginManager() + + with pytest.raises(KeyError): + pm.plugins["NonExistentPlugin"] + + +def test_plugin_manager_list_plugins(): + """Test listing all available plugins.""" + with patch("knoepfe.plugins.manager.entry_points") as mock_entry_points: + # Mock two plugin entry points + mock_ep1 = Mock() + mock_ep1.name = "plugin1" + mock_ep1.load.return_value = MockPluginDescriptor1 + mock_dist1 = Mock() + mock_dist1.name = "plugin1-package" + mock_dist1.version = "1.0.0" + mock_dist1.metadata = {"Summary": "Test plugin 1"} + mock_ep1.dist = mock_dist1 + + mock_ep2 = Mock() + mock_ep2.name = "plugin2" + mock_ep2.load.return_value = MockPluginDescriptor2 + mock_dist2 = Mock() + mock_dist2.name = "plugin2-package" + mock_dist2.version = "1.0.0" + mock_dist2.metadata = {"Summary": "Test plugin 2"} + mock_ep2.dist = mock_dist2 + + mock_entry_points.return_value = [mock_ep1, mock_ep2] + + pm = PluginManager() + + assert "plugin1" in pm._plugins + assert "plugin2" in pm._plugins + + +def test_plugin_manager_with_plugin_config(): + """Test plugin manager accepts configuration in constructor.""" + config = {"test_key": "test_value"} + pm = PluginManager({"test_plugin": config}) + assert pm._plugin_configs["test_plugin"] == config + + +def test_plugin_manager_register_plugin(): + """Test that plugins are registered via entry points.""" + with patch("knoepfe.plugins.manager.entry_points") as mock_entry_points: + # Mock plugin entry point + mock_ep = Mock() + mock_ep.name = "test_plugin" + mock_ep.load.return_value = MockPluginDescriptor + mock_dist = Mock() + mock_dist.name = "test-package" + mock_dist.version = "1.0.0" + mock_dist.metadata = {"Summary": "Test plugin"} + mock_ep.dist = mock_dist + mock_entry_points.return_value = [mock_ep] + + pm = PluginManager({"test_plugin": {"test_config": "value"}}) + + assert "test_plugin" in pm.plugins + # Verify plugin info contains the plugin class + assert pm.plugins["test_plugin"].descriptor_class == MockPluginDescriptor + # Verify plugin was created + assert isinstance(pm.plugins["test_plugin"].plugin, Plugin) + + # Check that plugin widgets are available from plugin manager + assert "MockWidget" in pm.widgets + assert "MockWidgetNoSchema" in pm.widgets + + +def test_plugin_manager_register_duplicate_plugin(): + """Test that duplicate plugin names in entry points are handled.""" + with patch("knoepfe.plugins.manager.entry_points") as mock_entry_points: + # Mock two entry points with the same name (shouldn't happen in practice) + mock_ep1 = Mock() + mock_ep1.name = "test_plugin" + mock_ep1.load.return_value = MockPluginDescriptor + mock_dist1 = Mock() + mock_dist1.name = "test-package-1" + mock_dist1.version = "1.0.0" + mock_dist1.metadata = {"Summary": "Test plugin 1"} + mock_ep1.dist = mock_dist1 + + mock_ep2 = Mock() + mock_ep2.name = "test_plugin" # Same name + mock_ep2.load.return_value = MockPluginDescriptor + mock_dist2 = Mock() + mock_dist2.name = "test-package-2" + mock_dist2.version = "1.0.0" + mock_dist2.metadata = {"Summary": "Test plugin 2"} + mock_ep2.dist = mock_dist2 + + mock_entry_points.return_value = [mock_ep1, mock_ep2] + + pm = PluginManager({"test_plugin": {"test_config": "value"}}) + + # Second plugin should overwrite the first + assert "test_plugin" in pm._plugins + assert len([p for p in pm._plugins if p == "test_plugin"]) == 1 + + +def test_plugin_manager_register_plugin_with_duplicate_widget(): + """Test that PluginManager warns about duplicate widget names.""" + with patch("knoepfe.plugins.manager.entry_points") as mock_entry_points: + with patch("knoepfe.plugins.manager.logger") as mock_logger: + # Mock two plugins with the same widget names + mock_ep1 = Mock() + mock_ep1.name = "plugin1" + mock_ep1.load.return_value = MockPluginDescriptor + mock_dist1 = Mock() + mock_dist1.name = "plugin1-package" + mock_dist1.version = "1.0.0" + mock_dist1.metadata = {"Summary": "Test plugin 1"} + mock_ep1.dist = mock_dist1 + + mock_ep2 = Mock() + mock_ep2.name = "plugin2" + mock_ep2.load.return_value = MockPluginDescriptor # Same widgets + mock_dist2 = Mock() + mock_dist2.name = "plugin2-package" + mock_dist2.version = "1.0.0" + mock_dist2.metadata = {"Summary": "Test plugin 2"} + mock_ep2.dist = mock_dist2 + + mock_entry_points.return_value = [mock_ep1, mock_ep2] + + pm = PluginManager({"plugin1": {"test_config": "value"}, "plugin2": {"test_config": "value"}}) + + # Both plugins should be registered + assert "plugin1" in pm._plugins + assert "plugin2" in pm._plugins + + # Should have logged warnings about duplicate widgets + assert mock_logger.warning.called + + +def test_plugin_manager_shutdown_all(): + """Test shutting down all plugins.""" + with patch("knoepfe.plugins.manager.entry_points") as mock_entry_points: + # Mock plugin entry point + mock_ep = Mock() + mock_ep.name = "test_plugin" + mock_ep.load.return_value = MockPluginDescriptor + mock_dist = Mock() + mock_dist.name = "test-package" + mock_dist.version = "1.0.0" + mock_dist.metadata = {"Summary": "Test plugin"} + mock_ep.dist = mock_dist + mock_entry_points.return_value = [mock_ep] + + pm = PluginManager({"test_plugin": {"test_config": "value"}}) + + # Get the plugin instance and mock its shutdown method + plugin = pm.plugins["test_plugin"].plugin + plugin.shutdown = Mock() + + pm.shutdown_all() + plugin.shutdown.assert_called_once() + + +def test_plugin_manager_disabled_plugin(): + """Test that disabled plugins are not loaded.""" + with patch("knoepfe.plugins.manager.entry_points") as mock_entry_points: + with patch("knoepfe.plugins.manager.logger") as mock_logger: + # Mock plugin entry point + mock_ep = Mock() + mock_ep.name = "test_plugin" + mock_ep.load.return_value = MockPluginDescriptor + mock_dist = Mock() + mock_dist.name = "test-package" + mock_dist.version = "1.0.0" + mock_dist.metadata = {"Summary": "Test plugin"} + mock_ep.dist = mock_dist + mock_entry_points.return_value = [mock_ep] + + # Create plugin manager with disabled plugin + pm = PluginManager({"test_plugin": {"enabled": False}}) + + # Plugin should not be registered + assert "test_plugin" not in pm._plugins + + # Widgets from disabled plugin should not be available + assert "MockWidget" not in pm._widgets + assert "MockWidgetNoSchema" not in pm._widgets + + # Should have logged that plugin was skipped + mock_logger.info.assert_called_with("Plugin 'test_plugin' is disabled in config, skipping") + + +def test_plugin_manager_enabled_plugin_explicit(): + """Test that explicitly enabled plugins are loaded.""" + with patch("knoepfe.plugins.manager.entry_points") as mock_entry_points: + # Mock plugin entry point + mock_ep = Mock() + mock_ep.name = "test_plugin" + mock_ep.load.return_value = MockPluginDescriptor + mock_dist = Mock() + mock_dist.name = "test-package" + mock_dist.version = "1.0.0" + mock_dist.metadata = {"Summary": "Test plugin"} + mock_ep.dist = mock_dist + mock_entry_points.return_value = [mock_ep] + + # Create plugin manager with explicitly enabled plugin + pm = PluginManager({"test_plugin": {"enabled": True, "test_config": "value"}}) + + # Plugin should be registered + assert "test_plugin" in pm._plugins + + # Widgets should be available + assert "MockWidget" in pm._widgets + assert "MockWidgetNoSchema" in pm._widgets + + +def test_plugin_manager_enabled_by_default(): + """Test that plugins are enabled by default when enabled field is not specified.""" + with patch("knoepfe.plugins.manager.entry_points") as mock_entry_points: + # Mock plugin entry point + mock_ep = Mock() + mock_ep.name = "test_plugin" + mock_ep.load.return_value = MockPluginDescriptor + mock_dist = Mock() + mock_dist.name = "test-package" + mock_dist.version = "1.0.0" + mock_dist.metadata = {"Summary": "Test plugin"} + mock_ep.dist = mock_dist + mock_entry_points.return_value = [mock_ep] + + # Create plugin manager without specifying enabled field + pm = PluginManager({"test_plugin": {"test_config": "value"}}) + + # Plugin should be registered (enabled by default) + assert "test_plugin" in pm._plugins + + # Widgets should be available + assert "MockWidget" in pm._widgets + assert "MockWidgetNoSchema" in pm._widgets + + +def test_plugin_manager_mixed_enabled_disabled(): + """Test loading multiple plugins with different enabled states.""" + with patch("knoepfe.plugins.manager.entry_points") as mock_entry_points: + # Mock two plugin entry points + mock_ep1 = Mock() + mock_ep1.name = "enabled_plugin" + mock_ep1.load.return_value = MockPluginDescriptor1 + mock_dist1 = Mock() + mock_dist1.name = "enabled-package" + mock_dist1.version = "1.0.0" + mock_dist1.metadata = {"Summary": "Enabled plugin"} + mock_ep1.dist = mock_dist1 + + mock_ep2 = Mock() + mock_ep2.name = "disabled_plugin" + mock_ep2.load.return_value = MockPluginDescriptor2 + mock_dist2 = Mock() + mock_dist2.name = "disabled-package" + mock_dist2.version = "1.0.0" + mock_dist2.metadata = {"Summary": "Disabled plugin"} + mock_ep2.dist = mock_dist2 + + mock_entry_points.return_value = [mock_ep1, mock_ep2] + + # Create plugin manager with one enabled and one disabled + pm = PluginManager({"enabled_plugin": {"enabled": True}, "disabled_plugin": {"enabled": False}}) + + # Only enabled plugin should be registered + assert "enabled_plugin" in pm._plugins + assert "disabled_plugin" not in pm._plugins + + +def test_plugin_manager_extracts_description_from_docstring(): + """Test that plugin descriptions are extracted from class docstrings.""" + with patch("knoepfe.plugins.manager.entry_points") as mock_entry_points: + # Mock plugin entry point + mock_ep = Mock() + mock_ep.name = "test_plugin" + mock_ep.load.return_value = MockPluginDescriptor + mock_dist = Mock() + mock_dist.name = "test-package" + mock_dist.version = "1.0.0" + mock_ep.dist = mock_dist + mock_entry_points.return_value = [mock_ep] + + pm = PluginManager({"test_plugin": {"test_config": "value"}}) + + # Verify plugin is registered + assert "test_plugin" in pm._plugins + + # Verify description is extracted from docstring + plugin_info = pm._plugins["test_plugin"] + assert plugin_info.description == "Mock plugin descriptor for testing." + + +def test_plugin_manager_handles_missing_docstring(): + """Test that plugin manager handles descriptors without docstrings. + + When a descriptor doesn't have its own docstring, inspect.getdoc() returns + the parent class docstring, which is the expected Python behavior. + """ + + class DescriptorWithoutDocstring(PluginDescriptor[EmptyPluginConfig, Plugin]): + @classmethod + def widgets(cls) -> list[type[Widget]]: + return [] + + with patch("knoepfe.plugins.manager.entry_points") as mock_entry_points: + # Mock plugin entry point + mock_ep = Mock() + mock_ep.name = "no_docstring_plugin" + mock_ep.load.return_value = DescriptorWithoutDocstring + mock_dist = Mock() + mock_dist.name = "test-package" + mock_dist.version = "1.0.0" + mock_ep.dist = mock_dist + mock_entry_points.return_value = [mock_ep] + + pm = PluginManager({"no_docstring_plugin": {}}) + + # Verify plugin is registered + assert "no_docstring_plugin" in pm._plugins + + # Verify description inherits from parent class when no docstring exists + plugin_info = pm._plugins["no_docstring_plugin"] + assert plugin_info.description is not None + assert "Base class for all knoepfe plugin descriptors" in plugin_info.description + + +def test_plugin_info_attributes(): + """Test that all PluginInfo attributes are correctly populated.""" + with patch("knoepfe.plugins.manager.entry_points") as mock_entry_points: + # Mock plugin entry point + mock_ep = Mock() + mock_ep.name = "test_plugin" + mock_ep.load.return_value = MockPluginDescriptor + mock_dist = Mock() + mock_dist.name = "test-package" + mock_dist.version = "1.2.3" + mock_ep.dist = mock_dist + mock_entry_points.return_value = [mock_ep] + + pm = PluginManager({"test_plugin": {"test_config": "custom_value"}}) + + # Verify plugin is registered + assert "test_plugin" in pm._plugins + plugin_info = pm._plugins["test_plugin"] + + # Test name attribute + assert plugin_info.name == "test_plugin" + + # Test version attribute + assert plugin_info.version == "1.2.3" + + # Test descriptor_class attribute + assert plugin_info.descriptor_class == MockPluginDescriptor + + # Test config attribute + assert isinstance(plugin_info.config, MockPluginDescriptorConfig) + assert plugin_info.config.test_config == "custom_value" + assert plugin_info.config.enabled is True + + # Test plugin attribute + assert isinstance(plugin_info.plugin, Plugin) + + # Test description attribute + assert plugin_info.description == "Mock plugin descriptor for testing." + + # Test widgets attribute + assert len(plugin_info.widgets) == 2 + widget_names = [w.name for w in plugin_info.widgets] + assert "MockWidget" in widget_names + assert "MockWidgetNoSchema" in widget_names + + +def test_plugin_info_version_fallback(): + """Test that version falls back to 'unknown' when dist is None.""" + with patch("knoepfe.plugins.manager.entry_points") as mock_entry_points: + # Mock plugin entry point without dist + mock_ep = Mock() + mock_ep.name = "test_plugin" + mock_ep.load.return_value = MockPluginDescriptor + mock_ep.dist = None # No distribution info + mock_entry_points.return_value = [mock_ep] + + pm = PluginManager({"test_plugin": {}}) + + # Verify plugin is registered + assert "test_plugin" in pm._plugins + plugin_info = pm._plugins["test_plugin"] + + # Test version falls back to "unknown" + assert plugin_info.version == "unknown" + + +def test_widget_info_attributes(): + """Test that WidgetInfo attributes are correctly populated.""" + with patch("knoepfe.plugins.manager.entry_points") as mock_entry_points: + # Mock plugin entry point + mock_ep = Mock() + mock_ep.name = "test_plugin" + mock_ep.load.return_value = MockPluginDescriptor + mock_dist = Mock() + mock_dist.name = "test-package" + mock_dist.version = "1.0.0" + mock_ep.dist = mock_dist + mock_entry_points.return_value = [mock_ep] + + pm = PluginManager({"test_plugin": {}}) + + # Get widget info + assert "MockWidget" in pm._widgets + widget_info = pm._widgets["MockWidget"] + + # Test widget name + assert widget_info.name == "MockWidget" + + # Test widget description (extracted from docstring) + assert widget_info.description == "A mock widget for testing." + + # Test widget class + assert widget_info.widget_class == MockWidget + + # Test config type + assert widget_info.config_type == MockWidgetConfig + + # Test plugin_info reference + assert widget_info.plugin_info.name == "test_plugin" + assert widget_info.plugin_info.descriptor_class == MockPluginDescriptor diff --git a/tests/test_renderer.py b/tests/test_renderer.py new file mode 100644 index 0000000..2da3300 --- /dev/null +++ b/tests/test_renderer.py @@ -0,0 +1,321 @@ +from contextlib import contextmanager +from unittest.mock import Mock, patch + +from knoepfe.rendering import Renderer +from knoepfe.rendering.font_manager import FontManager + + +@contextmanager +def mock_fontconfig_system(): + """Context manager to mock the fontconfig system with common setup.""" + with patch("knoepfe.rendering.font_manager.fontconfig") as mock_fontconfig: + mock_fontconfig.query.return_value = ["/path/to/font.ttf"] + + with patch("knoepfe.rendering.font_manager.ImageFont.truetype") as mock_truetype: + mock_font = Mock() + mock_font.size = 12 # Default size for tests + # Mock the getmask2 method that PIL uses internally + mock_font.getmask2.return_value = (Mock(), (0, 0)) + mock_truetype.return_value = mock_font + + yield {"fontconfig": mock_fontconfig, "truetype": mock_truetype, "font": mock_font} + + +def test_renderer_text() -> None: + renderer = Renderer("Roboto", "RobotoMono Nerd Font") + with patch.object(renderer, "_draw") as mock_draw: + with mock_fontconfig_system(): + renderer.text((48, 48), "Blubb") + mock_draw.text.assert_called_once() + + +def test_renderer_draw_text() -> None: + with mock_fontconfig_system(): + renderer = Renderer("Roboto", "RobotoMono Nerd Font") + + with patch.object(renderer, "_draw") as mock_draw: + # Test basic text rendering + renderer.text((10, 20), "Test Text", size=12) + + # Check that text was called with correct parameters + mock_draw.text.assert_called_once() + call_args = mock_draw.text.call_args + assert call_args[0][0] == (10, 20) # position + assert call_args[0][1] == "Test Text" # text is positional arg + + +def test_renderer_convenience_methods() -> None: + with mock_fontconfig_system() as mocks: + # Mock getmetrics for text_multiline (ascent, descent) + mocks["font"].getmetrics.return_value = (12, 4) # Total height = 16 + + renderer = Renderer("Roboto", "RobotoMono Nerd Font") + + with patch.object(renderer, "_draw") as mock_draw: + # Test icon method + renderer.icon("test_icon", size=64) + mock_draw.text.assert_called() + + # Test text_multiline method + renderer.text_multiline("Test multiline text") + assert mock_draw.text.call_count >= 1 + + +def test_renderer_image_method() -> None: + """Test the image convenience method.""" + with mock_fontconfig_system(): + renderer = Renderer("Roboto", "RobotoMono Nerd Font") + + # Mock the draw_image method to verify it's called correctly + with patch.object(renderer, "draw_image") as mock_draw_image: + mock_draw_image.return_value = renderer # For method chaining + + # Test with default parameters (centered, 86px) + result = renderer.image("test.png") + + # Verify draw_image was called with correct parameters + # Default: size=86, position=(48,48) + # Calculated position: (48 - 86//2, 48 - 86//2) = (5, 5) + # Size passed to draw_image: (86, 86) + mock_draw_image.assert_called_once_with("test.png", (5, 5), (86, 86)) + + # Verify method chaining works + assert result is renderer + + +def test_renderer_image_method_custom_size() -> None: + """Test image method with custom size.""" + with mock_fontconfig_system(): + renderer = Renderer("Roboto", "RobotoMono Nerd Font") + + with patch.object(renderer, "draw_image") as mock_draw_image: + mock_draw_image.return_value = renderer + + # Test with custom size + renderer.image("test.png", size=64) + + # Verify draw_image was called with correct position for 64px image + # Position: (48 - 64//2, 48 - 64//2) = (16, 16) + mock_draw_image.assert_called_once_with("test.png", (16, 16), (64, 64)) + + +def test_renderer_image_method_custom_position() -> None: + """Test image method with custom position.""" + with mock_fontconfig_system(): + renderer = Renderer("Roboto", "RobotoMono Nerd Font") + + with patch.object(renderer, "draw_image") as mock_draw_image: + mock_draw_image.return_value = renderer + + # Test with custom position + renderer.image("test.png", position=(30, 40)) + + # Verify draw_image was called with position adjusted for centering + # Position: (30 - 86//2, 40 - 86//2) = (-13, -3) + mock_draw_image.assert_called_once_with("test.png", (-13, -3), (86, 86)) + + +def test_renderer_image_method_with_pil_image() -> None: + """Test image method with PIL Image object instead of path.""" + with mock_fontconfig_system(): + renderer = Renderer("Roboto", "RobotoMono Nerd Font") + + # Create a mock PIL Image object + from PIL import Image + + mock_img = Mock(spec=Image.Image) + + with patch.object(renderer, "draw_image") as mock_draw_image: + mock_draw_image.return_value = renderer + + # Pass PIL Image directly + renderer.image(mock_img) + + # Verify draw_image was called with the PIL Image object + mock_draw_image.assert_called_once_with(mock_img, (5, 5), (86, 86)) + + +def test_font_manager_get_font() -> None: + """Test FontManager font loading with mocked fontconfig.""" + # Clear the cache first to ensure clean test + FontManager.get_font.cache_clear() + + with mock_fontconfig_system() as mocks: + font = FontManager.get_font("Roboto", 24) + + assert font == mocks["font"] + mocks["fontconfig"].query.assert_called_with("Roboto") + mocks["truetype"].assert_called_with("/path/to/font.ttf", 24) + + +def test_font_manager_caching() -> None: + """Test FontManager font caching.""" + # Clear the cache first to ensure clean test + FontManager.get_font.cache_clear() + + with mock_fontconfig_system() as mocks: + font1 = FontManager.get_font("Roboto", 24) + font2 = FontManager.get_font("Roboto", 24) + + # Should be the same cached font + assert font1 is font2 + # truetype should only be called once due to caching + assert mocks["truetype"].call_count == 1 + + # Check cache info + cache_info = FontManager.get_font.cache_info() + assert cache_info.hits == 1 # Second call was a cache hit + assert cache_info.misses == 1 # First call was a cache miss + + +def test_font_manager_error_handling() -> None: + """Test FontManager error handling when pattern not found.""" + with mock_fontconfig_system() as mocks: + # Override to return empty list (no fonts found) + mocks["fontconfig"].query.return_value = [] + + # Should raise ValueError when no fonts found + try: + FontManager.get_font("nonexistent", 24) + raise AssertionError("Expected ValueError to be raised") + except ValueError as e: + assert "No font found for pattern: nonexistent" in str(e) + + mocks["fontconfig"].query.assert_called_with("nonexistent") + + +def test_renderer_fontconfig_integration() -> None: + """Test Renderer integration with FontManager.""" + with mock_fontconfig_system() as mocks: + # Override for Ubuntu font + mocks["fontconfig"].query.return_value = ["/path/to/ubuntu.ttf"] + + renderer = Renderer("Roboto", "RobotoMono Nerd Font") + + with patch.object(renderer, "_draw") as mock_draw: + # Test text with fontconfig pattern + renderer.text((48, 48), "Hello", font="Ubuntu", size=24) + + # Should have queried fontconfig for Ubuntu + mocks["fontconfig"].query.assert_called_with("Ubuntu") + mocks["truetype"].assert_called_with("/path/to/ubuntu.ttf", 24) + + # Should have drawn text + mock_draw.text.assert_called_once() + + +def test_renderer_text_at() -> None: + """Test Renderer text_at method.""" + with mock_fontconfig_system(): + renderer = Renderer("Roboto", "RobotoMono Nerd Font") + + with patch.object(renderer, "_draw") as mock_draw: + renderer.text((10, 20), "Positioned", font="monospace", anchor="la") + + mock_draw.text.assert_called_once() + call_args = mock_draw.text.call_args + assert call_args[0][0] == (10, 20) + assert call_args[0][1] == "Positioned" # text is positional arg + + +def test_renderer_backward_compatibility() -> None: + """Test that existing code without font parameter still works.""" + with mock_fontconfig_system(): + renderer = Renderer("Roboto", "RobotoMono Nerd Font") + + with patch.object(renderer, "_draw") as mock_draw: + # Test with default font (should use Roboto) + renderer.text((48, 48), "Legacy Text", size=20, color="#ffffff") + + mock_draw.text.assert_called_once() + call_args = mock_draw.text.call_args + assert call_args[0][1] == "Legacy Text" # text is positional arg + assert call_args[1]["fill"] == "#ffffff" + + +def test_renderer_unicode_icons() -> None: + """Test that Unicode icons work with fontconfig patterns.""" + with mock_fontconfig_system() as mocks: + # Override for Material Icons font + mocks["fontconfig"].query.return_value = ["/path/to/materialicons.ttf"] + + renderer = Renderer("Roboto", "RobotoMono Nerd Font") + + with patch.object(renderer, "_draw"): + # Test Unicode icon with Nerd Font + renderer.text((48, 48), "🎤", font="RobotoMono Nerd Font", size=86) + + # Should have queried fontconfig for RobotoMono Nerd Font + mocks["fontconfig"].query.assert_called_with("RobotoMono Nerd Font") + mocks["truetype"].assert_called_with("/path/to/materialicons.ttf", 86) + + # Should have drawn the Unicode character + + +def test_renderer_text_multiline_with_newlines() -> None: + """Test that text_multiline preserves explicit newlines.""" + with mock_fontconfig_system() as mocks: + # Mock getmetrics to return font metrics (ascent, descent) + mocks["font"].getmetrics.return_value = (12, 4) # Total height = 16 + + renderer = Renderer("Roboto", "RobotoMono Nerd Font") + + with patch.object(renderer, "_draw") as mock_draw: + # Test text with explicit newlines + renderer.text_multiline("Hello\nWorld", size=16) + + # Should have called text twice (once per line) + assert mock_draw.text.call_count == 2 + + # Verify the text content of each call + calls = mock_draw.text.call_args_list + assert calls[0][0][1] == "Hello" # First line + assert calls[1][0][1] == "World" # Second line + + +def test_renderer_text_multiline_with_multiple_newlines() -> None: + """Test that text_multiline handles multiple consecutive newlines.""" + with mock_fontconfig_system() as mocks: + # Mock getmetrics to return font metrics (ascent, descent) + mocks["font"].getmetrics.return_value = (12, 4) # Total height = 16 + + renderer = Renderer("Roboto", "RobotoMono Nerd Font") + + with patch.object(renderer, "_draw") as mock_draw: + # Test text with multiple newlines (creates empty line) + renderer.text_multiline("Line1\n\nLine3", size=16) + + # Should have called text three times (including empty line) + assert mock_draw.text.call_count == 3 + + # Verify the text content + calls = mock_draw.text.call_args_list + assert calls[0][0][1] == "Line1" # First line + assert calls[1][0][1] == "" # Empty line + assert calls[2][0][1] == "Line3" # Third line + + +def test_renderer_text_multiline_preserves_spacing() -> None: + """Test that text_multiline maintains proper line spacing with newlines.""" + with mock_fontconfig_system() as mocks: + # Mock getmetrics to return font metrics (ascent, descent) + mocks["font"].getmetrics.return_value = (12, 4) # Total height = 16 + + renderer = Renderer("Roboto", "RobotoMono Nerd Font") + + with patch.object(renderer, "_draw") as mock_draw: + # Test with custom line spacing + renderer.text_multiline("Line1\nLine2\nLine3", size=16, line_spacing=8) + + # Should have three calls + assert mock_draw.text.call_count == 3 + + # Verify y-coordinates increase by (line_height + line_spacing) + calls = mock_draw.text.call_args_list + y1 = calls[0][0][0][1] # y-coordinate of first line + y2 = calls[1][0][0][1] # y-coordinate of second line + y3 = calls[2][0][0][1] # y-coordinate of third line + + # Each line should be (16 + 8) = 24 pixels apart (line_height from getmetrics + spacing) + assert y2 - y1 == 24 + assert y3 - y2 == 24 diff --git a/tests/test_wakelock.py b/tests/test_wakelock.py index 985fd0d..80bdf2a 100644 --- a/tests/test_wakelock.py +++ b/tests/test_wakelock.py @@ -1,6 +1,6 @@ from asyncio import Event -from knoepfe.wakelock import WakeLock +from knoepfe.utils.wakelock import WakeLock def test_wake_lock() -> None: diff --git a/tests/utils/test_task_manager.py b/tests/utils/test_task_manager.py new file mode 100644 index 0000000..904d149 --- /dev/null +++ b/tests/utils/test_task_manager.py @@ -0,0 +1,171 @@ +"""Tests for TaskManager.""" + +from asyncio import Event, sleep + +import pytest + +from knoepfe.utils.task_manager import TaskManager + + +@pytest.mark.asyncio +async def test_start_task(): + """Test starting a basic task.""" + manager = TaskManager() + event = Event() + + async def test_coro(): + event.set() + + manager.start_task("test", test_coro()) + await sleep(0.1) + + assert event.is_set() + assert not manager.is_running("test") # Task completed + + +@pytest.mark.asyncio +async def test_start_task_idempotent(): + """Test that starting same task twice returns existing task.""" + manager = TaskManager() + + async def test_coro(): + await sleep(10) + + # Start the first task + task1 = manager.start_task("test", test_coro()) + + # Try to start a second task with the same name + # Create the coroutine but close it immediately since it won't be used + coro2 = test_coro() + task2 = manager.start_task("test", coro2) + coro2.close() # Close the unused coroutine to prevent warning + + assert task1 is task2 + manager.stop_all() + + +@pytest.mark.asyncio +async def test_start_task_restart(): + """Test restarting a running task.""" + manager = TaskManager() + counter = {"value": 0} + + async def test_coro(): + counter["value"] += 1 + await sleep(10) + + task1 = manager.start_task("test", test_coro()) + await sleep(0.1) + assert counter["value"] == 1 + + task2 = manager.start_task("test", test_coro(), restart_if_running=True) + await sleep(0.1) + assert counter["value"] == 2 + assert task1 is not task2 + + manager.stop_all() + + +@pytest.mark.asyncio +async def test_stop_task(): + """Test stopping a task.""" + manager = TaskManager() + + async def test_coro(): + await sleep(10) + + manager.start_task("test", test_coro()) + assert manager.is_running("test") + + result = manager.stop_task("test") + assert result is True + assert not manager.is_running("test") + + result = manager.stop_task("nonexistent") + assert result is False + + +@pytest.mark.asyncio +async def test_cleanup(): + """Test cleanup of all tasks.""" + manager = TaskManager() + + async def test_coro(): + await sleep(10) + + manager.start_task("task1", test_coro()) + manager.start_task("task2", test_coro()) + manager.start_task("task3", test_coro()) + + assert manager.is_running("task1") + assert manager.is_running("task2") + assert manager.is_running("task3") + + manager.cleanup() + + assert not manager.is_running("task1") + assert not manager.is_running("task2") + assert not manager.is_running("task3") + + +@pytest.mark.asyncio +async def test_stop_all(): + """Test stopping all tasks.""" + manager = TaskManager() + + async def test_coro(): + await sleep(10) + + manager.start_task("task1", test_coro()) + manager.start_task("task2", test_coro()) + manager.start_task("task3", test_coro()) + + assert manager.is_running("task1") + assert manager.is_running("task2") + assert manager.is_running("task3") + + manager.stop_all() + + assert not manager.is_running("task1") + assert not manager.is_running("task2") + assert not manager.is_running("task3") + + +@pytest.mark.asyncio +async def test_task_auto_cleanup_on_completion(): + """Test that tasks are automatically removed when they complete.""" + manager = TaskManager() + event = Event() + + async def test_coro(): + event.set() + + manager.start_task("test", test_coro()) + await sleep(0.1) + + # Task should have completed and been auto-removed + assert not manager.is_running("test") + assert event.is_set() + + +@pytest.mark.asyncio +async def test_multiple_managers_independent(): + """Test that multiple TaskManagers are independent.""" + manager1 = TaskManager() + manager2 = TaskManager() + + async def test_coro(): + await sleep(10) + + manager1.start_task("test", test_coro()) + manager2.start_task("test", test_coro()) + + assert manager1.is_running("test") + assert manager2.is_running("test") + + manager1.stop_task("test") + + assert not manager1.is_running("test") + assert manager2.is_running("test") # Should still be running + + manager2.stop_all() diff --git a/tests/widgets/test_base.py b/tests/widgets/test_base.py deleted file mode 100644 index f1a1de5..0000000 --- a/tests/widgets/test_base.py +++ /dev/null @@ -1,71 +0,0 @@ -from asyncio import sleep -from unittest.mock import AsyncMock, Mock, patch - -from pytest import raises - -from knoepfe.deck import SwitchDeckException -from knoepfe.wakelock import WakeLock -from knoepfe.widgets.base import Widget - - -async def test_presses() -> None: - widget = Widget({}, {}) - with patch.object(widget, "triggered") as triggered: - await widget.pressed() - await widget.released() - assert triggered.call_args[0][0] is False - - with ( - patch.object(widget, "triggered") as triggered, - patch("knoepfe.widgets.base.sleep", AsyncMock()), - ): - await widget.pressed() - await sleep(0.1) - await widget.released() - assert triggered.call_count == 1 - assert triggered.call_args[0][0] is True - - -async def test_switch_deck() -> None: - widget = Widget({"switch_deck": "new_deck"}, {}) - with raises(SwitchDeckException) as e: - widget.long_press_task = Mock() - await widget.released() - assert e.value.new_deck == "new_deck" - - -async def test_request_update() -> None: - widget = Widget({}, {}) - with patch.object(widget, "update_requested_event") as event: - widget.request_update() - assert event.set.called - assert widget.needs_update - - -async def test_periodic_update() -> None: - widget = Widget({}, {}) - - with patch.object(widget, "request_update") as request_update: - widget.request_periodic_update(0.0) - await sleep(0.01) - assert request_update.called - count = request_update.call_count - - widget.stop_periodic_update() - await sleep(0.01) - assert request_update.call_count == count - - -async def test_wake_lock() -> None: - widget = Widget({}, {}) - widget.wake_lock = WakeLock(Mock()) - - widget.acquire_wake_lock() - assert widget.wake_lock.count == 1 - widget.acquire_wake_lock() - assert widget.wake_lock.count == 1 - - widget.release_wake_lock() - assert widget.wake_lock.count == 0 - widget.release_wake_lock() - assert widget.wake_lock.count == 0 diff --git a/tests/widgets/test_clock.py b/tests/widgets/test_clock.py new file mode 100644 index 0000000..5a9d345 --- /dev/null +++ b/tests/widgets/test_clock.py @@ -0,0 +1,200 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from knoepfe.config.plugin import EmptyPluginConfig +from knoepfe.plugins.plugin import Plugin +from knoepfe.widgets.builtin.clock import Clock, ClockConfig, ClockSegment + + +@pytest.fixture +def plugin(): + """Create a plugin instance for testing.""" + config = EmptyPluginConfig() + return Plugin(config) + + +async def test_clock_update_with_defaults(plugin) -> None: + """Test that Clock widget updates with default configuration.""" + widget = Clock(ClockConfig(), plugin) + + # Mock renderer + renderer = MagicMock() + renderer.measure_text.return_value = (50, 20) # Mock text dimensions + + # Update widget + await widget.update(renderer) + + # Verify renderer was used + renderer.clear.assert_called_once() + renderer.text.assert_called_once() + call_args = renderer.text.call_args + assert call_args[1]["anchor"] == "mm" + assert call_args[1]["font"] is None + assert call_args[1]["color"] == "white" + + +async def test_clock_update_with_custom_segments(plugin) -> None: + """Test that Clock widget uses custom segments.""" + config = ClockConfig( + font="Roboto", + color="#fefefe", + segments=[ + ClockSegment(format="%H", x=0, y=0, width=72, height=24, font="Roboto:style=Bold"), + ClockSegment(format="%M", x=0, y=24, width=72, height=24), + ClockSegment(format="%S", x=0, y=48, width=72, height=24, font="Roboto:style=Thin"), + ], + ) + widget = Clock(config, plugin) + + # Mock renderer + renderer = MagicMock() + renderer.measure_text.return_value = (50, 20) # Mock text dimensions + + # Update widget + await widget.update(renderer) + + # Verify renderer was called for each segment + renderer.clear.assert_called_once() + assert renderer.text.call_count == 3 + + # Check first segment uses custom font + first_call = renderer.text.call_args_list[0] + assert first_call[1]["font"] == "Roboto:style=Bold" + assert first_call[1]["color"] == "#fefefe" + + # Check second segment inherits widget font + second_call = renderer.text.call_args_list[1] + assert second_call[1]["font"] == "Roboto" + assert second_call[1]["color"] == "#fefefe" + + # Check third segment uses custom font + third_call = renderer.text.call_args_list[2] + assert third_call[1]["font"] == "Roboto:style=Thin" + assert third_call[1]["color"] == "#fefefe" + + +async def test_clock_update_only_when_time_changes(plugin) -> None: + """Test that Clock widget only updates when time changes.""" + widget = Clock(ClockConfig(), plugin) + + # Mock renderer + renderer = MagicMock() + renderer.measure_text.return_value = (50, 20) + + # First update + with patch("knoepfe.widgets.builtin.clock.datetime") as mock_datetime: + mock_datetime.now.return_value.strftime.return_value = "12:34" + await widget.update(renderer) + assert widget.last_time == "12:34" + assert renderer.text.call_count == 1 + + # Second update with same time - should not render + renderer.reset_mock() + with patch("knoepfe.widgets.builtin.clock.datetime") as mock_datetime: + mock_datetime.now.return_value.strftime.return_value = "12:34" + await widget.update(renderer) + # Should return early, not call renderer methods + renderer.clear.assert_not_called() + renderer.text.assert_not_called() + + # Third update with different time - should render + renderer.reset_mock() + renderer.measure_text.return_value = (50, 20) + with patch("knoepfe.widgets.builtin.clock.datetime") as mock_datetime: + mock_datetime.now.return_value.strftime.return_value = "12:35" + await widget.update(renderer) + assert widget.last_time == "12:35" + assert renderer.text.call_count == 1 + + +async def test_clock_activate_starts_periodic_update(plugin) -> None: + """Test that activate starts periodic updates.""" + widget = Clock(ClockConfig(interval=2.0), plugin) + widget.request_periodic_update = MagicMock() + + await widget.activate() + + widget.request_periodic_update.assert_called_once_with(2.0) + + +async def test_clock_deactivate_resets_state(plugin) -> None: + """Test that deactivate resets state.""" + widget = Clock(ClockConfig(), plugin) + widget.last_time = "12:34" + + await widget.deactivate() + + # Tasks are cleaned up automatically by Deck, not by widget + assert widget.last_time == "" + + +def test_clock_config_defaults() -> None: + """Test ClockConfig default values.""" + config = ClockConfig() + assert len(config.segments) == 1 + assert config.segments[0].format == "%H:%M" + assert config.segments[0].x == 0 + assert config.segments[0].y == 0 + assert config.segments[0].width == 96 + assert config.segments[0].height == 96 + assert config.font is None + assert config.color == "white" + assert config.interval == 1.0 + + +def test_clock_config_custom_segments() -> None: + """Test ClockConfig with custom segments.""" + config = ClockConfig( + font="Ubuntu:style=Bold", + color="#ff0000", + interval=0.5, + segments=[ + ClockSegment(format="%H", x=0, y=0, width=48, height=32), + ClockSegment(format="%M", x=48, y=0, width=48, height=32), + ], + ) + assert len(config.segments) == 2 + assert config.segments[0].format == "%H" + assert config.segments[0].x == 0 + assert config.segments[0].width == 48 + assert config.segments[1].format == "%M" + assert config.segments[1].x == 48 + assert config.font == "Ubuntu:style=Bold" + assert config.color == "#ff0000" + assert config.interval == 0.5 + + +def test_clock_segment_defaults() -> None: + """Test ClockSegment default values.""" + segment = ClockSegment(format="%H", x=10, y=20, width=30, height=40) + assert segment.format == "%H" + assert segment.x == 10 + assert segment.y == 20 + assert segment.width == 30 + assert segment.height == 40 + assert segment.font is None + assert segment.color is None + assert segment.anchor == "mm" + + +def test_clock_segment_custom_values() -> None: + """Test ClockSegment with custom values.""" + segment = ClockSegment( + format="%M", + x=5, + y=10, + width=50, + height=25, + font="monospace", + color="#00ff00", + anchor="lt", + ) + assert segment.format == "%M" + assert segment.x == 5 + assert segment.y == 10 + assert segment.width == 50 + assert segment.height == 25 + assert segment.font == "monospace" + assert segment.color == "#00ff00" + assert segment.anchor == "lt" diff --git a/tests/widgets/test_text.py b/tests/widgets/test_text.py index 808a12c..73101de 100644 --- a/tests/widgets/test_text.py +++ b/tests/widgets/test_text.py @@ -1,16 +1,78 @@ from unittest.mock import MagicMock -from schema import Schema +import pytest +from pydantic import ValidationError -from knoepfe.widgets import Text +from knoepfe.config.plugin import EmptyPluginConfig +from knoepfe.plugins import Plugin +from knoepfe.widgets.builtin.text import Text, TextConfig async def test_text_update() -> None: - widget = Text({"text": "Text"}, {}) - key = MagicMock() - await widget.update(key) - assert key.renderer.return_value.__enter__.return_value.text.called + """Test that Text widget updates correctly.""" + # Create plugin instance + config = EmptyPluginConfig() + plugin = Plugin(config) + # Create widget with config + widget = Text(TextConfig(text="Test Text"), plugin) -def test_text_schema() -> None: - assert isinstance(Text.get_config_schema(), Schema) + # Mock renderer + renderer = MagicMock() + + # Update widget + await widget.update(renderer) + + # Verify text_multiline was called + assert renderer.text_multiline.called + + +def test_text_config_validation() -> None: + """Test that Text widget validates config correctly.""" + + config = EmptyPluginConfig() + plugin = Plugin(config) + + # Valid config should work + widget = Text(TextConfig(text="Valid"), plugin) + assert widget.config.text == "Valid" + + # Missing required field should raise ValidationError + with pytest.raises(ValidationError): + TextConfig() # type: ignore[call-arg] + + +async def test_text_with_font_and_color() -> None: + """Test that Text widget uses custom font and color.""" + config = EmptyPluginConfig() + plugin = Plugin(config) + + # Create widget with custom font and color + widget = Text(TextConfig(text="Styled Text", font="sans:style=Bold", color="#ff0000"), plugin) + + # Mock renderer + renderer = MagicMock() + + # Update widget + await widget.update(renderer) + + # Verify text_multiline was called with font and color + renderer.text_multiline.assert_called_once_with("Styled Text", font="sans:style=Bold", color="#ff0000") + + +async def test_text_with_defaults() -> None: + """Test that Text widget works with default font and color.""" + config = EmptyPluginConfig() + plugin = Plugin(config) + + # Create widget with defaults + widget = Text(TextConfig(text="Plain Text"), plugin) + + # Mock renderer + renderer = MagicMock() + + # Update widget + await widget.update(renderer) + + # Verify text_multiline was called with default color (font is None) + renderer.text_multiline.assert_called_once_with("Plain Text", font=None, color="white") diff --git a/tests/widgets/test_timer.py b/tests/widgets/test_timer.py new file mode 100644 index 0000000..da4676a --- /dev/null +++ b/tests/widgets/test_timer.py @@ -0,0 +1,181 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from knoepfe.config.plugin import EmptyPluginConfig +from knoepfe.plugins import Plugin +from knoepfe.widgets.builtin.timer import Timer, TimerConfig + + +@pytest.fixture +def plugin(): + """Create a plugin instance for testing.""" + config = EmptyPluginConfig() + return Plugin(config) + + +async def test_timer_idle_with_defaults(plugin) -> None: + """Test that Timer displays icon when idle with default configuration.""" + widget = Timer(TimerConfig(), plugin) + + # Mock renderer + renderer = MagicMock() + + # Update widget (idle state) + await widget.update(renderer) + + # Verify icon was called with defaults + renderer.clear.assert_called_once() + renderer.icon.assert_called_once_with( + "󱎫", # nf-md-timer + color="white", + ) + + +async def test_timer_idle_with_custom_icon_and_color(plugin) -> None: + """Test that Timer uses custom icon and base color when idle.""" + widget = Timer(TimerConfig(icon="⏱️", color="#00ff00"), plugin) + + # Mock renderer + renderer = MagicMock() + + # Update widget (idle state) + await widget.update(renderer) + + # Verify icon was called with custom values + renderer.icon.assert_called_once_with("⏱️", color="#00ff00") + + +async def test_timer_running_with_custom_font_and_color(plugin) -> None: + """Test that Timer uses custom font and color when running.""" + widget = Timer(TimerConfig(font="monospace:style=Bold", running_color="#00ff00"), plugin) + + # Set timer to running state + with patch("knoepfe.widgets.builtin.timer.time.monotonic", return_value=100.0): + widget.start = 95.0 # 5 seconds elapsed + + # Mock renderer + renderer = MagicMock() + + # Update widget (running state) + await widget.update(renderer) + + # Verify text was called with custom font and running color + renderer.clear.assert_called_once() + renderer.text.assert_called_once() + call_args = renderer.text.call_args + assert call_args[1]["font"] == "monospace:style=Bold" + assert call_args[1]["color"] == "#00ff00" + assert call_args[1]["anchor"] == "mm" + + +async def test_timer_stopped_with_custom_color(plugin) -> None: + """Test that Timer uses custom stopped color when stopped.""" + widget = Timer(TimerConfig(font="sans:style=Bold", stopped_color="#ff00ff"), plugin) + + # Set timer to stopped state + widget.start = 95.0 + widget.stop = 100.0 # 5 seconds elapsed + + # Mock renderer + renderer = MagicMock() + + # Update widget (stopped state) + await widget.update(renderer) + + # Verify text was called with stopped color + renderer.clear.assert_called_once() + renderer.text.assert_called_once() + call_args = renderer.text.call_args + assert call_args[1]["color"] == "#ff00ff" + assert call_args[1]["anchor"] == "mm" + + +async def test_timer_start_stop_reset_cycle(plugin) -> None: + """Test the complete timer lifecycle: start, stop, reset.""" + widget = Timer(TimerConfig(), plugin) + widget.request_periodic_update = MagicMock() + widget.stop_periodic_update = MagicMock() + widget.request_update = MagicMock() + widget.acquire_wake_lock = MagicMock() + widget.release_wake_lock = MagicMock() + + # Start timer + with patch("knoepfe.widgets.builtin.timer.time.monotonic", return_value=100.0): + await widget.triggered() + assert widget.start == 100.0 + assert widget.stop is None + widget.request_periodic_update.assert_called_once_with(1.0) + widget.acquire_wake_lock.assert_called_once() + + # Stop timer + widget.request_periodic_update.reset_mock() + with patch("knoepfe.widgets.builtin.timer.time.monotonic", return_value=105.0): + await widget.triggered() + assert widget.start == 100.0 + assert widget.stop == 105.0 + widget.stop_periodic_update.assert_called_once() + widget.release_wake_lock.assert_called_once() + + # Reset timer + widget.stop_periodic_update.reset_mock() + await widget.triggered() + assert widget.start is None + assert widget.stop is None + assert widget.stop_periodic_update.call_count == 1 + + +async def test_timer_deactivate_cleanup(plugin) -> None: + """Test that deactivate preserves timer state for running timers.""" + widget = Timer(TimerConfig(), plugin) + widget.release_wake_lock = MagicMock() + + # Test 1: Timer is running - state should be preserved, wake lock kept + widget.start = 100.0 + widget.stop = None + + await widget.deactivate() + + # Timer state should be preserved for running timers + assert widget.start == 100.0 + assert widget.stop is None + # Wake lock should NOT be released for running timer + widget.release_wake_lock.assert_not_called() + + # Test 2: Timer is stopped - wake lock should be released + widget.start = 100.0 + widget.stop = 150.0 + + await widget.deactivate() + + # Timer state should still be preserved + assert widget.start == 100.0 + assert widget.stop == 150.0 + # Wake lock should be released for stopped timer + widget.release_wake_lock.assert_called_once() + + +def test_timer_config_defaults() -> None: + """Test TimerConfig default values.""" + config = TimerConfig() + assert config.icon == "󱎫" # nf-md-timer + assert config.font is None + assert config.color == "white" # Base color + assert config.running_color is None # Defaults to base color + assert config.stopped_color == "red" + + +def test_timer_config_custom_values() -> None: + """Test TimerConfig with custom values.""" + config = TimerConfig( + icon="⏱️", + font="Ubuntu:style=Bold", + color="#0000ff", # Base color for idle icon + running_color="#00ff00", + stopped_color="#ff00ff", + ) + assert config.icon == "⏱️" + assert config.font == "Ubuntu:style=Bold" + assert config.color == "#0000ff" + assert config.running_color == "#00ff00" + assert config.stopped_color == "#ff00ff" diff --git a/tests/widgets/test_widget.py b/tests/widgets/test_widget.py new file mode 100644 index 0000000..592139b --- /dev/null +++ b/tests/widgets/test_widget.py @@ -0,0 +1,111 @@ +from asyncio import sleep +from unittest.mock import AsyncMock, Mock, patch + +from knoepfe.config.plugin import EmptyPluginConfig +from knoepfe.config.widget import EmptyConfig +from knoepfe.core.actions import SwitchDeckAction +from knoepfe.plugins.plugin import Plugin +from knoepfe.rendering import Renderer +from knoepfe.utils.wakelock import WakeLock +from knoepfe.widgets.widget import TASK_LONG_PRESS, UpdateResult, Widget + + +class ConcreteWidget(Widget[EmptyConfig, Plugin]): + """Concrete test widget for testing base functionality.""" + + name = "ConcreteWidget" + + async def update(self, renderer: Renderer) -> UpdateResult: + return UpdateResult.UPDATED + + +async def test_presses() -> None: + config = EmptyPluginConfig() + plugin = Plugin(config) + widget = ConcreteWidget(EmptyConfig(), plugin) + with patch.object(widget, "triggered") as triggered: + await widget.pressed() + await widget.released() + assert triggered.call_args[0][0] is False + + with ( + patch.object(widget, "triggered") as triggered, + patch("knoepfe.widgets.widget.sleep", AsyncMock()), + ): + await widget.pressed() + await sleep(0.1) + await widget.released() + assert triggered.call_count == 1 + assert triggered.call_args[0][0] is True + + +async def test_switch_deck() -> None: + config = EmptyPluginConfig() + plugin = Plugin(config) + widget = ConcreteWidget(EmptyConfig(switch_deck="new_deck"), plugin) + + # Simulate long press task running + async def dummy_task(): + pass + + widget.tasks.start_task(TASK_LONG_PRESS, dummy_task()) + action = await widget.released() + assert isinstance(action, SwitchDeckAction) + assert action.target_deck == "new_deck" + + +async def test_no_switch_deck() -> None: + config = EmptyPluginConfig() + plugin = Plugin(config) + widget = ConcreteWidget(EmptyConfig(), plugin) + + # Simulate long press task running + async def dummy_task(): + pass + + widget.tasks.start_task(TASK_LONG_PRESS, dummy_task()) + action = await widget.released() + assert action is None + + +async def test_request_update() -> None: + config = EmptyPluginConfig() + plugin = Plugin(config) + widget = ConcreteWidget(EmptyConfig(), plugin) + with patch.object(widget, "update_requested_event") as event: + widget.request_update() + assert event.set.called + assert widget.needs_update + + +async def test_periodic_update() -> None: + config = EmptyPluginConfig() + plugin = Plugin(config) + widget = ConcreteWidget(EmptyConfig(), plugin) + + with patch.object(widget, "request_update") as request_update: + widget.request_periodic_update(0.0) + await sleep(0.01) + assert request_update.called + count = request_update.call_count + + widget.stop_periodic_update() + await sleep(0.01) + assert request_update.call_count == count + + +async def test_wake_lock() -> None: + config = EmptyPluginConfig() + plugin = Plugin(config) + widget = ConcreteWidget(EmptyConfig(), plugin) + widget.wake_lock = WakeLock(Mock()) + + widget.acquire_wake_lock() + assert widget.wake_lock.count == 1 + widget.acquire_wake_lock() + assert widget.wake_lock.count == 1 + + widget.release_wake_lock() + assert widget.wake_lock.count == 0 + widget.release_wake_lock() + assert widget.wake_lock.count == 0 diff --git a/uv.lock b/uv.lock index f638b23..32e0147 100644 --- a/uv.lock +++ b/uv.lock @@ -1,87 +1,43 @@ version = 1 revision = 2 -requires-python = ">=3.10" +requires-python = ">=3.11" -[[package]] -name = "aiorun" -version = "2024.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/ef/9177bbc0776db751024e106654f890c2e7ae363f6d8d5ff480aef2d77c93/aiorun-2024.8.1.tar.gz", hash = "sha256:87ea66b6146756ced58175d2f5ae64519ef96c4657f46b0e0c036e541a22c764", size = 31177, upload-time = "2024-08-05T15:20:26.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/2f/bac4cf15c9723b3449c76cdee06415d89dbc65048f235d28904810e39e36/aiorun-2024.8.1-py3-none-any.whl", hash = "sha256:e06cd75611a85f71802e741e7294b2db470f77bba8d76dce229fcc51dd58ec38", size = 17729, upload-time = "2024-08-05T15:20:23.387Z" }, -] - -[[package]] -name = "appdirs" -version = "1.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, +[manifest] +members = [ + "knoepfe", + "knoepfe-audio-plugin", + "knoepfe-example-plugin", + "knoepfe-obs-plugin", ] [[package]] -name = "attrs" -version = "24.3.0" +name = "aiorun" +version = "2025.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/48/c8/6260f8ccc11f0917360fc0da435c5c9c7504e3db174d5a12a1494887b045/attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", size = 805984, upload-time = "2024-12-16T06:59:29.899Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/b9/77d7ecc3c0738046b086498eca5f67669285b6bd10adf44b242daf02ecba/aiorun-2025.1.1.tar.gz", hash = "sha256:86d1075a034ce2671ab532db06e9204fe784cdd0c66ca7b8cc47a7527d0d50a3", size = 31451, upload-time = "2025-01-27T15:01:42.759Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/aa/ab0f7891a01eeb2d2e338ae8fecbe57fcebea1a24dbb64d45801bfab481d/attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397, upload-time = "2024-12-16T06:59:26.977Z" }, + { url = "https://files.pythonhosted.org/packages/37/e2/48ff3d538f173fde54dc406b4718c63c73c7d215eba37f405b729cf4700b/aiorun-2025.1.1-py3-none-any.whl", hash = "sha256:46d6fa7ac4bfe93ff8385fa17941e4dbe0452d0353497196be25b000571fe3e1", size = 18053, upload-time = "2025-01-27T15:01:40.131Z" }, ] [[package]] -name = "black" -version = "24.10.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d8/0d/cc2fb42b8c50d80143221515dd7e4766995bd07c56c9a3ed30baf080b6dc/black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875", size = 645813, upload-time = "2024-10-07T19:20:50.361Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/f3/465c0eb5cddf7dbbfe1fecd9b875d1dcf51b88923cd2c1d7e9ab95c6336b/black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812", size = 1623211, upload-time = "2024-10-07T19:26:12.43Z" }, - { url = "https://files.pythonhosted.org/packages/df/57/b6d2da7d200773fdfcc224ffb87052cf283cec4d7102fab450b4a05996d8/black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea", size = 1457139, upload-time = "2024-10-07T19:25:06.453Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c5/9023b7673904a5188f9be81f5e129fff69f51f5515655fbd1d5a4e80a47b/black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f", size = 1753774, upload-time = "2024-10-07T19:23:58.47Z" }, - { url = "https://files.pythonhosted.org/packages/e1/32/df7f18bd0e724e0d9748829765455d6643ec847b3f87e77456fc99d0edab/black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e", size = 1414209, upload-time = "2024-10-07T19:24:42.54Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cc/7496bb63a9b06a954d3d0ac9fe7a73f3bf1cd92d7a58877c27f4ad1e9d41/black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad", size = 1607468, upload-time = "2024-10-07T19:26:14.966Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e3/69a738fb5ba18b5422f50b4f143544c664d7da40f09c13969b2fd52900e0/black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50", size = 1437270, upload-time = "2024-10-07T19:25:24.291Z" }, - { url = "https://files.pythonhosted.org/packages/c9/9b/2db8045b45844665c720dcfe292fdaf2e49825810c0103e1191515fc101a/black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392", size = 1737061, upload-time = "2024-10-07T19:23:52.18Z" }, - { url = "https://files.pythonhosted.org/packages/a3/95/17d4a09a5be5f8c65aa4a361444d95edc45def0de887810f508d3f65db7a/black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175", size = 1423293, upload-time = "2024-10-07T19:24:41.7Z" }, - { url = "https://files.pythonhosted.org/packages/90/04/bf74c71f592bcd761610bbf67e23e6a3cff824780761f536512437f1e655/black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3", size = 1644256, upload-time = "2024-10-07T19:27:53.355Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ea/a77bab4cf1887f4b2e0bce5516ea0b3ff7d04ba96af21d65024629afedb6/black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65", size = 1448534, upload-time = "2024-10-07T19:26:44.953Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3e/443ef8bc1fbda78e61f79157f303893f3fddf19ca3c8989b163eb3469a12/black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f", size = 1761892, upload-time = "2024-10-07T19:24:10.264Z" }, - { url = "https://files.pythonhosted.org/packages/52/93/eac95ff229049a6901bc84fec6908a5124b8a0b7c26ea766b3b8a5debd22/black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8", size = 1434796, upload-time = "2024-10-07T19:25:06.239Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a0/a993f58d4ecfba035e61fca4e9f64a2ecae838fc9f33ab798c62173ed75c/black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981", size = 1643986, upload-time = "2024-10-07T19:28:50.684Z" }, - { url = "https://files.pythonhosted.org/packages/37/d5/602d0ef5dfcace3fb4f79c436762f130abd9ee8d950fa2abdbf8bbc555e0/black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b", size = 1448085, upload-time = "2024-10-07T19:28:12.093Z" }, - { url = "https://files.pythonhosted.org/packages/47/6d/a3a239e938960df1a662b93d6230d4f3e9b4a22982d060fc38c42f45a56b/black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2", size = 1760928, upload-time = "2024-10-07T19:24:15.233Z" }, - { url = "https://files.pythonhosted.org/packages/dd/cf/af018e13b0eddfb434df4d9cd1b2b7892bab119f7a20123e93f6910982e8/black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b", size = 1436875, upload-time = "2024-10-07T19:24:42.762Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a7/4b27c50537ebca8bec139b872861f9d2bf501c5ec51fcf897cb924d9e264/black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d", size = 206898, upload-time = "2024-10-07T19:20:48.317Z" }, -] - -[[package]] -name = "cfgv" -version = "3.4.0" +name = "annotated-types" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] name = "click" -version = "8.2.1" +version = "8.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, ] [[package]] @@ -99,16 +55,6 @@ version = "7.10.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/14/70/025b179c993f019105b79575ac6edb5e084fb0f0e63f15cdebef4e454fb5/coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90", size = 823736, upload-time = "2025-08-29T15:35:16.668Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/1d/2e64b43d978b5bd184e0756a41415597dfef30fcbd90b747474bd749d45f/coverage-7.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356", size = 217025, upload-time = "2025-08-29T15:32:57.169Z" }, - { url = "https://files.pythonhosted.org/packages/23/62/b1e0f513417c02cc10ef735c3ee5186df55f190f70498b3702d516aad06f/coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301", size = 217419, upload-time = "2025-08-29T15:32:59.908Z" }, - { url = "https://files.pythonhosted.org/packages/e7/16/b800640b7a43e7c538429e4d7223e0a94fd72453a1a048f70bf766f12e96/coverage-7.10.6-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460", size = 244180, upload-time = "2025-08-29T15:33:01.608Z" }, - { url = "https://files.pythonhosted.org/packages/fb/6f/5e03631c3305cad187eaf76af0b559fff88af9a0b0c180d006fb02413d7a/coverage-7.10.6-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd", size = 245992, upload-time = "2025-08-29T15:33:03.239Z" }, - { url = "https://files.pythonhosted.org/packages/eb/a1/f30ea0fb400b080730125b490771ec62b3375789f90af0bb68bfb8a921d7/coverage-7.10.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb", size = 247851, upload-time = "2025-08-29T15:33:04.603Z" }, - { url = "https://files.pythonhosted.org/packages/02/8e/cfa8fee8e8ef9a6bb76c7bef039f3302f44e615d2194161a21d3d83ac2e9/coverage-7.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6", size = 245891, upload-time = "2025-08-29T15:33:06.176Z" }, - { url = "https://files.pythonhosted.org/packages/93/a9/51be09b75c55c4f6c16d8d73a6a1d46ad764acca0eab48fa2ffaef5958fe/coverage-7.10.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945", size = 243909, upload-time = "2025-08-29T15:33:07.74Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a6/ba188b376529ce36483b2d585ca7bdac64aacbe5aa10da5978029a9c94db/coverage-7.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e", size = 244786, upload-time = "2025-08-29T15:33:08.965Z" }, - { url = "https://files.pythonhosted.org/packages/d0/4c/37ed872374a21813e0d3215256180c9a382c3f5ced6f2e5da0102fc2fd3e/coverage-7.10.6-cp310-cp310-win32.whl", hash = "sha256:86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1", size = 219521, upload-time = "2025-08-29T15:33:10.599Z" }, - { url = "https://files.pythonhosted.org/packages/8e/36/9311352fdc551dec5b973b61f4e453227ce482985a9368305880af4f85dd/coverage-7.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528", size = 220417, upload-time = "2025-08-29T15:33:11.907Z" }, { url = "https://files.pythonhosted.org/packages/d4/16/2bea27e212c4980753d6d563a0803c150edeaaddb0771a50d2afc410a261/coverage-7.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f", size = 217129, upload-time = "2025-08-29T15:33:13.575Z" }, { url = "https://files.pythonhosted.org/packages/2a/51/e7159e068831ab37e31aac0969d47b8c5ee25b7d307b51e310ec34869315/coverage-7.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc", size = 217532, upload-time = "2025-08-29T15:33:14.872Z" }, { url = "https://files.pythonhosted.org/packages/e7/c0/246ccbea53d6099325d25cd208df94ea435cd55f0db38099dd721efc7a1f/coverage-7.10.6-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a", size = 247931, upload-time = "2025-08-29T15:33:16.142Z" }, @@ -184,207 +130,194 @@ toml = [ ] [[package]] -name = "distlib" -version = "0.3.9" +name = "hidapi" +version = "0.14.0.post4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923, upload-time = "2024-10-09T18:35:47.551Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/72/21ccaaca6ffb06f544afd16191425025d831c2a6d318635e9c8854070f2d/hidapi-0.14.0.post4.tar.gz", hash = "sha256:48fce253e526d17b663fbf9989c71c7ef7653ced5f4be65f1437c313fb3dbdf6", size = 174388, upload-time = "2024-11-19T16:38:10.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/b2/6666dfae3c48986a3cf77d049ff8bc6e6620ac0402443ef235b82684eeea/hidapi-0.14.0.post4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:74ae8ce339655b2568d74e49c8ef644d34a445dd0a9b4b89d1bf09447b83f5af", size = 71068, upload-time = "2024-11-19T16:36:11.123Z" }, + { url = "https://files.pythonhosted.org/packages/35/ad/5c3dfcb986de80f3ea61908bb2c7ff498900ee79df59a894d834e49b55c9/hidapi-0.14.0.post4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e749b79d9cafc1e9fd9d397d8039377c928ca10a36847fda6407169513802f68", size = 68750, upload-time = "2024-11-19T16:36:12.902Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ab/94ff5dc6c66227bbf316cf8b056145922d1ca931a37092519e8247214df7/hidapi-0.14.0.post4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4169893fe5e368777fce7575a8bdedc1861f13d8fb9fda6b05e8155dde6eb7f1", size = 1072450, upload-time = "2024-11-19T16:36:15.306Z" }, + { url = "https://files.pythonhosted.org/packages/11/77/1e8c35728a17baae2c61646d7060062249f1f01ecd2c28631d07aa8547af/hidapi-0.14.0.post4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d51f8102a2441ce22e080576f8f370d25cb3962161818a89f236b0401840f18", size = 1067973, upload-time = "2024-11-19T16:36:16.932Z" }, + { url = "https://files.pythonhosted.org/packages/99/82/540a15251d27287742a4c9f897bed01ccc82ab2ea6e4321ed3468741dd33/hidapi-0.14.0.post4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff021ed0962f2d5d67405ae53c85f6cb3ab8c5af3dff7db8c74672f79f7a39d1", size = 1064054, upload-time = "2024-11-19T16:36:19.53Z" }, + { url = "https://files.pythonhosted.org/packages/72/93/bb8ed59a06faa0262068486f3f01b49c68e0ae055940f5c9c65bb97064c7/hidapi-0.14.0.post4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8ab5ba9fce95e342335ef48640221a46600c1afb66847432fad9823d40a2022", size = 682621, upload-time = "2024-11-19T16:36:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/53/42/e262091785d25b30fcfeb0c87b8e0b96ba935336181bab86e7ee25181dd1/hidapi-0.14.0.post4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:56d7538a4e156041bb80f07f47c327f8944e39da469b010041ce44e324d0657c", size = 669789, upload-time = "2024-11-19T16:36:22.725Z" }, + { url = "https://files.pythonhosted.org/packages/6d/eb/b0516fadd686ad5b3d99df0db4e16d4d93ae5f74506ac0b2cf4452733e49/hidapi-0.14.0.post4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a28de4a03fc276614518d8d0997d8152d0edaf8ca9166522316ef1c455e8bc29", size = 696581, upload-time = "2024-11-19T16:36:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b5/35f053d1268c61e1ce6999059d416118d11fd60ba496a3e29c9adcf2ecd7/hidapi-0.14.0.post4-cp311-cp311-win32.whl", hash = "sha256:348e68e3a2145a6ec6bebce13ffdf3e5883d8c720752c365027f16e16764def6", size = 62950, upload-time = "2024-11-19T16:36:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/d91652ad32f4266c832f8b09879ac1cad9ad5b1660ef35d9ea7171a9e39b/hidapi-0.14.0.post4-cp311-cp311-win_amd64.whl", hash = "sha256:5a5af70dad759b45536a9946d8232ef7d90859845d3554c93bea3e790250df75", size = 70396, upload-time = "2024-11-19T16:36:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9a/9b7d5d5e2c003aed2fecdc348caff8d3b6a8ead0220da489ccb822d7e5ef/hidapi-0.14.0.post4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:129d684c2760fafee9014ce63a58d8e2699cdf00cd1a11bb3d706d4715f5ff96", size = 71668, upload-time = "2024-11-19T16:36:28.666Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e5/a919eb542a692cc27dc58b1997dd860cace0e4c64e38c8bf9236ff8b95b7/hidapi-0.14.0.post4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4f04de00e40db2efc0bcdd047c160274ba7ccd861100fd87c295dd63cb932f2f", size = 69146, upload-time = "2024-11-19T16:36:30.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/7c/63316c8cba89cc039a952bb8805c3fb585e79f7fc8a5d27acaa6beb2fe81/hidapi-0.14.0.post4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10a01af155c51a8089fe44e627af2fbd323cfbef7bd55a86837d971aef6088b0", size = 1083772, upload-time = "2024-11-19T16:36:32.798Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a8/bb222e3f096467d8e37c717000b9b0c6acee043c1145eaaeba4abfc8cffd/hidapi-0.14.0.post4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6eaff1d120c47e1a121eada8dc85eac007d1ed81f3db7fc0da5b6ed17d8edefb", size = 1081215, upload-time = "2024-11-19T16:36:35.512Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c8/52134f7d3e09fd4feb7756ccd872c55bfd1899ee81ceed4f8ad5ae39f457/hidapi-0.14.0.post4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fedb9c3be6a2376de436d13fcb37a686a9b6bc988585bcc4f5ec61cad925e794", size = 1077222, upload-time = "2024-11-19T16:36:38.062Z" }, + { url = "https://files.pythonhosted.org/packages/a8/da/88ebbd465dbaff04e9ef3bbdb4a6ca9d24a3458e4726878dbe26bb69236e/hidapi-0.14.0.post4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6270677da02e86b56b81afd5f6f313736b8315b493f3c8a431da285e3a3c5de9", size = 694510, upload-time = "2024-11-19T16:36:39.572Z" }, + { url = "https://files.pythonhosted.org/packages/da/f0/ea437ed339c5f0b446983011000d8cad8c4f8a51ee39e837d16e101b66da/hidapi-0.14.0.post4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:da700db947562f8c0ac530215b74b5a27e4c669916ec99cfb5accd14ba08562c", size = 682635, upload-time = "2024-11-19T16:36:41.35Z" }, + { url = "https://files.pythonhosted.org/packages/01/e9/14e63f1a5ec0c2430b84695d28e994b2b63398544adb20d910f6dc41ac66/hidapi-0.14.0.post4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:707b1ebf5cb051b020e94b039e603351bf2e6620b48fc970228e0dd5d3a91fca", size = 701667, upload-time = "2024-11-19T16:36:43.135Z" }, + { url = "https://files.pythonhosted.org/packages/df/3d/8a37ed1b250ae45eb7fa5cd3227c865d38a1ddf9ccab626f4f6adfbd424a/hidapi-0.14.0.post4-cp312-cp312-win32.whl", hash = "sha256:1487312ad50cf2c08a5ea786167b3229afd6478c4b26974157c3845a84e91231", size = 63123, upload-time = "2024-11-19T16:36:44.329Z" }, + { url = "https://files.pythonhosted.org/packages/f4/fd/e642211e579875e35015aed12d3b2c2a25f6a731ff846a2c2aaaf4bf8898/hidapi-0.14.0.post4-cp312-cp312-win_amd64.whl", hash = "sha256:8d924bd002a1c17ca51905b3b7b3d580e80ec211a9b8fe4667b73db0ff9e9b54", size = 70478, upload-time = "2024-11-19T16:36:45.388Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/8601f03a6eeeac35655245177b50bb00e707f3392e0a79c34637f8525207/hidapi-0.14.0.post4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6f96ae777e906f0a9d6f75e873313145dfec2b774f558bfcae8ba34f09792460", size = 70358, upload-time = "2024-11-19T16:36:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5d/7376cf339fbe6fca26048e3c7e183ef4d99c046cc5d8378516a745914327/hidapi-0.14.0.post4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6439fc9686518d0336fac8c5e370093279f53c997540065fce131c97567118d8", size = 68034, upload-time = "2024-11-19T16:36:47.419Z" }, + { url = "https://files.pythonhosted.org/packages/8c/5a/4bca20898c699810f016d2719b980fc57fe36d5012d03eca7a89ace98547/hidapi-0.14.0.post4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2acadb4f1ae569c4f73ddb461af8733e8f5efcb290c3d0ef1b0671ba793b0ae3", size = 1075570, upload-time = "2024-11-19T16:36:48.931Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/66e6b7c27297249bc737115dff4a1e819d3e0e73885160a3104ebec7ac13/hidapi-0.14.0.post4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:884fa003d899113e14908bd3b519c60b48fc3cec0410264dcbdad1c4a8fc2e8d", size = 1081482, upload-time = "2024-11-19T16:36:51.021Z" }, + { url = "https://files.pythonhosted.org/packages/86/a8/21e9860eddeefd0dc41b3f7e6e81cd9ff53c2b07130f57776b56a1dddc66/hidapi-0.14.0.post4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a2d466b995f8ff387d68c052d3b74ee981a4ddc4f1a99f32f2dc7022273dc11", size = 1069549, upload-time = "2024-11-19T16:36:52.808Z" }, + { url = "https://files.pythonhosted.org/packages/e8/01/3adf46a7ea5bf31f12e09d4392e1810e662101ba6611214ea6e2c35bea7a/hidapi-0.14.0.post4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e1f6409854c0a8ed4d1fdbe88d5ee4baf6f19996d1561f76889a132cb083574d", size = 698200, upload-time = "2024-11-19T16:36:54.606Z" }, + { url = "https://files.pythonhosted.org/packages/f0/19/db15cd21bef1b0dc8ef4309c5734b64affb7e88540efd3c090f153cdae0b/hidapi-0.14.0.post4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bca568a2b7d0d454c7921d70b1cc44f427eb6f95961b6d7b3b9b4532d0de74ef", size = 671554, upload-time = "2024-11-19T16:36:56.2Z" }, + { url = "https://files.pythonhosted.org/packages/f5/23/f896ee8f0977710c354bd1b9ac6d5206c12842bd39d78a357c866f8ec6b6/hidapi-0.14.0.post4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f14ac2737fd6f58d88d2e6bf8ebd03aac7b486c14d3f570b7b1d0013d61b726", size = 703897, upload-time = "2024-11-19T16:36:57.796Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5e/3c93bb12b01392b538870bc710786fee86a9ced074a8b5c091a59786ee07/hidapi-0.14.0.post4-cp313-cp313-win32.whl", hash = "sha256:b6b9c4dbf7d7e2635ff129ce6ea82174865c073b75888b8b97dda5a3d9a70493", size = 62688, upload-time = "2024-11-19T16:36:59.124Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a6/0d43ac0be00db25fb0c2c6125e15a3e3536196c9a7cd806d50ebfb37b375/hidapi-0.14.0.post4-cp313-cp313-win_amd64.whl", hash = "sha256:87218eeba366c871adcc273407aacbabab781d6a964919712d5583eded5ca50f", size = 69749, upload-time = "2024-11-19T16:37:00.561Z" }, ] [[package]] -name = "docopt" -version = "0.6.2" +name = "iniconfig" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/55/8f8cab2afd404cf578136ef2cc5dfb50baa1761b68c9da1fb1e4eed343c9/docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491", size = 25901, upload-time = "2014-06-16T11:18:57.406Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] [[package]] -name = "exceptiongroup" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } +name = "knoepfe" +source = { editable = "." } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "aiorun" }, + { name = "click" }, + { name = "hidapi" }, + { name = "pillow" }, + { name = "platformdirs" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-fontconfig" }, + { name = "streamdeck" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, + +[package.optional-dependencies] +all = [ + { name = "knoepfe-audio-plugin" }, + { name = "knoepfe-obs-plugin" }, +] +audio = [ + { name = "knoepfe-audio-plugin" }, +] +obs = [ + { name = "knoepfe-obs-plugin" }, ] -[[package]] -name = "filelock" -version = "3.19.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiorun", specifier = ">=2025.1.1" }, + { name = "click", specifier = ">=8.2.1" }, + { name = "hidapi", specifier = ">=0.14.0.post4" }, + { name = "knoepfe-audio-plugin", marker = "extra == 'all'", editable = "plugins/audio" }, + { name = "knoepfe-audio-plugin", marker = "extra == 'audio'", editable = "plugins/audio" }, + { name = "knoepfe-obs-plugin", marker = "extra == 'all'", editable = "plugins/obs" }, + { name = "knoepfe-obs-plugin", marker = "extra == 'obs'", editable = "plugins/obs" }, + { name = "pillow", specifier = ">=10.4.0" }, + { name = "platformdirs", specifier = ">=4.4.0" }, + { name = "pydantic", specifier = ">=2.11.9" }, + { name = "pydantic-settings", specifier = ">=2.11.0" }, + { name = "python-fontconfig", specifier = ">=0.6.2.post1" }, + { name = "streamdeck", specifier = ">=0.9.5" }, +] +provides-extras = ["all", "audio", "obs"] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "pytest-cov", specifier = ">=5.0.0" }, ] [[package]] -name = "flake8" -version = "7.3.0" -source = { registry = "https://pypi.org/simple" } +name = "knoepfe-audio-plugin" +source = { editable = "plugins/audio" } dependencies = [ - { name = "mccabe" }, - { name = "pycodestyle" }, - { name = "pyflakes" }, + { name = "knoepfe" }, + { name = "pulsectl" }, + { name = "pulsectl-asyncio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, ] -[[package]] -name = "flake8-bugbear" -version = "24.12.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "flake8" }, +[package.metadata] +requires-dist = [ + { name = "knoepfe", editable = "." }, + { name = "pulsectl", specifier = ">=24.11.0" }, + { name = "pulsectl-asyncio", specifier = ">=1.2.2" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/25/48ba712ff589b0149f21135234f9bb45c14d6689acc6151b5e2ff8ac2ae9/flake8_bugbear-24.12.12.tar.gz", hash = "sha256:46273cef0a6b6ff48ca2d69e472f41420a42a46e24b2a8972e4f0d6733d12a64", size = 82907, upload-time = "2024-12-12T16:49:26.307Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/21/0a875f75fbe4008bd171e2fefa413536258fe6b4cfaaa087986de74588f4/flake8_bugbear-24.12.12-py3-none-any.whl", hash = "sha256:1b6967436f65ca22a42e5373aaa6f2d87966ade9aa38d4baf2a1be550767545e", size = 36664, upload-time = "2024-12-12T16:49:23.584Z" }, + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "pytest-cov", specifier = ">=5.0.0" }, ] [[package]] -name = "identify" -version = "2.6.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/c4/62963f25a678f6a050fb0505a65e9e726996171e6dbe1547f79619eefb15/identify-2.6.14.tar.gz", hash = "sha256:663494103b4f717cb26921c52f8751363dc89db64364cd836a9bf1535f53cd6a", size = 99283, upload-time = "2025-09-06T19:30:52.938Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/ae/2ad30f4652712c82f1c23423d79136fbce338932ad166d70c1efb86a5998/identify-2.6.14-py2.py3-none-any.whl", hash = "sha256:11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e", size = 99172, upload-time = "2025-09-06T19:30:51.759Z" }, +name = "knoepfe-example-plugin" +source = { editable = "plugins/example" } +dependencies = [ + { name = "knoepfe" }, ] -[[package]] -name = "iniconfig" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, ] -[[package]] -name = "isort" -version = "5.13.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/87/f9/c1eb8635a24e87ade2efce21e3ce8cd6b8630bb685ddc9cdaca1349b2eb5/isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", size = 175303, upload-time = "2023-12-13T20:37:26.124Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/b3/8def84f539e7d2289a02f0524b944b15d7c75dab7628bedf1c4f0992029c/isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6", size = 92310, upload-time = "2023-12-13T20:37:23.244Z" }, +[package.metadata] +requires-dist = [{ name = "knoepfe", editable = "." }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "pytest-cov", specifier = ">=5.0.0" }, ] [[package]] -name = "knoepfe" -version = "0.1.1" -source = { editable = "." } +name = "knoepfe-obs-plugin" +source = { editable = "plugins/obs" } dependencies = [ - { name = "aiorun" }, - { name = "appdirs" }, - { name = "docopt" }, - { name = "pillow" }, - { name = "pulsectl" }, - { name = "pulsectl-asyncio" }, - { name = "schema" }, + { name = "knoepfe" }, { name = "simpleobsws" }, - { name = "streamdeck" }, - { name = "websockets" }, ] [package.dev-dependencies] dev = [ - { name = "attrs" }, - { name = "black" }, - { name = "cfgv" }, - { name = "click" }, - { name = "coverage" }, - { name = "distlib" }, - { name = "filelock" }, - { name = "flake8" }, - { name = "flake8-bugbear" }, - { name = "identify" }, - { name = "iniconfig" }, - { name = "isort" }, - { name = "mccabe" }, - { name = "mypy" }, - { name = "mypy-extensions" }, - { name = "nodeenv" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, - { name = "pluggy" }, - { name = "pre-commit" }, - { name = "pycodestyle" }, - { name = "pyflakes" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, - { name = "pyyaml" }, - { name = "tomli" }, - { name = "types-appdirs" }, - { name = "types-docopt" }, - { name = "types-pillow" }, - { name = "typing-extensions" }, - { name = "virtualenv" }, ] [package.metadata] requires-dist = [ - { name = "aiorun", specifier = ">=2024.8.1,<2025" }, - { name = "appdirs", specifier = ">=1.4.4,<2" }, - { name = "docopt", specifier = ">=0.6.2,<0.7" }, - { name = "pillow", specifier = ">=10.4.0,<11" }, - { name = "pulsectl", specifier = ">=24.8.0,<25" }, - { name = "pulsectl-asyncio", specifier = ">=1.2.1,<2" }, - { name = "schema", specifier = ">=0.7.7,<0.8" }, + { name = "knoepfe", editable = "." }, { name = "simpleobsws", specifier = ">=1.4.0" }, - { name = "streamdeck", specifier = ">=0.9.5,<0.10" }, - { name = "websockets", specifier = "~=13.1" }, ] [package.metadata.requires-dev] dev = [ - { name = "attrs", specifier = ">=24.2.0,<25" }, - { name = "black", specifier = ">=24.8.0,<25" }, - { name = "cfgv", specifier = ">=3.4.0,<4" }, - { name = "click", specifier = ">=8.1.7,<9" }, - { name = "coverage", specifier = ">=7.6.1,<8" }, - { name = "distlib", specifier = ">=0.3.8,<0.4" }, - { name = "filelock", specifier = ">=3.16.1,<4" }, - { name = "flake8", specifier = ">=7.1.1,<8" }, - { name = "flake8-bugbear", specifier = ">=24.8.19,<25" }, - { name = "identify", specifier = ">=2.6.1,<3" }, - { name = "iniconfig", specifier = ">=2.0.0,<3" }, - { name = "isort", specifier = ">=5.13.2,<6" }, - { name = "mccabe", specifier = ">=0.7.0,<0.8" }, - { name = "mypy", specifier = ">=1.11.2,<2" }, - { name = "mypy-extensions", specifier = ">=1.0.0,<2" }, - { name = "nodeenv", specifier = ">=1.9.1,<2" }, - { name = "packaging", specifier = "~=24.1" }, - { name = "pathspec", specifier = ">=0.12.1,<0.13" }, - { name = "platformdirs", specifier = ">=4.3.6,<5" }, - { name = "pluggy", specifier = ">=1.5.0,<2" }, - { name = "pre-commit", specifier = ">=3.8.0,<4" }, - { name = "pycodestyle", specifier = ">=2.12.1,<3" }, - { name = "pyflakes", specifier = ">=3.2.0,<4" }, - { name = "pytest", specifier = ">=8.3.3,<9" }, - { name = "pytest-asyncio", specifier = ">=0.24.0,<0.25" }, - { name = "pytest-cov", specifier = ">=5.0.0,<6" }, - { name = "pyyaml", specifier = ">=6.0.2,<7" }, - { name = "tomli", specifier = ">=2.0.1,<3" }, - { name = "types-appdirs", specifier = ">=1.4.3.5,<2" }, - { name = "types-docopt", specifier = ">=0.6.11.4,<0.7" }, - { name = "types-pillow", specifier = ">=10.2.0.20240822,<11" }, - { name = "typing-extensions", specifier = ">=4.12.2,<5" }, - { name = "virtualenv", specifier = ">=20.26.5,<21" }, -] - -[[package]] -name = "mccabe" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "pytest-cov", specifier = ">=5.0.0" }, ] [[package]] @@ -393,16 +326,6 @@ version = "1.1.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/45/b1/ea4f68038a18c77c9467400d166d74c4ffa536f34761f7983a104357e614/msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd", size = 173555, upload-time = "2025-06-13T06:52:51.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/52/f30da112c1dc92cf64f57d08a273ac771e7b29dea10b4b30369b2d7e8546/msgpack-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:353b6fc0c36fde68b661a12949d7d49f8f51ff5fa019c1e47c87c4ff34b080ed", size = 81799, upload-time = "2025-06-13T06:51:37.228Z" }, - { url = "https://files.pythonhosted.org/packages/e4/35/7bfc0def2f04ab4145f7f108e3563f9b4abae4ab0ed78a61f350518cc4d2/msgpack-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:79c408fcf76a958491b4e3b103d1c417044544b68e96d06432a189b43d1215c8", size = 78278, upload-time = "2025-06-13T06:51:38.534Z" }, - { url = "https://files.pythonhosted.org/packages/e8/c5/df5d6c1c39856bc55f800bf82778fd4c11370667f9b9e9d51b2f5da88f20/msgpack-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78426096939c2c7482bf31ef15ca219a9e24460289c00dd0b94411040bb73ad2", size = 402805, upload-time = "2025-06-13T06:51:39.538Z" }, - { url = "https://files.pythonhosted.org/packages/20/8e/0bb8c977efecfe6ea7116e2ed73a78a8d32a947f94d272586cf02a9757db/msgpack-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b17ba27727a36cb73aabacaa44b13090feb88a01d012c0f4be70c00f75048b4", size = 408642, upload-time = "2025-06-13T06:51:41.092Z" }, - { url = "https://files.pythonhosted.org/packages/59/a1/731d52c1aeec52006be6d1f8027c49fdc2cfc3ab7cbe7c28335b2910d7b6/msgpack-1.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a17ac1ea6ec3c7687d70201cfda3b1e8061466f28f686c24f627cae4ea8efd0", size = 395143, upload-time = "2025-06-13T06:51:42.575Z" }, - { url = "https://files.pythonhosted.org/packages/2b/92/b42911c52cda2ba67a6418ffa7d08969edf2e760b09015593c8a8a27a97d/msgpack-1.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88d1e966c9235c1d4e2afac21ca83933ba59537e2e2727a999bf3f515ca2af26", size = 395986, upload-time = "2025-06-13T06:51:43.807Z" }, - { url = "https://files.pythonhosted.org/packages/61/dc/8ae165337e70118d4dab651b8b562dd5066dd1e6dd57b038f32ebc3e2f07/msgpack-1.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f6d58656842e1b2ddbe07f43f56b10a60f2ba5826164910968f5933e5178af75", size = 402682, upload-time = "2025-06-13T06:51:45.534Z" }, - { url = "https://files.pythonhosted.org/packages/58/27/555851cb98dcbd6ce041df1eacb25ac30646575e9cd125681aa2f4b1b6f1/msgpack-1.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96decdfc4adcbc087f5ea7ebdcfd3dee9a13358cae6e81d54be962efc38f6338", size = 406368, upload-time = "2025-06-13T06:51:46.97Z" }, - { url = "https://files.pythonhosted.org/packages/d4/64/39a26add4ce16f24e99eabb9005e44c663db00e3fce17d4ae1ae9d61df99/msgpack-1.1.1-cp310-cp310-win32.whl", hash = "sha256:6640fd979ca9a212e4bcdf6eb74051ade2c690b862b679bfcb60ae46e6dc4bfd", size = 65004, upload-time = "2025-06-13T06:51:48.582Z" }, - { url = "https://files.pythonhosted.org/packages/7d/18/73dfa3e9d5d7450d39debde5b0d848139f7de23bd637a4506e36c9800fd6/msgpack-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:8b65b53204fe1bd037c40c4148d00ef918eb2108d24c9aaa20bc31f9810ce0a8", size = 71548, upload-time = "2025-06-13T06:51:49.558Z" }, { url = "https://files.pythonhosted.org/packages/7f/83/97f24bf9848af23fe2ba04380388216defc49a8af6da0c28cc636d722502/msgpack-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:71ef05c1726884e44f8b1d1773604ab5d4d17729d8491403a705e649116c9558", size = 82728, upload-time = "2025-06-13T06:51:50.68Z" }, { url = "https://files.pythonhosted.org/packages/aa/7f/2eaa388267a78401f6e182662b08a588ef4f3de6f0eab1ec09736a7aaa2b/msgpack-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:36043272c6aede309d29d56851f8841ba907a1a3d04435e43e8a19928e243c1d", size = 79279, upload-time = "2025-06-13T06:51:51.72Z" }, { url = "https://files.pythonhosted.org/packages/f8/46/31eb60f4452c96161e4dfd26dbca562b4ec68c72e4ad07d9566d7ea35e8a/msgpack-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a32747b1b39c3ac27d0670122b57e6e57f28eefb725e0b625618d1b59bf9d1e0", size = 423859, upload-time = "2025-06-13T06:51:52.749Z" }, @@ -435,144 +358,97 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/91/7dc28d5e2a11a5ad804cf2b7f7a5fcb1eb5a4966d66a5d2b41aee6376543/msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69", size = 72341, upload-time = "2025-06-13T06:52:27.835Z" }, ] -[[package]] -name = "mypy" -version = "1.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8e/22/ea637422dedf0bf36f3ef238eab4e455e2a0dcc3082b5cc067615347ab8e/mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01", size = 3352570, upload-time = "2025-07-31T07:54:19.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/a9/3d7aa83955617cdf02f94e50aab5c830d205cfa4320cf124ff64acce3a8e/mypy-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3fbe6d5555bf608c47203baa3e72dbc6ec9965b3d7c318aa9a4ca76f465bd972", size = 11003299, upload-time = "2025-07-31T07:54:06.425Z" }, - { url = "https://files.pythonhosted.org/packages/83/e8/72e62ff837dd5caaac2b4a5c07ce769c8e808a00a65e5d8f94ea9c6f20ab/mypy-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80ef5c058b7bce08c83cac668158cb7edea692e458d21098c7d3bce35a5d43e7", size = 10125451, upload-time = "2025-07-31T07:53:52.974Z" }, - { url = "https://files.pythonhosted.org/packages/7d/10/f3f3543f6448db11881776f26a0ed079865926b0c841818ee22de2c6bbab/mypy-1.17.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a580f8a70c69e4a75587bd925d298434057fe2a428faaf927ffe6e4b9a98df", size = 11916211, upload-time = "2025-07-31T07:53:18.879Z" }, - { url = "https://files.pythonhosted.org/packages/06/bf/63e83ed551282d67bb3f7fea2cd5561b08d2bb6eb287c096539feb5ddbc5/mypy-1.17.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd86bb649299f09d987a2eebb4d52d10603224500792e1bee18303bbcc1ce390", size = 12652687, upload-time = "2025-07-31T07:53:30.544Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/68f2eeef11facf597143e85b694a161868b3b006a5fbad50e09ea117ef24/mypy-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a76906f26bd8d51ea9504966a9c25419f2e668f012e0bdf3da4ea1526c534d94", size = 12896322, upload-time = "2025-07-31T07:53:50.74Z" }, - { url = "https://files.pythonhosted.org/packages/a3/87/8e3e9c2c8bd0d7e071a89c71be28ad088aaecbadf0454f46a540bda7bca6/mypy-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:e79311f2d904ccb59787477b7bd5d26f3347789c06fcd7656fa500875290264b", size = 9507962, upload-time = "2025-07-31T07:53:08.431Z" }, - { url = "https://files.pythonhosted.org/packages/46/cf/eadc80c4e0a70db1c08921dcc220357ba8ab2faecb4392e3cebeb10edbfa/mypy-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad37544be07c5d7fba814eb370e006df58fed8ad1ef33ed1649cb1889ba6ff58", size = 10921009, upload-time = "2025-07-31T07:53:23.037Z" }, - { url = "https://files.pythonhosted.org/packages/5d/c1/c869d8c067829ad30d9bdae051046561552516cfb3a14f7f0347b7d973ee/mypy-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:064e2ff508e5464b4bd807a7c1625bc5047c5022b85c70f030680e18f37273a5", size = 10047482, upload-time = "2025-07-31T07:53:26.151Z" }, - { url = "https://files.pythonhosted.org/packages/98/b9/803672bab3fe03cee2e14786ca056efda4bb511ea02dadcedde6176d06d0/mypy-1.17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70401bbabd2fa1aa7c43bb358f54037baf0586f41e83b0ae67dd0534fc64edfd", size = 11832883, upload-time = "2025-07-31T07:53:47.948Z" }, - { url = "https://files.pythonhosted.org/packages/88/fb/fcdac695beca66800918c18697b48833a9a6701de288452b6715a98cfee1/mypy-1.17.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e92bdc656b7757c438660f775f872a669b8ff374edc4d18277d86b63edba6b8b", size = 12566215, upload-time = "2025-07-31T07:54:04.031Z" }, - { url = "https://files.pythonhosted.org/packages/7f/37/a932da3d3dace99ee8eb2043b6ab03b6768c36eb29a02f98f46c18c0da0e/mypy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c1fdf4abb29ed1cb091cf432979e162c208a5ac676ce35010373ff29247bcad5", size = 12751956, upload-time = "2025-07-31T07:53:36.263Z" }, - { url = "https://files.pythonhosted.org/packages/8c/cf/6438a429e0f2f5cab8bc83e53dbebfa666476f40ee322e13cac5e64b79e7/mypy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:ff2933428516ab63f961644bc49bc4cbe42bbffb2cd3b71cc7277c07d16b1a8b", size = 9507307, upload-time = "2025-07-31T07:53:59.734Z" }, - { url = "https://files.pythonhosted.org/packages/17/a2/7034d0d61af8098ec47902108553122baa0f438df8a713be860f7407c9e6/mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb", size = 11086295, upload-time = "2025-07-31T07:53:28.124Z" }, - { url = "https://files.pythonhosted.org/packages/14/1f/19e7e44b594d4b12f6ba8064dbe136505cec813549ca3e5191e40b1d3cc2/mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403", size = 10112355, upload-time = "2025-07-31T07:53:21.121Z" }, - { url = "https://files.pythonhosted.org/packages/5b/69/baa33927e29e6b4c55d798a9d44db5d394072eef2bdc18c3e2048c9ed1e9/mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056", size = 11875285, upload-time = "2025-07-31T07:53:55.293Z" }, - { url = "https://files.pythonhosted.org/packages/90/13/f3a89c76b0a41e19490b01e7069713a30949d9a6c147289ee1521bcea245/mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341", size = 12737895, upload-time = "2025-07-31T07:53:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/23/a1/c4ee79ac484241301564072e6476c5a5be2590bc2e7bfd28220033d2ef8f/mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb", size = 12931025, upload-time = "2025-07-31T07:54:17.125Z" }, - { url = "https://files.pythonhosted.org/packages/89/b8/7409477be7919a0608900e6320b155c72caab4fef46427c5cc75f85edadd/mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19", size = 9584664, upload-time = "2025-07-31T07:54:12.842Z" }, - { url = "https://files.pythonhosted.org/packages/5b/82/aec2fc9b9b149f372850291827537a508d6c4d3664b1750a324b91f71355/mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7", size = 11075338, upload-time = "2025-07-31T07:53:38.873Z" }, - { url = "https://files.pythonhosted.org/packages/07/ac/ee93fbde9d2242657128af8c86f5d917cd2887584cf948a8e3663d0cd737/mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81", size = 10113066, upload-time = "2025-07-31T07:54:14.707Z" }, - { url = "https://files.pythonhosted.org/packages/5a/68/946a1e0be93f17f7caa56c45844ec691ca153ee8b62f21eddda336a2d203/mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6", size = 11875473, upload-time = "2025-07-31T07:53:14.504Z" }, - { url = "https://files.pythonhosted.org/packages/9f/0f/478b4dce1cb4f43cf0f0d00fba3030b21ca04a01b74d1cd272a528cf446f/mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849", size = 12744296, upload-time = "2025-07-31T07:53:03.896Z" }, - { url = "https://files.pythonhosted.org/packages/ca/70/afa5850176379d1b303f992a828de95fc14487429a7139a4e0bdd17a8279/mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14", size = 12914657, upload-time = "2025-07-31T07:54:08.576Z" }, - { url = "https://files.pythonhosted.org/packages/53/f9/4a83e1c856a3d9c8f6edaa4749a4864ee98486e9b9dbfbc93842891029c2/mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a", size = 9593320, upload-time = "2025-07-31T07:53:01.341Z" }, - { url = "https://files.pythonhosted.org/packages/38/56/79c2fac86da57c7d8c48622a05873eaab40b905096c33597462713f5af90/mypy-1.17.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733", size = 11040037, upload-time = "2025-07-31T07:54:10.942Z" }, - { url = "https://files.pythonhosted.org/packages/4d/c3/adabe6ff53638e3cad19e3547268482408323b1e68bf082c9119000cd049/mypy-1.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd", size = 10131550, upload-time = "2025-07-31T07:53:41.307Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c5/2e234c22c3bdeb23a7817af57a58865a39753bde52c74e2c661ee0cfc640/mypy-1.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0", size = 11872963, upload-time = "2025-07-31T07:53:16.878Z" }, - { url = "https://files.pythonhosted.org/packages/ab/26/c13c130f35ca8caa5f2ceab68a247775648fdcd6c9a18f158825f2bc2410/mypy-1.17.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a", size = 12710189, upload-time = "2025-07-31T07:54:01.962Z" }, - { url = "https://files.pythonhosted.org/packages/82/df/c7d79d09f6de8383fe800521d066d877e54d30b4fb94281c262be2df84ef/mypy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91", size = 12900322, upload-time = "2025-07-31T07:53:10.551Z" }, - { url = "https://files.pythonhosted.org/packages/b8/98/3d5a48978b4f708c55ae832619addc66d677f6dc59f3ebad71bae8285ca6/mypy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed", size = 9751879, upload-time = "2025-07-31T07:52:56.683Z" }, - { url = "https://files.pythonhosted.org/packages/1d/f3/8fcd2af0f5b806f6cf463efaffd3c9548a28f84220493ecd38d127b6b66d/mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9", size = 2283411, upload-time = "2025-07-31T07:53:24.664Z" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - -[[package]] -name = "nodeenv" -version = "1.9.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, -] - [[package]] name = "packaging" -version = "24.2" +version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, -] - -[[package]] -name = "pathspec" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] name = "pillow" -version = "10.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/74/ad3d526f3bf7b6d3f408b73fde271ec69dfac8b81341a318ce825f2b3812/pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06", size = 46555059, upload-time = "2024-07-01T09:48:43.583Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/69/a31cccd538ca0b5272be2a38347f8839b97a14be104ea08b0db92f749c74/pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e", size = 3509271, upload-time = "2024-07-01T09:45:22.07Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9e/4143b907be8ea0bce215f2ae4f7480027473f8b61fcedfda9d851082a5d2/pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d", size = 3375658, upload-time = "2024-07-01T09:45:25.292Z" }, - { url = "https://files.pythonhosted.org/packages/8a/25/1fc45761955f9359b1169aa75e241551e74ac01a09f487adaaf4c3472d11/pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856", size = 4332075, upload-time = "2024-07-01T09:45:27.94Z" }, - { url = "https://files.pythonhosted.org/packages/5e/dd/425b95d0151e1d6c951f45051112394f130df3da67363b6bc75dc4c27aba/pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f", size = 4444808, upload-time = "2024-07-01T09:45:30.305Z" }, - { url = "https://files.pythonhosted.org/packages/b1/84/9a15cc5726cbbfe7f9f90bfb11f5d028586595907cd093815ca6644932e3/pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b", size = 4356290, upload-time = "2024-07-01T09:45:32.868Z" }, - { url = "https://files.pythonhosted.org/packages/b5/5b/6651c288b08df3b8c1e2f8c1152201e0b25d240e22ddade0f1e242fc9fa0/pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc", size = 4525163, upload-time = "2024-07-01T09:45:35.279Z" }, - { url = "https://files.pythonhosted.org/packages/07/8b/34854bf11a83c248505c8cb0fcf8d3d0b459a2246c8809b967963b6b12ae/pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e", size = 4463100, upload-time = "2024-07-01T09:45:37.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/63/0632aee4e82476d9cbe5200c0cdf9ba41ee04ed77887432845264d81116d/pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46", size = 4592880, upload-time = "2024-07-01T09:45:39.89Z" }, - { url = "https://files.pythonhosted.org/packages/df/56/b8663d7520671b4398b9d97e1ed9f583d4afcbefbda3c6188325e8c297bd/pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984", size = 2235218, upload-time = "2024-07-01T09:45:42.771Z" }, - { url = "https://files.pythonhosted.org/packages/f4/72/0203e94a91ddb4a9d5238434ae6c1ca10e610e8487036132ea9bf806ca2a/pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141", size = 2554487, upload-time = "2024-07-01T09:45:45.176Z" }, - { url = "https://files.pythonhosted.org/packages/bd/52/7e7e93d7a6e4290543f17dc6f7d3af4bd0b3dd9926e2e8a35ac2282bc5f4/pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1", size = 2243219, upload-time = "2024-07-01T09:45:47.274Z" }, - { url = "https://files.pythonhosted.org/packages/a7/62/c9449f9c3043c37f73e7487ec4ef0c03eb9c9afc91a92b977a67b3c0bbc5/pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c", size = 3509265, upload-time = "2024-07-01T09:45:49.812Z" }, - { url = "https://files.pythonhosted.org/packages/f4/5f/491dafc7bbf5a3cc1845dc0430872e8096eb9e2b6f8161509d124594ec2d/pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be", size = 3375655, upload-time = "2024-07-01T09:45:52.462Z" }, - { url = "https://files.pythonhosted.org/packages/73/d5/c4011a76f4207a3c151134cd22a1415741e42fa5ddecec7c0182887deb3d/pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3", size = 4340304, upload-time = "2024-07-01T09:45:55.006Z" }, - { url = "https://files.pythonhosted.org/packages/ac/10/c67e20445a707f7a610699bba4fe050583b688d8cd2d202572b257f46600/pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6", size = 4452804, upload-time = "2024-07-01T09:45:58.437Z" }, - { url = "https://files.pythonhosted.org/packages/a9/83/6523837906d1da2b269dee787e31df3b0acb12e3d08f024965a3e7f64665/pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe", size = 4365126, upload-time = "2024-07-01T09:46:00.713Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e5/8c68ff608a4203085158cff5cc2a3c534ec384536d9438c405ed6370d080/pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319", size = 4533541, upload-time = "2024-07-01T09:46:03.235Z" }, - { url = "https://files.pythonhosted.org/packages/f4/7c/01b8dbdca5bc6785573f4cee96e2358b0918b7b2c7b60d8b6f3abf87a070/pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d", size = 4471616, upload-time = "2024-07-01T09:46:05.356Z" }, - { url = "https://files.pythonhosted.org/packages/c8/57/2899b82394a35a0fbfd352e290945440e3b3785655a03365c0ca8279f351/pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696", size = 4600802, upload-time = "2024-07-01T09:46:08.145Z" }, - { url = "https://files.pythonhosted.org/packages/4d/d7/a44f193d4c26e58ee5d2d9db3d4854b2cfb5b5e08d360a5e03fe987c0086/pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496", size = 2235213, upload-time = "2024-07-01T09:46:10.211Z" }, - { url = "https://files.pythonhosted.org/packages/c1/d0/5866318eec2b801cdb8c82abf190c8343d8a1cd8bf5a0c17444a6f268291/pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91", size = 2554498, upload-time = "2024-07-01T09:46:12.685Z" }, - { url = "https://files.pythonhosted.org/packages/d4/c8/310ac16ac2b97e902d9eb438688de0d961660a87703ad1561fd3dfbd2aa0/pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22", size = 2243219, upload-time = "2024-07-01T09:46:14.83Z" }, - { url = "https://files.pythonhosted.org/packages/05/cb/0353013dc30c02a8be34eb91d25e4e4cf594b59e5a55ea1128fde1e5f8ea/pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94", size = 3509350, upload-time = "2024-07-01T09:46:17.177Z" }, - { url = "https://files.pythonhosted.org/packages/e7/cf/5c558a0f247e0bf9cec92bff9b46ae6474dd736f6d906315e60e4075f737/pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597", size = 3374980, upload-time = "2024-07-01T09:46:19.169Z" }, - { url = "https://files.pythonhosted.org/packages/84/48/6e394b86369a4eb68b8a1382c78dc092245af517385c086c5094e3b34428/pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80", size = 4343799, upload-time = "2024-07-01T09:46:21.883Z" }, - { url = "https://files.pythonhosted.org/packages/3b/f3/a8c6c11fa84b59b9df0cd5694492da8c039a24cd159f0f6918690105c3be/pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca", size = 4459973, upload-time = "2024-07-01T09:46:24.321Z" }, - { url = "https://files.pythonhosted.org/packages/7d/1b/c14b4197b80150fb64453585247e6fb2e1d93761fa0fa9cf63b102fde822/pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef", size = 4370054, upload-time = "2024-07-01T09:46:26.825Z" }, - { url = "https://files.pythonhosted.org/packages/55/77/40daddf677897a923d5d33329acd52a2144d54a9644f2a5422c028c6bf2d/pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a", size = 4539484, upload-time = "2024-07-01T09:46:29.355Z" }, - { url = "https://files.pythonhosted.org/packages/40/54/90de3e4256b1207300fb2b1d7168dd912a2fb4b2401e439ba23c2b2cabde/pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b", size = 4477375, upload-time = "2024-07-01T09:46:31.756Z" }, - { url = "https://files.pythonhosted.org/packages/13/24/1bfba52f44193860918ff7c93d03d95e3f8748ca1de3ceaf11157a14cf16/pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9", size = 4608773, upload-time = "2024-07-01T09:46:33.73Z" }, - { url = "https://files.pythonhosted.org/packages/55/04/5e6de6e6120451ec0c24516c41dbaf80cce1b6451f96561235ef2429da2e/pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42", size = 2235690, upload-time = "2024-07-01T09:46:36.587Z" }, - { url = "https://files.pythonhosted.org/packages/74/0a/d4ce3c44bca8635bd29a2eab5aa181b654a734a29b263ca8efe013beea98/pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a", size = 2554951, upload-time = "2024-07-01T09:46:38.777Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ca/184349ee40f2e92439be9b3502ae6cfc43ac4b50bc4fc6b3de7957563894/pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9", size = 2243427, upload-time = "2024-07-01T09:46:43.15Z" }, - { url = "https://files.pythonhosted.org/packages/c3/00/706cebe7c2c12a6318aabe5d354836f54adff7156fd9e1bd6c89f4ba0e98/pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3", size = 3525685, upload-time = "2024-07-01T09:46:45.194Z" }, - { url = "https://files.pythonhosted.org/packages/cf/76/f658cbfa49405e5ecbfb9ba42d07074ad9792031267e782d409fd8fe7c69/pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb", size = 3374883, upload-time = "2024-07-01T09:46:47.331Z" }, - { url = "https://files.pythonhosted.org/packages/46/2b/99c28c4379a85e65378211971c0b430d9c7234b1ec4d59b2668f6299e011/pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70", size = 4339837, upload-time = "2024-07-01T09:46:49.647Z" }, - { url = "https://files.pythonhosted.org/packages/f1/74/b1ec314f624c0c43711fdf0d8076f82d9d802afd58f1d62c2a86878e8615/pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be", size = 4455562, upload-time = "2024-07-01T09:46:51.811Z" }, - { url = "https://files.pythonhosted.org/packages/4a/2a/4b04157cb7b9c74372fa867096a1607e6fedad93a44deeff553ccd307868/pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0", size = 4366761, upload-time = "2024-07-01T09:46:53.961Z" }, - { url = "https://files.pythonhosted.org/packages/ac/7b/8f1d815c1a6a268fe90481232c98dd0e5fa8c75e341a75f060037bd5ceae/pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc", size = 4536767, upload-time = "2024-07-01T09:46:56.664Z" }, - { url = "https://files.pythonhosted.org/packages/e5/77/05fa64d1f45d12c22c314e7b97398ffb28ef2813a485465017b7978b3ce7/pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a", size = 4477989, upload-time = "2024-07-01T09:46:58.977Z" }, - { url = "https://files.pythonhosted.org/packages/12/63/b0397cfc2caae05c3fb2f4ed1b4fc4fc878f0243510a7a6034ca59726494/pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309", size = 4610255, upload-time = "2024-07-01T09:47:01.189Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f9/cfaa5082ca9bc4a6de66ffe1c12c2d90bf09c309a5f52b27759a596900e7/pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060", size = 2235603, upload-time = "2024-07-01T09:47:03.918Z" }, - { url = "https://files.pythonhosted.org/packages/01/6a/30ff0eef6e0c0e71e55ded56a38d4859bf9d3634a94a88743897b5f96936/pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea", size = 2554972, upload-time = "2024-07-01T09:47:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/48/2c/2e0a52890f269435eee38b21c8218e102c621fe8d8df8b9dd06fabf879ba/pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d", size = 2243375, upload-time = "2024-07-01T09:47:09.065Z" }, - { url = "https://files.pythonhosted.org/packages/38/30/095d4f55f3a053392f75e2eae45eba3228452783bab3d9a920b951ac495c/pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4", size = 3493889, upload-time = "2024-07-01T09:48:04.815Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e8/4ff79788803a5fcd5dc35efdc9386af153569853767bff74540725b45863/pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da", size = 3346160, upload-time = "2024-07-01T09:48:07.206Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ac/4184edd511b14f760c73f5bb8a5d6fd85c591c8aff7c2229677a355c4179/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026", size = 3435020, upload-time = "2024-07-01T09:48:09.66Z" }, - { url = "https://files.pythonhosted.org/packages/da/21/1749cd09160149c0a246a81d646e05f35041619ce76f6493d6a96e8d1103/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e", size = 3490539, upload-time = "2024-07-01T09:48:12.529Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f5/f71fe1888b96083b3f6dfa0709101f61fc9e972c0c8d04e9d93ccef2a045/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5", size = 3476125, upload-time = "2024-07-01T09:48:14.891Z" }, - { url = "https://files.pythonhosted.org/packages/96/b9/c0362c54290a31866c3526848583a2f45a535aa9d725fd31e25d318c805f/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885", size = 3579373, upload-time = "2024-07-01T09:48:17.601Z" }, - { url = "https://files.pythonhosted.org/packages/52/3b/ce7a01026a7cf46e5452afa86f97a5e88ca97f562cafa76570178ab56d8d/pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5", size = 2554661, upload-time = "2024-07-01T09:48:20.293Z" }, +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, + { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" }, + { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" }, + { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" }, + { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" }, + { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, + { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" }, + { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" }, + { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, ] [[package]] @@ -593,22 +469,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "pre-commit" -version = "3.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cfgv" }, - { name = "identify" }, - { name = "nodeenv" }, - { name = "pyyaml" }, - { name = "virtualenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/64/10/97ee2fa54dff1e9da9badbc5e35d0bbaef0776271ea5907eccf64140f72f/pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af", size = 177815, upload-time = "2024-07-28T19:59:01.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/92/caae8c86e94681b42c246f0bca35c059a2f0529e5b92619f6aba4cf7e7b6/pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f", size = 204643, upload-time = "2024-07-28T19:58:59.335Z" }, -] - [[package]] name = "pulsectl" version = "24.11.0" @@ -631,21 +491,97 @@ wheels = [ ] [[package]] -name = "pycodestyle" -version = "2.14.0" +name = "pydantic" +version = "2.11.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495, upload-time = "2025-09-13T11:26:39.325Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" }, ] [[package]] -name = "pyflakes" -version = "3.4.0" +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, + { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" }, ] [[package]] @@ -663,12 +599,10 @@ version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ @@ -677,93 +611,66 @@ wheels = [ [[package]] name = "pytest-asyncio" -version = "0.24.0" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855, upload-time = "2024-08-22T08:03:18.145Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024, upload-time = "2024-08-22T08:03:15.536Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, ] [[package]] name = "pytest-cov" -version = "5.0.0" +version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042, upload-time = "2024-03-24T20:16:34.856Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990, upload-time = "2024-03-24T20:16:32.444Z" }, + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] [[package]] -name = "pyyaml" -version = "6.0.2" +name = "python-dotenv" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, - { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, - { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, - { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, - { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, - { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, - { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, - { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, - { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, - { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, - { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, - { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, - { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, - { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, - { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, - { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, -] - -[[package]] -name = "schema" -version = "0.7.7" + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "python-fontconfig" +version = "0.6.2.post1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/01/0ea2e66bad2f13271e93b729c653747614784d3ebde219679e41ccdceecd/schema-0.7.7.tar.gz", hash = "sha256:7da553abd2958a19dc2547c388cde53398b39196175a9be59ea1caf5ab0a1807", size = 44245, upload-time = "2024-05-04T10:56:17.318Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/59/99db03443f584b6d14bb635ef9f849dae7966792471dee9bf50ae2308f07/python_fontconfig-0.6.2.post1.tar.gz", hash = "sha256:4837290305613710cf6c515db8923284da06e4f48a549d2fe8e2d4276aed3e73", size = 110187, upload-time = "2025-09-25T02:05:13.172Z" } + +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/1b/81855a88c6db2b114d5b2e9f96339190d5ee4d1b981d217fa32127bb00e0/schema-0.7.7-py2.py3-none-any.whl", hash = "sha256:5d976a5b50f36e74e2157b47097b60002bd4d42e65425fcc9c9befadb4255dde", size = 18632, upload-time = "2024-05-04T10:56:13.86Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, ] [[package]] name = "simpleobsws" -version = "1.4.0" +version = "1.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "msgpack" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/2f/aae8a6b920ac2fac7f6297b11cf8c533523910b7a3bc03303972004fc1aa/simpleobsws-1.4.0.tar.gz", hash = "sha256:2acebb054b4574f78b694de7baacf7c6499e8edc423a682e446e8e5c34438d68", size = 5335, upload-time = "2023-05-28T07:07:43.67Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/36/86270e246dee811b2e2776e43d598db9296fa5d2e0eb55c183a4bec5d8e0/simpleobsws-1.4.3.tar.gz", hash = "sha256:9cd1f97e4cc39a42cfd2f5c3ffd9e785df5913c20d173ec25ae1c2573beba0ed", size = 5461, upload-time = "2025-06-20T04:20:23.077Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/a5/a5360f13cd1a931413f8cfcba420c3cc2b24a63a3b579d4fa9c5e466261d/simpleobsws-1.4.0-py3-none-any.whl", hash = "sha256:47cb8e04c02e5b2a180210f9635b17eced9737eb3e4bcb4589502f2aa0763882", size = 5512, upload-time = "2023-05-28T07:07:42.551Z" }, + { url = "https://files.pythonhosted.org/packages/9f/06/d61d8b1d055ba788ef0939fa4e063dd812165d04dfdc9e47975b968e4ebc/simpleobsws-1.4.3-py3-none-any.whl", hash = "sha256:a9310c6d2eba7f1ee25ae83ff020aa2037059a73f5581fcb6705fbf7ccc11c72", size = 5661, upload-time = "2025-06-20T04:20:21.989Z" }, ] [[package]] @@ -814,33 +721,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] -[[package]] -name = "types-appdirs" -version = "1.4.3.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/dc/600964f9ee98f4afdb69be74cd8e1ca566635a76ada9af0046e44a778fbb/types-appdirs-1.4.3.5.tar.gz", hash = "sha256:83268da64585361bfa291f8f506a209276212a0497bd37f0512a939b3d69ff14", size = 2866, upload-time = "2023-03-14T15:21:34.849Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/07/41f5b9b11f11855eb67760ed680330e0ce9136a44b51c24dd52edb1c4eb1/types_appdirs-1.4.3.5-py3-none-any.whl", hash = "sha256:337c750e423c40911d389359b4edabe5bbc2cdd5cd0bd0518b71d2839646273b", size = 2667, upload-time = "2023-03-14T15:21:32.431Z" }, -] - -[[package]] -name = "types-docopt" -version = "0.6.11.20241107" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/42/76bad7ee3b49f06c1c577cd8403a13e98f6d70fe2bbb18a6cecf6188f6d2/types-docopt-0.6.11.20241107.tar.gz", hash = "sha256:61c44d03ac4895b5be8d40ba5cc80ce52a63d3d76777ad14669e94b5e9edf7a7", size = 3159, upload-time = "2024-11-07T15:20:41.643Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/91/9fc73a8ecfcc82a8be2e21f8e63fc35ffcd3a67771ac95a6dfba49a15ab8/types_docopt-0.6.11.20241107-py3-none-any.whl", hash = "sha256:4aaaa43ef4c16eaff2a5af44c0018a28981e608a5f9293500f750818edb2d97b", size = 3188, upload-time = "2024-11-07T15:20:40.221Z" }, -] - -[[package]] -name = "types-pillow" -version = "10.2.0.20240822" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/4a/4495264dddaa600d65d68bcedb64dcccf9d9da61adff51f7d2ffd8e4c9ce/types-Pillow-10.2.0.20240822.tar.gz", hash = "sha256:559fb52a2ef991c326e4a0d20accb3bb63a7ba8d40eb493e0ecb0310ba52f0d3", size = 35389, upload-time = "2024-08-22T02:32:48.15Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/66/23/e81a5354859831fcf54d488d33b80ba6133ea84f874a9c0ec40a4881e133/types_Pillow-10.2.0.20240822-py3-none-any.whl", hash = "sha256:d9dab025aba07aeb12fd50a6799d4eac52a9603488eca09d7662543983f16c5d", size = 54354, upload-time = "2024-08-22T02:32:46.664Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" @@ -851,75 +731,55 @@ wheels = [ ] [[package]] -name = "virtualenv" -version = "20.34.0" +name = "typing-inspection" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "distlib" }, - { name = "filelock" }, - { name = "platformdirs" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "websockets" -version = "13.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e2/73/9223dbc7be3dcaf2a7bbf756c351ec8da04b1fa573edaf545b95f6b0c7fd/websockets-13.1.tar.gz", hash = "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878", size = 158549, upload-time = "2024-09-21T17:34:21.54Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/94/d15dbfc6a5eb636dbc754303fba18208f2e88cf97e733e1d64fb9cb5c89e/websockets-13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee", size = 157815, upload-time = "2024-09-21T17:32:27.107Z" }, - { url = "https://files.pythonhosted.org/packages/30/02/c04af33f4663945a26f5e8cf561eb140c35452b50af47a83c3fbcfe62ae1/websockets-13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7", size = 155466, upload-time = "2024-09-21T17:32:28.428Z" }, - { url = "https://files.pythonhosted.org/packages/35/e8/719f08d12303ea643655e52d9e9851b2dadbb1991d4926d9ce8862efa2f5/websockets-13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6", size = 155716, upload-time = "2024-09-21T17:32:29.905Z" }, - { url = "https://files.pythonhosted.org/packages/91/e1/14963ae0252a8925f7434065d25dcd4701d5e281a0b4b460a3b5963d2594/websockets-13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b", size = 164806, upload-time = "2024-09-21T17:32:31.384Z" }, - { url = "https://files.pythonhosted.org/packages/ec/fa/ab28441bae5e682a0f7ddf3d03440c0c352f930da419301f4a717f675ef3/websockets-13.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa", size = 163810, upload-time = "2024-09-21T17:32:32.384Z" }, - { url = "https://files.pythonhosted.org/packages/44/77/dea187bd9d16d4b91566a2832be31f99a40d0f5bfa55eeb638eb2c3bc33d/websockets-13.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700", size = 164125, upload-time = "2024-09-21T17:32:33.398Z" }, - { url = "https://files.pythonhosted.org/packages/cf/d9/3af14544e83f1437eb684b399e6ba0fa769438e869bf5d83d74bc197fae8/websockets-13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c", size = 164532, upload-time = "2024-09-21T17:32:35.109Z" }, - { url = "https://files.pythonhosted.org/packages/1c/8a/6d332eabe7d59dfefe4b8ba6f46c8c5fabb15b71c8a8bc3d2b65de19a7b6/websockets-13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0", size = 163948, upload-time = "2024-09-21T17:32:36.214Z" }, - { url = "https://files.pythonhosted.org/packages/1a/91/a0aeadbaf3017467a1ee03f8fb67accdae233fe2d5ad4b038c0a84e357b0/websockets-13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f", size = 163898, upload-time = "2024-09-21T17:32:37.277Z" }, - { url = "https://files.pythonhosted.org/packages/71/31/a90fb47c63e0ae605be914b0b969d7c6e6ffe2038cd744798e4b3fbce53b/websockets-13.1-cp310-cp310-win32.whl", hash = "sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe", size = 158706, upload-time = "2024-09-21T17:32:38.755Z" }, - { url = "https://files.pythonhosted.org/packages/93/ca/9540a9ba80da04dc7f36d790c30cae4252589dbd52ccdc92e75b0be22437/websockets-13.1-cp310-cp310-win_amd64.whl", hash = "sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a", size = 159141, upload-time = "2024-09-21T17:32:40.495Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f0/cf0b8a30d86b49e267ac84addbebbc7a48a6e7bb7c19db80f62411452311/websockets-13.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19", size = 157813, upload-time = "2024-09-21T17:32:42.188Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e7/22285852502e33071a8cf0ac814f8988480ec6db4754e067b8b9d0e92498/websockets-13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5", size = 155469, upload-time = "2024-09-21T17:32:43.858Z" }, - { url = "https://files.pythonhosted.org/packages/68/d4/c8c7c1e5b40ee03c5cc235955b0fb1ec90e7e37685a5f69229ad4708dcde/websockets-13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd", size = 155717, upload-time = "2024-09-21T17:32:44.914Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/c50999b9b848b1332b07c7fd8886179ac395cb766fda62725d1539e7bc6c/websockets-13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02", size = 165379, upload-time = "2024-09-21T17:32:45.933Z" }, - { url = "https://files.pythonhosted.org/packages/bc/49/4a4ad8c072f18fd79ab127650e47b160571aacfc30b110ee305ba25fffc9/websockets-13.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7", size = 164376, upload-time = "2024-09-21T17:32:46.987Z" }, - { url = "https://files.pythonhosted.org/packages/af/9b/8c06d425a1d5a74fd764dd793edd02be18cf6fc3b1ccd1f29244ba132dc0/websockets-13.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096", size = 164753, upload-time = "2024-09-21T17:32:48.046Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5b/0acb5815095ff800b579ffc38b13ab1b915b317915023748812d24e0c1ac/websockets-13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084", size = 165051, upload-time = "2024-09-21T17:32:49.271Z" }, - { url = "https://files.pythonhosted.org/packages/30/93/c3891c20114eacb1af09dedfcc620c65c397f4fd80a7009cd12d9457f7f5/websockets-13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3", size = 164489, upload-time = "2024-09-21T17:32:50.392Z" }, - { url = "https://files.pythonhosted.org/packages/28/09/af9e19885539759efa2e2cd29b8b3f9eecef7ecefea40d46612f12138b36/websockets-13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9", size = 164438, upload-time = "2024-09-21T17:32:52.223Z" }, - { url = "https://files.pythonhosted.org/packages/b6/08/6f38b8e625b3d93de731f1d248cc1493327f16cb45b9645b3e791782cff0/websockets-13.1-cp311-cp311-win32.whl", hash = "sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f", size = 158710, upload-time = "2024-09-21T17:32:53.244Z" }, - { url = "https://files.pythonhosted.org/packages/fb/39/ec8832ecb9bb04a8d318149005ed8cee0ba4e0205835da99e0aa497a091f/websockets-13.1-cp311-cp311-win_amd64.whl", hash = "sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557", size = 159137, upload-time = "2024-09-21T17:32:54.721Z" }, - { url = "https://files.pythonhosted.org/packages/df/46/c426282f543b3c0296cf964aa5a7bb17e984f58dde23460c3d39b3148fcf/websockets-13.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc", size = 157821, upload-time = "2024-09-21T17:32:56.442Z" }, - { url = "https://files.pythonhosted.org/packages/aa/85/22529867010baac258da7c45848f9415e6cf37fef00a43856627806ffd04/websockets-13.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49", size = 155480, upload-time = "2024-09-21T17:32:57.698Z" }, - { url = "https://files.pythonhosted.org/packages/29/2c/bdb339bfbde0119a6e84af43ebf6275278698a2241c2719afc0d8b0bdbf2/websockets-13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd", size = 155715, upload-time = "2024-09-21T17:32:59.429Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d0/8612029ea04c5c22bf7af2fd3d63876c4eaeef9b97e86c11972a43aa0e6c/websockets-13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0", size = 165647, upload-time = "2024-09-21T17:33:00.495Z" }, - { url = "https://files.pythonhosted.org/packages/56/04/1681ed516fa19ca9083f26d3f3a302257e0911ba75009533ed60fbb7b8d1/websockets-13.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6", size = 164592, upload-time = "2024-09-21T17:33:02.223Z" }, - { url = "https://files.pythonhosted.org/packages/38/6f/a96417a49c0ed132bb6087e8e39a37db851c70974f5c724a4b2a70066996/websockets-13.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9", size = 165012, upload-time = "2024-09-21T17:33:03.288Z" }, - { url = "https://files.pythonhosted.org/packages/40/8b/fccf294919a1b37d190e86042e1a907b8f66cff2b61e9befdbce03783e25/websockets-13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68", size = 165311, upload-time = "2024-09-21T17:33:04.728Z" }, - { url = "https://files.pythonhosted.org/packages/c1/61/f8615cf7ce5fe538476ab6b4defff52beb7262ff8a73d5ef386322d9761d/websockets-13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14", size = 164692, upload-time = "2024-09-21T17:33:05.829Z" }, - { url = "https://files.pythonhosted.org/packages/5c/f1/a29dd6046d3a722d26f182b783a7997d25298873a14028c4760347974ea3/websockets-13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf", size = 164686, upload-time = "2024-09-21T17:33:06.823Z" }, - { url = "https://files.pythonhosted.org/packages/0f/99/ab1cdb282f7e595391226f03f9b498f52109d25a2ba03832e21614967dfa/websockets-13.1-cp312-cp312-win32.whl", hash = "sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c", size = 158712, upload-time = "2024-09-21T17:33:07.877Z" }, - { url = "https://files.pythonhosted.org/packages/46/93/e19160db48b5581feac8468330aa11b7292880a94a37d7030478596cc14e/websockets-13.1-cp312-cp312-win_amd64.whl", hash = "sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3", size = 159145, upload-time = "2024-09-21T17:33:09.202Z" }, - { url = "https://files.pythonhosted.org/packages/51/20/2b99ca918e1cbd33c53db2cace5f0c0cd8296fc77558e1908799c712e1cd/websockets-13.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6", size = 157828, upload-time = "2024-09-21T17:33:10.987Z" }, - { url = "https://files.pythonhosted.org/packages/b8/47/0932a71d3d9c0e9483174f60713c84cee58d62839a143f21a2bcdbd2d205/websockets-13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708", size = 155487, upload-time = "2024-09-21T17:33:12.153Z" }, - { url = "https://files.pythonhosted.org/packages/a9/60/f1711eb59ac7a6c5e98e5637fef5302f45b6f76a2c9d64fd83bbb341377a/websockets-13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418", size = 155721, upload-time = "2024-09-21T17:33:13.909Z" }, - { url = "https://files.pythonhosted.org/packages/6a/e6/ba9a8db7f9d9b0e5f829cf626ff32677f39824968317223605a6b419d445/websockets-13.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a", size = 165609, upload-time = "2024-09-21T17:33:14.967Z" }, - { url = "https://files.pythonhosted.org/packages/c1/22/4ec80f1b9c27a0aebd84ccd857252eda8418ab9681eb571b37ca4c5e1305/websockets-13.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f", size = 164556, upload-time = "2024-09-21T17:33:17.113Z" }, - { url = "https://files.pythonhosted.org/packages/27/ac/35f423cb6bb15600438db80755609d27eda36d4c0b3c9d745ea12766c45e/websockets-13.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5", size = 164993, upload-time = "2024-09-21T17:33:18.168Z" }, - { url = "https://files.pythonhosted.org/packages/31/4e/98db4fd267f8be9e52e86b6ee4e9aa7c42b83452ea0ea0672f176224b977/websockets-13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135", size = 165360, upload-time = "2024-09-21T17:33:19.233Z" }, - { url = "https://files.pythonhosted.org/packages/3f/15/3f0de7cda70ffc94b7e7024544072bc5b26e2c1eb36545291abb755d8cdb/websockets-13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2", size = 164745, upload-time = "2024-09-21T17:33:20.361Z" }, - { url = "https://files.pythonhosted.org/packages/a1/6e/66b6b756aebbd680b934c8bdbb6dcb9ce45aad72cde5f8a7208dbb00dd36/websockets-13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6", size = 164732, upload-time = "2024-09-21T17:33:23.103Z" }, - { url = "https://files.pythonhosted.org/packages/35/c6/12e3aab52c11aeb289e3dbbc05929e7a9d90d7a9173958477d3ef4f8ce2d/websockets-13.1-cp313-cp313-win32.whl", hash = "sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d", size = 158709, upload-time = "2024-09-21T17:33:24.196Z" }, - { url = "https://files.pythonhosted.org/packages/41/d8/63d6194aae711d7263df4498200c690a9c39fb437ede10f3e157a6343e0d/websockets-13.1-cp313-cp313-win_amd64.whl", hash = "sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2", size = 159144, upload-time = "2024-09-21T17:33:25.96Z" }, - { url = "https://files.pythonhosted.org/packages/2d/75/6da22cb3ad5b8c606963f9a5f9f88656256fecc29d420b4b2bf9e0c7d56f/websockets-13.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238", size = 155499, upload-time = "2024-09-21T17:33:54.917Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ba/22833d58629088fcb2ccccedfae725ac0bbcd713319629e97125b52ac681/websockets-13.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5", size = 155737, upload-time = "2024-09-21T17:33:56.052Z" }, - { url = "https://files.pythonhosted.org/packages/95/54/61684fe22bdb831e9e1843d972adadf359cf04ab8613285282baea6a24bb/websockets-13.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9", size = 157095, upload-time = "2024-09-21T17:33:57.21Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f5/6652fb82440813822022a9301a30afde85e5ff3fb2aebb77f34aabe2b4e8/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6", size = 156701, upload-time = "2024-09-21T17:33:59.061Z" }, - { url = "https://files.pythonhosted.org/packages/67/33/ae82a7b860fa8a08aba68818bdf7ff61f04598aa5ab96df4cd5a3e418ca4/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a", size = 156654, upload-time = "2024-09-21T17:34:00.944Z" }, - { url = "https://files.pythonhosted.org/packages/63/0b/a1b528d36934f833e20f6da1032b995bf093d55cb416b9f2266f229fb237/websockets-13.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23", size = 159192, upload-time = "2024-09-21T17:34:02.656Z" }, - { url = "https://files.pythonhosted.org/packages/56/27/96a5cd2626d11c8280656c6c71d8ab50fe006490ef9971ccd154e0c42cd2/websockets-13.1-py3-none-any.whl", hash = "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f", size = 152134, upload-time = "2024-09-21T17:34:19.904Z" }, +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ]