diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cae7e61..626c995 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,7 +159,8 @@ jobs: --compare-branch=origin/${{ github.base_ref }} \ --fail-under=80 \ --diff-range-notation='..' \ - --exclude '*/portkeydrop/ui/*' \ - --exclude '*/portkeydrop/dialogs/*' \ - --exclude '*/portkeydrop/importers/winscp.py' + --exclude \ + '*/portkeydrop/ui/*' \ + '*/portkeydrop/dialogs/*' \ + '*/portkeydrop/importers/winscp.py' echo "✅ New code meets coverage threshold!" diff --git a/.gitignore b/.gitignore index 4ae1f0e..ae93818 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,6 @@ dist/ .ruff_cache/ .coverage cov.json -uv.lock # Security - credentials and keys .env diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d5629c..c9d42bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ All notable changes to this project will be documented in this file. ### Added - Batch transfers: select multiple local or remote files and folders, then use Ctrl+U, Ctrl+D, or Ctrl+T to queue them together. +- Default sound pack: Portkey Drop now includes built-in transfer, connection, file + operation, and app event sounds with a structured folder layout for custom packs. - FTP connections can now enable explicit SSL with the AUTH SSL command. - Experimental WebDAV connections for basic browse, upload, download, delete, folder creation, and rename workflows. diff --git a/README.md b/README.md index 8f86de5..c1fc103 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ Two side-by-side file browsers: Each pane is a labeled standard list control, so screen readers announce "Local Files" or "Remote Files" when you Tab between them. Use Shift+Arrow or Ctrl+Arrow/Space in a file pane to select multiple items for batch transfers. +## Sound Packs + +Portkey Drop includes a built-in default sound pack with short cues for transfers, connections, file operations, and general app events. Sound packs live under a pack folder with section subfolders, such as `default/transfers/transfer_complete.ogg`, and a `pack.json` manifest maps each event to its sound file. + ## Keyboard Shortcuts | Shortcut | Action | diff --git a/pyproject.toml b/pyproject.toml index 6713f12..de36c1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,8 @@ dependencies = [ "asyncssh>=2.14", "puttykeys", "keyring>=25.0", + "platform-utils>=1.6.0", + "sound_lib @ git+https://github.com/samtupy/sound_lib_macos_fixes.git", "wxPython>=4.2", "prismatoid", "webdavclient3>=3.14", @@ -33,6 +35,13 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/portkeydrop"] +artifacts = [ + "src/portkeydrop/default_soundpacks/**/*.json", + "src/portkeydrop/default_soundpacks/**/*.ogg", +] + +[tool.hatch.metadata] +allow-direct-references = true [tool.ruff] target-version = "py311" diff --git a/src/portkeydrop/app.py b/src/portkeydrop/app.py index a04b00e..27447fa 100644 --- a/src/portkeydrop/app.py +++ b/src/portkeydrop/app.py @@ -57,6 +57,8 @@ save_settings, update_last_local_folder, ) +from portkeydrop.soundpack_paths import ensure_default_soundpack, get_soundpacks_dir +from portkeydrop.soundpacks import SoundPlayer from portkeydrop.sites import Site, SiteManager from portkeydrop.screen_reader import ScreenReaderAnnouncer from portkeydrop.services.updater import ( @@ -135,7 +137,14 @@ def __init__(self) -> None: self._transfer_state_by_id: dict[str, str] = {} self._transfer_progress_by_id: dict[str, int] = {} self._last_failed_transfer: str | None = None + self._exit_sound_played = False self._announcer = ScreenReaderAnnouncer() + self._soundpacks_dir = ensure_default_soundpack(get_soundpacks_dir()) + audio_settings = getattr(self._settings, "audio", None) + self._sound_player = SoundPlayer( + self._soundpacks_dir, + getattr(audio_settings, "sound_pack", "default"), + ) self._restore_transfer_queue() self._remote_filter_text = "" self._local_filter_text = "" @@ -732,6 +741,7 @@ def _on_connect_success(self, client) -> None: else str(client._info.protocol) ) self.log_event(f"Connected to {client._info.host} via {protocol_type}") + self._play_sound_event("connect_success") self._refresh_remote_files() self._toolbar_panel.Hide() self.GetSizer().Layout() @@ -742,6 +752,7 @@ def _on_connect_failure(self, exc: Exception) -> None: self._client = None self._update_status("Disconnected", "") self.log_event(f"Connection failed: {exc}") + self._play_sound_event("connect_failed") wx.MessageBox(f"Connection failed: {exc}", "Error", wx.OK | wx.ICON_ERROR, self) def _on_disconnect(self, event) -> None: @@ -759,12 +770,14 @@ def _on_disconnect(self, event) -> None: self._update_title() if was_connected: self.log_event("Disconnected from server") + self._play_sound_event("disconnect") if not self._toolbar_panel.IsShown(): self._toolbar_panel.Show() self.GetSizer().Layout() self.tb_host.SetFocus() def _on_exit(self, event: wx.CommandEvent) -> None: + self._play_exit_sound_once() self.request_exit() def request_exit(self) -> None: @@ -1638,9 +1651,11 @@ def _delete_remote(self) -> None: self._client.delete(f.path) self._announce(f"Deleted {f.name}") self._update_status("Delete complete.", self._client.cwd) + self._play_sound_event("delete_complete") self._refresh_remote_files() except Exception as e: self._update_status("Delete failed.", self._client.cwd) + self._play_sound_event("delete_failed") wx.MessageBox(f"Delete failed: {e}", "Error", wx.OK | wx.ICON_ERROR, self) def _delete_local(self) -> None: @@ -1654,8 +1669,10 @@ def _delete_local(self) -> None: try: delete_local(f.path) self._announce(f"Deleted {f.name}") + self._play_sound_event("delete_complete") self._refresh_local_files() except Exception as e: + self._play_sound_event("delete_failed") wx.MessageBox(f"Delete failed: {e}", "Error", wx.OK | wx.ICON_ERROR, self) def _on_rename(self, event) -> None: @@ -1680,9 +1697,11 @@ def _rename_remote(self) -> None: self._client.rename(f.path, new_path) self._announce(f"Renamed to {new_name}") self._update_status("Rename complete.", self._client.cwd) + self._play_sound_event("rename_complete") self._refresh_remote_files() except Exception as e: self._update_status("Rename failed.", self._client.cwd) + self._play_sound_event("rename_failed") wx.MessageBox(f"Rename failed: {e}", "Error", wx.OK | wx.ICON_ERROR, self) dlg.Destroy() @@ -1698,8 +1717,10 @@ def _rename_local(self) -> None: try: rename_local(f.path, new_name) self._announce(f"Renamed to {new_name}") + self._play_sound_event("rename_complete") self._refresh_local_files() except Exception as e: + self._play_sound_event("rename_failed") wx.MessageBox(f"Rename failed: {e}", "Error", wx.OK | wx.ICON_ERROR, self) dlg.Destroy() @@ -1723,9 +1744,11 @@ def _mkdir_remote(self) -> None: self._client.mkdir(path) self._announce(f"Created directory {name}") self._update_status("Directory created.", self._client.cwd) + self._play_sound_event("folder_created") self._refresh_remote_files() except Exception as e: self._update_status("Create directory failed.", self._client.cwd) + self._play_sound_event("folder_create_failed") wx.MessageBox( f"Failed to create directory: {e}", "Error", wx.OK | wx.ICON_ERROR, self ) @@ -1740,8 +1763,10 @@ def _mkdir_local(self) -> None: try: mkdir_local(self._local_cwd, name) self._announce(f"Created directory {name}") + self._play_sound_event("folder_created") self._refresh_local_files() except Exception as e: + self._play_sound_event("folder_create_failed") wx.MessageBox( f"Failed to create directory: {e}", "Error", wx.OK | wx.ICON_ERROR, self ) @@ -1805,6 +1830,7 @@ def _on_transfer_update(self, event) -> None: if job.status == TransferStatus.PENDING: latest_status_message = f"{direction_label} queued." + self._play_sound_event("transfer_queued") elif job.status == TransferStatus.IN_PROGRESS: progress_message = self._format_transfer_progress_message( job, direction_label, filename @@ -1812,12 +1838,15 @@ def _on_transfer_update(self, event) -> None: latest_status_message = ( f"{direction_label} in progress..." if state_changed else progress_message ) + if state_changed: + self._play_sound_event("transfer_started") if self._should_announce_transfer_progress(job): self._announce(progress_message) elif job.status == TransferStatus.COMPLETE: latest_status_message = f"{direction_label} complete." self._clear_transfer_progress(job.id) self.log_event(f"{direction_label} complete: {filename}") + self._play_sound_event("transfer_complete") if job.direction == TransferDirection.DOWNLOAD: refresh_local_files = True else: @@ -1828,12 +1857,14 @@ def _on_transfer_update(self, event) -> None: error_msg = job.error or "Unknown error" self.log_event(f"{direction_label} failed: {filename} — {error_msg}") self._announce(f"{direction_label} failed.") + self._play_sound_event("transfer_failed") self._last_failed_transfer = job.id self._retry_last_failed_item.Enable(True) elif job.status == TransferStatus.CANCELLED: latest_status_message = f"{direction_label} cancelled." self._clear_transfer_progress(job.id) self.log_event(f"{direction_label} cancelled: {filename}") + self._play_sound_event("transfer_cancelled") if refresh_local_files: self._refresh_local_files() @@ -1884,6 +1915,7 @@ def _on_settings(self, event: wx.CommandEvent) -> None: self._transfer_service.set_max_workers( self._settings.transfer.concurrent_transfers, ) + self._refresh_sound_player() self.update_check_updates_menu_label() self._start_auto_update_checks() self._sync_tray_icon() @@ -2247,6 +2279,7 @@ def _on_close(self, event) -> None: if event is not None and hasattr(event, "Veto"): event.Veto() return + self._play_exit_sound_once() if self._auto_update_check_timer: self._auto_update_check_timer.Stop() self._destroy_tray_icon() @@ -2277,6 +2310,36 @@ def _announce(self, message: str) -> None: logger.debug("Announcement requested: %s", message) self._announcer.announce(message) + def _refresh_sound_player(self) -> None: + """Refresh the event player after audio settings change.""" + if not hasattr(self, "_soundpacks_dir"): + self._soundpacks_dir = ensure_default_soundpack(get_soundpacks_dir()) + audio_settings = getattr(self._settings, "audio", None) + self._sound_player = SoundPlayer( + self._soundpacks_dir, + getattr(audio_settings, "sound_pack", "default"), + ) + + def _play_sound_event(self, event_key: str) -> bool: + """Play an event sound if enabled and mapped in the active pack.""" + settings = getattr(self, "_settings", None) + audio = getattr(settings, "audio", None) + enabled = bool(getattr(audio, "sound_enabled", True)) + muted = set(getattr(audio, "muted_sound_events", []) or []) + pack = getattr(audio, "sound_pack", "default") + if not hasattr(self, "_sound_player"): + return False + if getattr(self._sound_player, "pack_name", None) != pack: + self._refresh_sound_player() + return self._sound_player.play_event(event_key, enabled=enabled, muted=muted) + + def _play_exit_sound_once(self) -> bool: + """Play the exit sound once for menu and window-close paths.""" + if getattr(self, "_exit_sound_played", False): + return False + self._exit_sound_played = True + return self._play_sound_event("exit") + class PortkeyDropApp(wx.App): """Main wxPython application.""" @@ -2321,4 +2384,5 @@ def OnInit(self) -> bool: frame = MainFrame() frame.Show() self.SetTopWindow(frame) + wx.CallAfter(frame._play_sound_event, "startup") return True diff --git a/src/portkeydrop/default_soundpacks/default/connections/connect_failed.ogg b/src/portkeydrop/default_soundpacks/default/connections/connect_failed.ogg new file mode 100644 index 0000000..dd540b5 Binary files /dev/null and b/src/portkeydrop/default_soundpacks/default/connections/connect_failed.ogg differ diff --git a/src/portkeydrop/default_soundpacks/default/connections/connect_success.ogg b/src/portkeydrop/default_soundpacks/default/connections/connect_success.ogg new file mode 100644 index 0000000..15f2d68 Binary files /dev/null and b/src/portkeydrop/default_soundpacks/default/connections/connect_success.ogg differ diff --git a/src/portkeydrop/default_soundpacks/default/connections/disconnect.ogg b/src/portkeydrop/default_soundpacks/default/connections/disconnect.ogg new file mode 100644 index 0000000..71e5554 Binary files /dev/null and b/src/portkeydrop/default_soundpacks/default/connections/disconnect.ogg differ diff --git a/src/portkeydrop/default_soundpacks/default/file_operations/delete_complete.ogg b/src/portkeydrop/default_soundpacks/default/file_operations/delete_complete.ogg new file mode 100644 index 0000000..8e85d72 Binary files /dev/null and b/src/portkeydrop/default_soundpacks/default/file_operations/delete_complete.ogg differ diff --git a/src/portkeydrop/default_soundpacks/default/file_operations/delete_failed.ogg b/src/portkeydrop/default_soundpacks/default/file_operations/delete_failed.ogg new file mode 100644 index 0000000..f8d109d Binary files /dev/null and b/src/portkeydrop/default_soundpacks/default/file_operations/delete_failed.ogg differ diff --git a/src/portkeydrop/default_soundpacks/default/file_operations/folder_create_failed.ogg b/src/portkeydrop/default_soundpacks/default/file_operations/folder_create_failed.ogg new file mode 100644 index 0000000..e400b43 Binary files /dev/null and b/src/portkeydrop/default_soundpacks/default/file_operations/folder_create_failed.ogg differ diff --git a/src/portkeydrop/default_soundpacks/default/file_operations/folder_created.ogg b/src/portkeydrop/default_soundpacks/default/file_operations/folder_created.ogg new file mode 100644 index 0000000..ab9de48 Binary files /dev/null and b/src/portkeydrop/default_soundpacks/default/file_operations/folder_created.ogg differ diff --git a/src/portkeydrop/default_soundpacks/default/file_operations/rename_complete.ogg b/src/portkeydrop/default_soundpacks/default/file_operations/rename_complete.ogg new file mode 100644 index 0000000..87f92ac Binary files /dev/null and b/src/portkeydrop/default_soundpacks/default/file_operations/rename_complete.ogg differ diff --git a/src/portkeydrop/default_soundpacks/default/file_operations/rename_failed.ogg b/src/portkeydrop/default_soundpacks/default/file_operations/rename_failed.ogg new file mode 100644 index 0000000..8e35224 Binary files /dev/null and b/src/portkeydrop/default_soundpacks/default/file_operations/rename_failed.ogg differ diff --git a/src/portkeydrop/default_soundpacks/default/general/error.ogg b/src/portkeydrop/default_soundpacks/default/general/error.ogg new file mode 100644 index 0000000..9772d65 Binary files /dev/null and b/src/portkeydrop/default_soundpacks/default/general/error.ogg differ diff --git a/src/portkeydrop/default_soundpacks/default/general/exit.ogg b/src/portkeydrop/default_soundpacks/default/general/exit.ogg new file mode 100644 index 0000000..2ae9aab Binary files /dev/null and b/src/portkeydrop/default_soundpacks/default/general/exit.ogg differ diff --git a/src/portkeydrop/default_soundpacks/default/general/notify.ogg b/src/portkeydrop/default_soundpacks/default/general/notify.ogg new file mode 100644 index 0000000..fe888a6 Binary files /dev/null and b/src/portkeydrop/default_soundpacks/default/general/notify.ogg differ diff --git a/src/portkeydrop/default_soundpacks/default/general/startup.ogg b/src/portkeydrop/default_soundpacks/default/general/startup.ogg new file mode 100644 index 0000000..baf6ef6 Binary files /dev/null and b/src/portkeydrop/default_soundpacks/default/general/startup.ogg differ diff --git a/src/portkeydrop/default_soundpacks/default/general/success.ogg b/src/portkeydrop/default_soundpacks/default/general/success.ogg new file mode 100644 index 0000000..68a1a8f Binary files /dev/null and b/src/portkeydrop/default_soundpacks/default/general/success.ogg differ diff --git a/src/portkeydrop/default_soundpacks/default/pack.json b/src/portkeydrop/default_soundpacks/default/pack.json new file mode 100644 index 0000000..f5e2bc8 --- /dev/null +++ b/src/portkeydrop/default_soundpacks/default/pack.json @@ -0,0 +1,27 @@ +{ + "name": "Default", + "author": "Portkey Drop", + "description": "Built-in Portkey Drop sound pack with short, gentle transfer and app cues.", + "version": "1.0.0", + "sounds": { + "transfer_queued": "transfers/transfer_queued.ogg", + "transfer_started": "transfers/transfer_started.ogg", + "transfer_complete": "transfers/transfer_complete.ogg", + "transfer_failed": "transfers/transfer_failed.ogg", + "transfer_cancelled": "transfers/transfer_cancelled.ogg", + "connect_success": "connections/connect_success.ogg", + "connect_failed": "connections/connect_failed.ogg", + "disconnect": "connections/disconnect.ogg", + "delete_complete": "file_operations/delete_complete.ogg", + "delete_failed": "file_operations/delete_failed.ogg", + "rename_complete": "file_operations/rename_complete.ogg", + "rename_failed": "file_operations/rename_failed.ogg", + "folder_created": "file_operations/folder_created.ogg", + "folder_create_failed": "file_operations/folder_create_failed.ogg", + "success": "general/success.ogg", + "error": "general/error.ogg", + "notify": "general/notify.ogg", + "startup": "general/startup.ogg", + "exit": "general/exit.ogg" + } +} diff --git a/src/portkeydrop/default_soundpacks/default/transfers/transfer_cancelled.ogg b/src/portkeydrop/default_soundpacks/default/transfers/transfer_cancelled.ogg new file mode 100644 index 0000000..d9df0ea Binary files /dev/null and b/src/portkeydrop/default_soundpacks/default/transfers/transfer_cancelled.ogg differ diff --git a/src/portkeydrop/default_soundpacks/default/transfers/transfer_complete.ogg b/src/portkeydrop/default_soundpacks/default/transfers/transfer_complete.ogg new file mode 100644 index 0000000..53b1cba Binary files /dev/null and b/src/portkeydrop/default_soundpacks/default/transfers/transfer_complete.ogg differ diff --git a/src/portkeydrop/default_soundpacks/default/transfers/transfer_failed.ogg b/src/portkeydrop/default_soundpacks/default/transfers/transfer_failed.ogg new file mode 100644 index 0000000..6672a8d Binary files /dev/null and b/src/portkeydrop/default_soundpacks/default/transfers/transfer_failed.ogg differ diff --git a/src/portkeydrop/default_soundpacks/default/transfers/transfer_queued.ogg b/src/portkeydrop/default_soundpacks/default/transfers/transfer_queued.ogg new file mode 100644 index 0000000..c6b739b Binary files /dev/null and b/src/portkeydrop/default_soundpacks/default/transfers/transfer_queued.ogg differ diff --git a/src/portkeydrop/default_soundpacks/default/transfers/transfer_started.ogg b/src/portkeydrop/default_soundpacks/default/transfers/transfer_started.ogg new file mode 100644 index 0000000..ad382fa Binary files /dev/null and b/src/portkeydrop/default_soundpacks/default/transfers/transfer_started.ogg differ diff --git a/src/portkeydrop/dialogs/settings.py b/src/portkeydrop/dialogs/settings.py index 4a51762..bbd505c 100644 --- a/src/portkeydrop/dialogs/settings.py +++ b/src/portkeydrop/dialogs/settings.py @@ -8,6 +8,9 @@ from portkeydrop.protocols import SUPPORTED_PROTOCOL_VALUES from portkeydrop.settings import Settings +from portkeydrop.sound_events import SOUND_EVENT_SECTIONS, normalize_known_muted_sound_events +from portkeydrop.soundpack_paths import ensure_default_soundpack, get_soundpacks_dir +from portkeydrop.soundpacks import get_available_sound_packs CheckUpdatesCallback = Callable[[str, object | None], None] @@ -31,6 +34,8 @@ def __init__( self._settings = settings self._on_check_updates = on_check_updates self._spin_controls: list[tuple[wx.SpinCtrl, str]] = [] + self._sound_pack_ids: list[str] = [] + self._audio_event_checks: list[tuple[str, wx.CheckBox]] = [] self._build_ui() self._populate() @@ -51,6 +56,7 @@ def _build_ui(self) -> None: self._build_display_tab() self._build_connection_tab() self._build_updates_tab() + self._build_audio_tab() self._build_speech_tab() root.Add(self.notebook, 1, wx.EXPAND | wx.ALL, 8) @@ -444,6 +450,49 @@ def _build_speech_tab(self) -> None: sizer.AddStretchSpacer(1) self.notebook.AddPage(panel, "Speech") + def _build_audio_tab(self) -> None: + panel, sizer = self._new_tab_panel() + + self.sound_enabled_check = self._add_checkbox_row( + sizer, + wx.CheckBox(panel, label="Enable sound ¬ifications"), + name="Enable sound notifications", + ) + + self.sound_pack_choice = self._add_labeled_row( + panel, + sizer, + label="Sound &pack:", + make_control=lambda p: wx.Choice(p, choices=self._get_sound_pack_labels()), + control_name="Sound pack", + ) + + self.manage_soundpacks_button = self._add_labeled_row( + panel, + sizer, + label="", + make_control=lambda p: wx.Button(p, label="Manage Sound &Packs..."), + control_name="Manage sound packs", + ) + self.manage_soundpacks_button.Bind(wx.EVT_BUTTON, self._on_manage_soundpacks) + + muted_label = wx.StaticText(panel, label="Muted sound events:") + sizer.Add(muted_label, 0, wx.LEFT | wx.RIGHT | wx.TOP, 10) + self._audio_event_checks = [] + for section_title, _description, events in SOUND_EVENT_SECTIONS: + sizer.Add(wx.StaticText(panel, label=section_title), 0, wx.LEFT | wx.RIGHT | wx.TOP, 10) + for event_key, display_name in events: + checkbox = wx.CheckBox(panel, label=display_name) + self._add_checkbox_row( + sizer, + checkbox, + name=f"Mute {display_name}", + ) + self._audio_event_checks.append((event_key, checkbox)) + + sizer.AddStretchSpacer(1) + self.notebook.AddPage(panel, "Audio") + # -- Data binding -------------------------------------------------- def _populate(self) -> None: @@ -486,6 +535,15 @@ def _populate(self) -> None: ) update_channel = getattr(s.app, "update_channel", "stable") self.update_channel_choice.SetSelection(0 if update_channel == "stable" else 1) + # Audio + self.sound_enabled_check.SetValue(getattr(s.audio, "sound_enabled", True)) + sound_pack = getattr(s.audio, "sound_pack", "default") + self.sound_pack_choice.SetSelection( + self._sound_pack_ids.index(sound_pack) if sound_pack in self._sound_pack_ids else 0 + ) + muted_events = set(normalize_known_muted_sound_events(s.audio.muted_sound_events)) + for event_key, checkbox in self._audio_event_checks: + checkbox.SetValue(event_key in muted_events) # Speech self.speech_rate_spin.SetValue(s.speech.rate) self.speech_volume_spin.SetValue(s.speech.volume) @@ -522,6 +580,16 @@ def get_settings(self) -> Settings: s.app.auto_update_enabled = self.auto_update_check.GetValue() s.app.update_check_interval_hours = self.update_interval_spin.GetValue() s.app.update_channel = self.update_channel_choice.GetStringSelection() + s.audio.sound_enabled = self.sound_enabled_check.GetValue() + selected_pack = self.sound_pack_choice.GetSelection() + s.audio.sound_pack = ( + self._sound_pack_ids[selected_pack] + if 0 <= selected_pack < len(self._sound_pack_ids) + else "default" + ) + s.audio.muted_sound_events = normalize_known_muted_sound_events( + event_key for event_key, checkbox in self._audio_event_checks if checkbox.GetValue() + ) s.speech.rate = self.speech_rate_spin.GetValue() s.speech.volume = self.speech_volume_spin.GetValue() @@ -534,3 +602,32 @@ def _on_check_updates_now(self, event: wx.CommandEvent) -> None: return channel = self.update_channel_choice.GetStringSelection() self._on_check_updates(channel, self) + + def _get_sound_pack_labels(self) -> list[str]: + soundpacks_dir = ensure_default_soundpack(get_soundpacks_dir()) + packs = get_available_sound_packs(soundpacks_dir) + ordered = sorted(packs.items(), key=lambda item: item[1].get("name", item[0]).lower()) + self._sound_pack_ids = [pack_id for pack_id, _data in ordered] + return [ + f"{data.get('name', pack_id)} (by {data.get('author', 'Unknown')})" + for pack_id, data in ordered + ] or ["Default (by Portkey Drop)"] + + def _on_manage_soundpacks(self, event: wx.CommandEvent) -> None: + from portkeydrop.dialogs.soundpack_manager import SoundPackManagerDialog + + dialog = SoundPackManagerDialog(self) + dialog.ShowModal() + dialog.Destroy() + current_pack = ( + self._sound_pack_ids[self.sound_pack_choice.GetSelection()] + if 0 <= self.sound_pack_choice.GetSelection() < len(self._sound_pack_ids) + else "default" + ) + labels = self._get_sound_pack_labels() + self.sound_pack_choice.Clear() + for label in labels: + self.sound_pack_choice.Append(label) + self.sound_pack_choice.SetSelection( + self._sound_pack_ids.index(current_pack) if current_pack in self._sound_pack_ids else 0 + ) diff --git a/src/portkeydrop/dialogs/soundpack_manager.py b/src/portkeydrop/dialogs/soundpack_manager.py new file mode 100644 index 0000000..686a4a9 --- /dev/null +++ b/src/portkeydrop/dialogs/soundpack_manager.py @@ -0,0 +1,486 @@ +"""wxPython sound pack manager dialog.""" + +from __future__ import annotations + +import json +import logging +import shutil +import tempfile +import zipfile +from dataclasses import dataclass +from pathlib import Path + +import wx + +from portkeydrop.sound_events import FRIENDLY_SOUND_EVENT_CHOICES +from portkeydrop.soundpack_paths import ensure_default_soundpack, get_soundpacks_dir +from portkeydrop.soundpacks import ( + AUDIO_WILDCARD, + SoundPackInstaller, + get_available_sound_packs, + parse_sound_entry, + play_sound_file, + safe_extractall, + slugify_pack_name, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class SoundPackInfo: + """Information about a sound pack.""" + + pack_id: str + name: str + author: str + description: str + path: Path + sounds: dict + + +class SoundPackManagerDialog(wx.Dialog): + """Dialog for managing local sound packs.""" + + def __init__(self, parent: wx.Window | None, soundpacks_dir: Path | None = None) -> None: + super().__init__( + parent, + title="Sound Pack Manager", + size=(820, 560), + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER, + ) + self.soundpacks_dir = ensure_default_soundpack(soundpacks_dir or get_soundpacks_dir()) + self.installer = SoundPackInstaller(self.soundpacks_dir) + self.sound_packs: dict[str, SoundPackInfo] = {} + self.selected_pack: str | None = None + self._load_sound_packs() + self._build_ui() + self._refresh_pack_list() + self.Bind(wx.EVT_CHAR_HOOK, self._on_char_hook) + self.Centre() + + def _build_ui(self) -> None: + panel = wx.Panel(self) + root = wx.BoxSizer(wx.VERTICAL) + content = wx.BoxSizer(wx.HORIZONTAL) + content.Add(self._create_pack_list_panel(panel), 1, wx.EXPAND | wx.RIGHT, 10) + content.Add(self._create_details_panel(panel), 2, wx.EXPAND) + root.Add(content, 1, wx.EXPAND | wx.ALL, 10) + root.Add(self._create_button_panel(panel), 0, wx.EXPAND | wx.ALL, 10) + panel.SetSizer(root) + + def _create_pack_list_panel(self, parent: wx.Window) -> wx.BoxSizer: + sizer = wx.BoxSizer(wx.VERTICAL) + label = wx.StaticText(parent, label="Available sound packs:") + sizer.Add(label, 0, wx.BOTTOM, 5) + self.pack_listbox = wx.ListBox(parent, style=wx.LB_SINGLE) + self.pack_listbox.SetName("Available sound packs") + self.pack_listbox.Bind(wx.EVT_LISTBOX, self._on_pack_selected) + sizer.Add(self.pack_listbox, 1, wx.EXPAND | wx.BOTTOM, 10) + import_btn = wx.Button(parent, label="Import Sound Pack...") + import_btn.Bind(wx.EVT_BUTTON, self._on_import_pack) + sizer.Add(import_btn, 0, wx.EXPAND) + return sizer + + def _create_details_panel(self, parent: wx.Window) -> wx.BoxSizer: + sizer = wx.BoxSizer(wx.VERTICAL) + self.name_label = wx.StaticText(parent, label="No pack selected") + sizer.Add(self.name_label, 0, wx.BOTTOM, 5) + self.author_label = wx.StaticText(parent, label="") + sizer.Add(self.author_label, 0, wx.BOTTOM, 5) + self.description_label = wx.StaticText(parent, label="") + self.description_label.Wrap(400) + sizer.Add(self.description_label, 0, wx.BOTTOM, 10) + + sizer.Add(wx.StaticText(parent, label="Sounds in this pack:"), 0, wx.BOTTOM, 5) + self.sounds_listbox = wx.ListBox(parent) + self.sounds_listbox.SetName("Sounds in selected pack") + self.sounds_listbox.Bind(wx.EVT_LISTBOX, self._on_sound_selected) + sizer.Add(self.sounds_listbox, 1, wx.EXPAND | wx.BOTTOM, 5) + + preview_row = wx.BoxSizer(wx.HORIZONTAL) + self.preview_btn = wx.Button(parent, label="Preview Selected Sound") + self.preview_btn.Bind(wx.EVT_BUTTON, self._on_preview_sound) + self.preview_btn.Enable(False) + preview_row.Add(self.preview_btn, 0, wx.RIGHT, 5) + preview_row.Add( + wx.StaticText(parent, label="Volume:"), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5 + ) + self.volume_spin = wx.SpinCtrl(parent, min=0, max=100, initial=100, size=(70, -1)) + self.volume_spin.SetName("Selected sound volume") + preview_row.Add(self.volume_spin, 0, wx.RIGHT, 5) + self.set_volume_btn = wx.Button(parent, label="Set Volume") + self.set_volume_btn.Bind(wx.EVT_BUTTON, self._on_set_volume) + self.set_volume_btn.Enable(False) + preview_row.Add(self.set_volume_btn, 0) + sizer.Add(preview_row, 0, wx.BOTTOM, 10) + + mapping_box = wx.StaticBox(parent, label="Sound mappings") + mapping_sizer = wx.StaticBoxSizer(mapping_box, wx.VERTICAL) + category_row = wx.BoxSizer(wx.HORIZONTAL) + category_row.Add( + wx.StaticText(parent, label="Event:"), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5 + ) + self.event_choice = wx.Choice( + parent, choices=[name for name, _key in FRIENDLY_SOUND_EVENT_CHOICES] + ) + self.event_choice.SetName("Sound event") + self.event_choice.Bind(wx.EVT_CHOICE, self._on_event_changed) + category_row.Add(self.event_choice, 1, wx.RIGHT, 5) + self.mapping_file_text = wx.TextCtrl(parent, style=wx.TE_READONLY) + self.mapping_file_text.SetName("Mapped sound file") + category_row.Add(self.mapping_file_text, 1, wx.RIGHT, 5) + browse_btn = wx.Button(parent, label="Choose Sound...") + browse_btn.Bind(wx.EVT_BUTTON, self._on_browse_mapping) + category_row.Add(browse_btn, 0) + mapping_sizer.Add(category_row, 0, wx.EXPAND | wx.ALL, 5) + sizer.Add(mapping_sizer, 0, wx.EXPAND) + return sizer + + def _create_button_panel(self, parent: wx.Window) -> wx.BoxSizer: + sizer = wx.BoxSizer(wx.HORIZONTAL) + self.create_btn = wx.Button(parent, label="Create Sound Pack...") + self.create_btn.Bind(wx.EVT_BUTTON, self._on_create_pack) + sizer.Add(self.create_btn, 0, wx.RIGHT, 5) + self.duplicate_btn = wx.Button(parent, label="Duplicate") + self.duplicate_btn.Bind(wx.EVT_BUTTON, self._on_duplicate_pack) + self.duplicate_btn.Enable(False) + sizer.Add(self.duplicate_btn, 0, wx.RIGHT, 5) + self.edit_btn = wx.Button(parent, label="Edit...") + self.edit_btn.Bind(wx.EVT_BUTTON, self._on_edit_pack) + self.edit_btn.Enable(False) + sizer.Add(self.edit_btn, 0, wx.RIGHT, 5) + self.delete_btn = wx.Button(parent, label="Delete") + self.delete_btn.Bind(wx.EVT_BUTTON, self._on_delete_pack) + self.delete_btn.Enable(False) + sizer.Add(self.delete_btn, 0, wx.RIGHT, 5) + self.export_btn = wx.Button(parent, label="Export...") + self.export_btn.Bind(wx.EVT_BUTTON, self._on_export_pack) + self.export_btn.Enable(False) + sizer.Add(self.export_btn, 0) + sizer.AddStretchSpacer() + close_btn = wx.Button(parent, wx.ID_CLOSE, label="Close") + close_btn.Bind(wx.EVT_BUTTON, self._on_close) + sizer.Add(close_btn, 0) + return sizer + + def _load_sound_packs(self) -> None: + self.sound_packs.clear() + for pack_id, data in get_available_sound_packs(self.soundpacks_dir).items(): + self.sound_packs[pack_id] = SoundPackInfo( + pack_id=pack_id, + name=data.get("name", pack_id), + author=data.get("author", "Unknown"), + description=data.get("description", ""), + path=Path(data["path"]), + sounds=data.get("sounds", {}), + ) + + def _refresh_pack_list(self) -> None: + self.pack_listbox.Clear() + for pack_id, info in sorted(self.sound_packs.items(), key=lambda item: item[1].name): + self.pack_listbox.Append(f"{info.name} (by {info.author})", pack_id) + if self.pack_listbox.GetCount() > 0: + self.pack_listbox.SetSelection(0) + self._on_pack_selected(None) + + def _on_pack_selected(self, event) -> None: + selection = self.pack_listbox.GetSelection() + self.selected_pack = ( + None if selection == wx.NOT_FOUND else self.pack_listbox.GetClientData(selection) + ) + self._update_pack_details() + + def _update_pack_details(self) -> None: + if not self.selected_pack or self.selected_pack not in self.sound_packs: + self.name_label.SetLabel("No pack selected") + self.author_label.SetLabel("") + self.description_label.SetLabel("") + self.sounds_listbox.Clear() + for button in ( + self.preview_btn, + self.duplicate_btn, + self.edit_btn, + self.delete_btn, + self.export_btn, + self.set_volume_btn, + ): + button.Enable(False) + return + + info = self.sound_packs[self.selected_pack] + self.name_label.SetLabel(info.name) + self.author_label.SetLabel(f"Author: {info.author}") + self.description_label.SetLabel(info.description or "No description available") + self.description_label.Wrap(400) + self.sounds_listbox.Clear() + sounds, volumes = self._read_pack_sounds(info) + for event_key, sound_entry in sounds.items(): + filename, volume = parse_sound_entry(sound_entry, event_key, volumes) + status = "OK" if (info.path / filename).exists() else "Missing" + label = self._friendly_name(event_key) + self.sounds_listbox.Append( + f"{label} ({filename}) at {int(volume * 100)} percent - {status}", + (event_key, filename, volume), + ) + self.duplicate_btn.Enable(True) + self.edit_btn.Enable(True) + self.delete_btn.Enable(self.selected_pack != "default") + self.export_btn.Enable(True) + self._on_event_changed(None) + + def _on_sound_selected(self, event) -> None: + selection = self.sounds_listbox.GetSelection() + if selection == wx.NOT_FOUND or not self.selected_pack: + self.preview_btn.Enable(False) + self.set_volume_btn.Enable(False) + return + data = self.sounds_listbox.GetClientData(selection) + if not data: + return + _event_key, filename, volume = data + exists = (self.sound_packs[self.selected_pack].path / filename).exists() + self.volume_spin.SetValue(int(volume * 100)) + self.preview_btn.Enable(exists) + self.set_volume_btn.Enable(exists) + + def _on_preview_sound(self, event) -> None: + if not self.selected_pack: + return + selection = self.sounds_listbox.GetSelection() + if selection == wx.NOT_FOUND: + return + data = self.sounds_listbox.GetClientData(selection) + if not data: + return + _event_key, filename, _volume = data + play_sound_file( + self.sound_packs[self.selected_pack].path / filename, + volume=self.volume_spin.GetValue() / 100.0, + ) + + def _on_event_changed(self, event) -> None: + if not self.selected_pack or self.event_choice.GetSelection() == wx.NOT_FOUND: + self.mapping_file_text.SetValue("") + return + event_key = FRIENDLY_SOUND_EVENT_CHOICES[self.event_choice.GetSelection()][1] + sounds, volumes = self._read_pack_sounds(self.sound_packs[self.selected_pack]) + entry = sounds.get(event_key, "") + if entry: + filename, volume = parse_sound_entry(entry, event_key, volumes) + self.mapping_file_text.SetValue(filename) + self.volume_spin.SetValue(int(volume * 100)) + else: + self.mapping_file_text.SetValue("") + self.volume_spin.SetValue(100) + + def _on_browse_mapping(self, event) -> None: + if not self.selected_pack: + return + selection = self.event_choice.GetSelection() + if selection == wx.NOT_FOUND: + wx.MessageBox("Please select an event first.", "No Event", wx.OK | wx.ICON_INFORMATION) + return + event_key = FRIENDLY_SOUND_EVENT_CHOICES[selection][1] + with wx.FileDialog( + self, + "Select Audio File", + wildcard=AUDIO_WILDCARD, + style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST, + ) as dialog: + if dialog.ShowModal() != wx.ID_OK: + return + src_path = Path(dialog.GetPath()) + info = self.sound_packs[self.selected_pack] + dest_path = info.path / src_path.name + if src_path.resolve() != dest_path.resolve(): + shutil.copy2(src_path, dest_path) + self._write_sound_mapping( + info, event_key, src_path.name, self.volume_spin.GetValue() / 100.0 + ) + self._load_sound_packs() + self._update_pack_details() + + def _on_set_volume(self, event) -> None: + if not self.selected_pack: + return + selection = self.sounds_listbox.GetSelection() + if selection == wx.NOT_FOUND: + return + event_key, filename, _volume = self.sounds_listbox.GetClientData(selection) + info = self.sound_packs[self.selected_pack] + self._write_sound_mapping(info, event_key, filename, self.volume_spin.GetValue() / 100.0) + self._load_sound_packs() + self._update_pack_details() + + def _on_import_pack(self, event) -> None: + with wx.FileDialog( + self, + "Select Sound Pack ZIP File", + wildcard="ZIP files (*.zip)|*.zip", + style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST, + ) as dialog: + if dialog.ShowModal() != wx.ID_OK: + return + zip_path = Path(dialog.GetPath()) + try: + with zipfile.ZipFile(zip_path, "r") as zip_file: + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + safe_extractall(zip_file, temp_path) + pack_json = next(iter(temp_path.rglob("pack.json")), None) + if pack_json is None: + wx.MessageBox( + "Invalid sound pack: missing pack.json.", + "Import Error", + wx.OK | wx.ICON_ERROR, + ) + return + data = json.loads(pack_json.read_text(encoding="utf-8")) + pack_name = data.get("name", zip_path.stem) + pack_id = slugify_pack_name(pack_name) + pack_dir = self.soundpacks_dir / pack_id + if pack_dir.exists(): + result = wx.MessageBox( + f"A sound pack named '{pack_name}' already exists. Overwrite?", + "Pack Exists", + wx.YES_NO | wx.ICON_QUESTION, + ) + if result != wx.YES: + return + shutil.rmtree(pack_dir) + shutil.copytree(pack_json.parent, pack_dir) + self._load_sound_packs() + self._refresh_pack_list() + except Exception as exc: + logger.error("Failed to import sound pack: %s", exc) + wx.MessageBox( + f"Failed to import sound pack: {exc}", "Import Error", wx.OK | wx.ICON_ERROR + ) + + def _on_create_pack(self, event) -> None: + from portkeydrop.dialogs.soundpack_wizard import SoundPackWizardDialog + + wizard = SoundPackWizardDialog(self, self.soundpacks_dir) + result = wizard.ShowModal() + created_pack_id = wizard.created_pack_id + wizard.Destroy() + if result == wx.ID_OK and created_pack_id: + self._load_sound_packs() + self._refresh_pack_list() + self._select_pack(created_pack_id) + + def _on_duplicate_pack(self, event) -> None: + if not self.selected_pack: + return + info = self.sound_packs[self.selected_pack] + candidate = f"{self.selected_pack}_copy" + suffix = 2 + while (self.soundpacks_dir / candidate).exists(): + candidate = f"{self.selected_pack}_copy{suffix}" + suffix += 1 + shutil.copytree(info.path, self.soundpacks_dir / candidate) + pack_json = self.soundpacks_dir / candidate / "pack.json" + data = json.loads(pack_json.read_text(encoding="utf-8")) + data["name"] = f"{info.name} (Copy)" + pack_json.write_text(json.dumps(data, indent=2), encoding="utf-8") + self._load_sound_packs() + self._refresh_pack_list() + self._select_pack(candidate) + + def _on_edit_pack(self, event) -> None: + if not self.selected_pack: + return + info = self.sound_packs[self.selected_pack] + dialog = wx.TextEntryDialog(self, "Enter a new display name:", "Edit Sound Pack", info.name) + if dialog.ShowModal() == wx.ID_OK: + data_path = info.path / "pack.json" + data = json.loads(data_path.read_text(encoding="utf-8")) + data["name"] = dialog.GetValue().strip() or info.name + data_path.write_text(json.dumps(data, indent=2), encoding="utf-8") + self._load_sound_packs() + self._refresh_pack_list() + self._select_pack(self.selected_pack) + dialog.Destroy() + + def _on_delete_pack(self, event) -> None: + if not self.selected_pack or self.selected_pack == "default": + return + info = self.sound_packs[self.selected_pack] + result = wx.MessageBox( + f"Delete '{info.name}'? This cannot be undone.", + "Delete Sound Pack", + wx.YES_NO | wx.ICON_WARNING, + ) + if result != wx.YES: + return + shutil.rmtree(info.path) + self.selected_pack = None + self._load_sound_packs() + self._refresh_pack_list() + + def _on_export_pack(self, event) -> None: + if not self.selected_pack: + return + with wx.FileDialog( + self, + "Export Sound Pack", + defaultFile=f"{self.selected_pack}.zip", + wildcard="ZIP files (*.zip)|*.zip", + style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT, + ) as dialog: + if dialog.ShowModal() != wx.ID_OK: + return + ok, message = self.installer.export_pack(self.selected_pack, Path(dialog.GetPath())) + wx.MessageBox( + message, "Export Sound Pack", wx.OK | (wx.ICON_INFORMATION if ok else wx.ICON_ERROR) + ) + + def _select_pack(self, pack_id: str) -> None: + for index in range(self.pack_listbox.GetCount()): + if self.pack_listbox.GetClientData(index) == pack_id: + self.pack_listbox.SetSelection(index) + self._on_pack_selected(None) + break + + @staticmethod + def _friendly_name(event_key: str) -> str: + for display_name, key in FRIENDLY_SOUND_EVENT_CHOICES: + if key == event_key: + return display_name + return event_key.replace("_", " ").title() + + @staticmethod + def _read_pack_sounds(info: SoundPackInfo) -> tuple[dict, dict]: + try: + data = json.loads((info.path / "pack.json").read_text(encoding="utf-8")) + except Exception: + return {}, {} + sounds = data.get("sounds", {}) + volumes = data.get("volumes", {}) + return sounds if isinstance(sounds, dict) else {}, volumes if isinstance( + volumes, dict + ) else {} + + @staticmethod + def _write_sound_mapping( + info: SoundPackInfo, event_key: str, filename: str, volume: float + ) -> None: + data_path = info.path / "pack.json" + data = json.loads(data_path.read_text(encoding="utf-8")) + sounds = data.get("sounds", {}) + if volume < 1.0: + sounds[event_key] = {"file": filename, "volume": max(0.0, min(1.0, volume))} + else: + sounds[event_key] = filename + data["sounds"] = sounds + data_path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + def _on_char_hook(self, event: wx.KeyEvent) -> None: + if event.GetKeyCode() == wx.WXK_ESCAPE: + self._on_close(event) + return + event.Skip() + + def _on_close(self, event) -> None: + self.EndModal(wx.ID_CLOSE) diff --git a/src/portkeydrop/dialogs/soundpack_wizard.py b/src/portkeydrop/dialogs/soundpack_wizard.py new file mode 100644 index 0000000..dbd9fae --- /dev/null +++ b/src/portkeydrop/dialogs/soundpack_wizard.py @@ -0,0 +1,366 @@ +"""wxPython sound pack creation wizard.""" + +from __future__ import annotations + +import contextlib +import json +import shutil +import tempfile +from dataclasses import dataclass, field +from pathlib import Path + +import wx + +from portkeydrop.sound_events import FRIENDLY_SOUND_EVENT_CHOICES +from portkeydrop.soundpacks import AUDIO_WILDCARD, play_sound_file, slugify_pack_name + + +@dataclass +class WizardState: + """State container for the wizard.""" + + pack_name: str = "" + author: str = "" + description: str = "" + selected_event_keys: list[str] = field(default_factory=list) + sound_mappings: dict[str, str] = field(default_factory=dict) + + +class SoundPackWizardDialog(wx.Dialog): + """Wizard dialog for creating a new sound pack.""" + + def __init__(self, parent: wx.Window, soundpacks_dir: Path) -> None: + super().__init__( + parent, + title="Create Sound Pack", + size=(650, 550), + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER, + ) + self.soundpacks_dir = soundpacks_dir + self.current_step = 1 + self.total_steps = 4 + self.state = WizardState() + self.created_pack_id: str | None = None + self.staging_dir = Path(tempfile.mkdtemp(prefix="pkd_soundpack_wizard_")) + + self._build_shell() + self.Bind(wx.EVT_CHAR_HOOK, self._on_char_hook) + self._render_step() + self.Centre() + + def _build_shell(self) -> None: + self.panel = wx.Panel(self) + main_sizer = wx.BoxSizer(wx.VERTICAL) + + self.header_label = wx.StaticText(self.panel, label="") + main_sizer.Add(self.header_label, 0, wx.ALL, 10) + + self.content_panel = wx.Panel(self.panel) + self.content_sizer = wx.BoxSizer(wx.VERTICAL) + self.content_panel.SetSizer(self.content_sizer) + main_sizer.Add(self.content_panel, 1, wx.EXPAND | wx.LEFT | wx.RIGHT, 10) + + nav_sizer = wx.BoxSizer(wx.HORIZONTAL) + nav_sizer.AddStretchSpacer() + self.prev_btn = wx.Button(self.panel, label="< Previous") + self.prev_btn.Bind(wx.EVT_BUTTON, self._go_previous) + nav_sizer.Add(self.prev_btn, 0, wx.RIGHT, 5) + self.next_btn = wx.Button(self.panel, label="Next >") + self.next_btn.Bind(wx.EVT_BUTTON, self._go_next) + nav_sizer.Add(self.next_btn, 0, wx.RIGHT, 5) + self.cancel_btn = wx.Button(self.panel, wx.ID_CANCEL, label="Cancel") + self.cancel_btn.Bind(wx.EVT_BUTTON, self._on_cancel) + nav_sizer.Add(self.cancel_btn, 0) + + main_sizer.Add(nav_sizer, 0, wx.EXPAND | wx.ALL, 10) + self.panel.SetSizer(main_sizer) + + def _render_step(self) -> None: + titles = { + 1: "Pack Details", + 2: "Select Events", + 3: "Assign Sounds", + 4: "Preview and Finalize", + } + self.header_label.SetLabel( + f"Step {self.current_step} of {self.total_steps}: {titles[self.current_step]}" + ) + self.prev_btn.Enable(self.current_step > 1) + self.next_btn.SetLabel("Create Pack" if self.current_step == self.total_steps else "Next >") + self.content_sizer.Clear(True) + builders = { + 1: self._build_step1, + 2: self._build_step2, + 3: self._build_step3, + 4: self._build_step4, + } + builders[self.current_step]() + self.content_panel.Layout() + self.panel.Layout() + + def _build_step1(self) -> None: + self.content_sizer.Add( + wx.StaticText(self.content_panel, label="Pack name:"), 0, wx.BOTTOM, 3 + ) + self.name_input = wx.TextCtrl(self.content_panel, value=self.state.pack_name) + self.name_input.SetName("Sound pack name") + self.content_sizer.Add(self.name_input, 0, wx.EXPAND | wx.BOTTOM, 10) + + self.content_sizer.Add(wx.StaticText(self.content_panel, label="Author:"), 0, wx.BOTTOM, 3) + self.author_input = wx.TextCtrl(self.content_panel, value=self.state.author) + self.author_input.SetName("Sound pack author") + self.content_sizer.Add(self.author_input, 0, wx.EXPAND | wx.BOTTOM, 10) + + self.content_sizer.Add( + wx.StaticText(self.content_panel, label="Description:"), 0, wx.BOTTOM, 3 + ) + self.desc_input = wx.TextCtrl( + self.content_panel, + value=self.state.description, + style=wx.TE_MULTILINE, + size=(-1, 100), + ) + self.desc_input.SetName("Sound pack description") + self.content_sizer.Add(self.desc_input, 1, wx.EXPAND | wx.BOTTOM, 10) + self.name_input.SetFocus() + + def _build_step2(self) -> None: + self.content_sizer.Add( + wx.StaticText(self.content_panel, label="Choose the events you want sounds for:"), + 0, + wx.BOTTOM, + 10, + ) + scroll = wx.ScrolledWindow(self.content_panel, size=(-1, 300)) + scroll_sizer = wx.BoxSizer(wx.VERTICAL) + self.event_checks: list[tuple[str, wx.CheckBox]] = [] + for display_name, event_key in FRIENDLY_SOUND_EVENT_CHOICES: + checkbox = wx.CheckBox(scroll, label=display_name) + checkbox.SetName(f"{display_name} sound event") + checkbox.SetValue(event_key in self.state.selected_event_keys) + scroll_sizer.Add(checkbox, 0, wx.BOTTOM, 5) + self.event_checks.append((event_key, checkbox)) + scroll.SetSizer(scroll_sizer) + scroll.SetScrollRate(5, 5) + self.content_sizer.Add(scroll, 1, wx.EXPAND | wx.BOTTOM, 10) + + button_sizer = wx.BoxSizer(wx.HORIZONTAL) + common_btn = wx.Button(self.content_panel, label="Select Common") + common_btn.Bind(wx.EVT_BUTTON, self._select_common_events) + button_sizer.Add(common_btn, 0, wx.RIGHT, 5) + clear_btn = wx.Button(self.content_panel, label="Clear All") + clear_btn.Bind(wx.EVT_BUTTON, self._clear_all_events) + button_sizer.Add(clear_btn, 0) + self.content_sizer.Add(button_sizer, 0) + + def _select_common_events(self, event) -> None: + common = { + "transfer_queued", + "transfer_started", + "transfer_complete", + "transfer_failed", + "connect_success", + "connect_failed", + "success", + "error", + } + for key, checkbox in self.event_checks: + checkbox.SetValue(key in common) + + def _clear_all_events(self, event) -> None: + for _key, checkbox in self.event_checks: + checkbox.SetValue(False) + + def _build_step3(self) -> None: + self.content_sizer.Add( + wx.StaticText(self.content_panel, label="Assign a sound file to each selected event."), + 0, + wx.BOTTOM, + 10, + ) + scroll = wx.ScrolledWindow(self.content_panel, size=(-1, 350)) + grid = wx.FlexGridSizer(cols=3, hgap=5, vgap=5) + grid.AddGrowableCol(1, 1) + self.mapping_controls: list[tuple[str, wx.TextCtrl]] = [] + for key in self.state.selected_event_keys: + friendly = self._friendly_name(key) + grid.Add(wx.StaticText(scroll, label=f"{friendly}:"), 0, wx.ALIGN_CENTER_VERTICAL) + file_ctrl = wx.TextCtrl(scroll, style=wx.TE_READONLY) + file_ctrl.SetName(f"{friendly} sound file") + existing = self.state.sound_mappings.get(key) + if existing: + file_ctrl.SetValue(Path(existing).name) + grid.Add(file_ctrl, 1, wx.EXPAND) + choose_btn = wx.Button(scroll, label="Choose...") + choose_btn.Bind( + wx.EVT_BUTTON, + lambda evt, event_key=key, ctrl=file_ctrl: self._choose_sound_file(event_key, ctrl), + ) + grid.Add(choose_btn, 0) + self.mapping_controls.append((key, file_ctrl)) + scroll.SetSizer(grid) + scroll.SetScrollRate(5, 5) + self.content_sizer.Add(scroll, 1, wx.EXPAND) + + def _choose_sound_file(self, key: str, file_ctrl: wx.TextCtrl) -> None: + with wx.FileDialog( + self, + f"Choose sound for {self._friendly_name(key)}", + wildcard=AUDIO_WILDCARD, + style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST, + ) as dialog: + if dialog.ShowModal() != wx.ID_OK: + return + src = Path(dialog.GetPath()) + dest = self.staging_dir / src.name + if src.resolve() != dest.resolve(): + shutil.copy2(src, dest) + self.state.sound_mappings[key] = str(dest) + file_ctrl.SetValue(dest.name) + + def _build_step4(self) -> None: + assigned = len( + [key for key in self.state.selected_event_keys if key in self.state.sound_mappings] + ) + self.content_sizer.Add( + wx.StaticText( + self.content_panel, + label=f"Sounds assigned: {assigned} of {len(self.state.selected_event_keys)}", + ), + 0, + wx.BOTTOM, + 10, + ) + scroll = wx.ScrolledWindow(self.content_panel, size=(-1, 300)) + grid = wx.FlexGridSizer(cols=3, hgap=5, vgap=5) + grid.AddGrowableCol(1, 1) + for key in self.state.selected_event_keys: + grid.Add( + wx.StaticText(scroll, label=f"{self._friendly_name(key)}:"), + 0, + wx.ALIGN_CENTER_VERTICAL, + ) + file_name = ( + Path(self.state.sound_mappings[key]).name + if key in self.state.sound_mappings + else "(none)" + ) + grid.Add(wx.StaticText(scroll, label=file_name), 1, wx.EXPAND) + preview_btn = wx.Button(scroll, label="Preview") + preview_btn.Enable(key in self.state.sound_mappings) + preview_btn.Bind( + wx.EVT_BUTTON, lambda evt, event_key=key: self._preview_sound(event_key) + ) + grid.Add(preview_btn, 0) + scroll.SetSizer(grid) + scroll.SetScrollRate(5, 5) + self.content_sizer.Add(scroll, 1, wx.EXPAND) + + def _preview_sound(self, key: str) -> None: + src = self.state.sound_mappings.get(key) + if src: + play_sound_file(Path(src)) + + def _validate_current_step(self) -> bool: + if self.current_step == 1: + self.state.pack_name = self.name_input.GetValue().strip() + self.state.author = self.author_input.GetValue().strip() + self.state.description = self.desc_input.GetValue().strip() + if not self.state.pack_name: + wx.MessageBox("Please enter a pack name.", "Missing Name", wx.OK | wx.ICON_WARNING) + return False + elif self.current_step == 2: + self.state.selected_event_keys = [ + key for key, checkbox in self.event_checks if checkbox.GetValue() + ] + if not self.state.selected_event_keys: + wx.MessageBox( + "Please select at least one event.", + "No Selection", + wx.OK | wx.ICON_WARNING, + ) + return False + return True + + def _go_previous(self, event) -> None: + if self.current_step > 1: + self.current_step -= 1 + self._render_step() + + def _go_next(self, event) -> None: + if not self._validate_current_step(): + return + if self.current_step < self.total_steps: + self.current_step += 1 + self._render_step() + return + self._create_pack() + + def _create_pack(self) -> None: + pack_id = slugify_pack_name(self.state.pack_name) + candidate = pack_id + suffix = 2 + while (self.soundpacks_dir / candidate).exists(): + candidate = f"{pack_id}_{suffix}" + suffix += 1 + pack_dir = self.soundpacks_dir / candidate + pack_dir.mkdir(parents=True) + + sounds_mapping: dict[str, str] = {} + for key, src_path_str in self.state.sound_mappings.items(): + src_path = Path(src_path_str) + if src_path.exists(): + dest = pack_dir / src_path.name + shutil.copy2(src_path, dest) + sounds_mapping[key] = src_path.name + + pack_data = { + "name": self.state.pack_name, + "author": self.state.author or "Unknown", + "description": self.state.description, + "version": "1.0.0", + "sounds": sounds_mapping, + } + (pack_dir / "pack.json").write_text(json.dumps(pack_data, indent=2), encoding="utf-8") + self.created_pack_id = candidate + with contextlib.suppress(Exception): + shutil.rmtree(self.staging_dir) + wx.MessageBox( + f"Sound pack '{self.state.pack_name}' created successfully.", + "Pack Created", + wx.OK | wx.ICON_INFORMATION, + ) + self.EndModal(wx.ID_OK) + + def _on_char_hook(self, event: wx.KeyEvent) -> None: + if event.GetKeyCode() == wx.WXK_ESCAPE: + self._on_cancel(event) + return + event.Skip() + + def _on_cancel(self, event) -> None: + has_changes = bool( + self.state.pack_name + or self.state.author + or self.state.description + or self.state.selected_event_keys + or self.state.sound_mappings + ) + if has_changes: + result = wx.MessageBox( + "Discard changes and close the wizard?", + "Cancel Wizard", + wx.YES_NO | wx.ICON_QUESTION, + ) + if result != wx.YES: + return + with contextlib.suppress(Exception): + shutil.rmtree(self.staging_dir) + self.EndModal(wx.ID_CANCEL) + + @staticmethod + def _friendly_name(event_key: str) -> str: + for display_name, key in FRIENDLY_SOUND_EVENT_CHOICES: + if key == event_key: + return display_name + return event_key.replace("_", " ").title() diff --git a/src/portkeydrop/settings.py b/src/portkeydrop/settings.py index 6a659cc..6761224 100644 --- a/src/portkeydrop/settings.py +++ b/src/portkeydrop/settings.py @@ -8,6 +8,10 @@ from pathlib import Path from portkeydrop.portable import get_config_dir +from portkeydrop.sound_events import ( + DEFAULT_MUTED_SOUND_EVENTS, + normalize_known_muted_sound_events, +) logger = logging.getLogger(__name__) @@ -52,6 +56,13 @@ class SpeechSettings: verbosity: str = "normal" # minimal, normal, verbose +@dataclass +class AudioSettings: + sound_enabled: bool = True + sound_pack: str = "default" + muted_sound_events: list[str] = field(default_factory=lambda: list(DEFAULT_MUTED_SOUND_EVENTS)) + + @dataclass class AppSettings: remember_last_local_folder_on_startup: bool = True @@ -69,6 +80,7 @@ class Settings: display: DisplaySettings = field(default_factory=DisplaySettings) connection: ConnectionDefaults = field(default_factory=ConnectionDefaults) speech: SpeechSettings = field(default_factory=SpeechSettings) + audio: AudioSettings = field(default_factory=AudioSettings) app: AppSettings = field(default_factory=AppSettings) @@ -163,6 +175,13 @@ def _dict_to_settings(data: dict) -> Settings: if k in SpeechSettings.__dataclass_fields__ } ), + audio=AudioSettings( + **{ + k: (normalize_known_muted_sound_events(v) if k == "muted_sound_events" else v) + for k, v in data.get("audio", {}).items() + if k in AudioSettings.__dataclass_fields__ + } + ), app=AppSettings( **{ k: v diff --git a/src/portkeydrop/sound_events.py b/src/portkeydrop/sound_events.py new file mode 100644 index 0000000..2206d72 --- /dev/null +++ b/src/portkeydrop/sound_events.py @@ -0,0 +1,91 @@ +"""Shared sound event metadata for Portkey Drop.""" + +from __future__ import annotations + +from collections.abc import Collection +from itertools import chain + +DEFAULT_MUTED_SOUND_EVENTS: tuple[str, ...] = () + +SOUND_EVENT_SECTIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...] = ( + ( + "Transfers", + "File transfer queue and result sounds.", + ( + ("transfer_queued", "Transfer queued"), + ("transfer_started", "Transfer started"), + ("transfer_complete", "Transfer complete"), + ("transfer_failed", "Transfer failed"), + ("transfer_cancelled", "Transfer cancelled"), + ), + ), + ( + "Connections", + "Server connection lifecycle sounds.", + ( + ("connect_success", "Connected"), + ("connect_failed", "Connection failed"), + ("disconnect", "Disconnected"), + ), + ), + ( + "File operations", + "Remote and local file operation result sounds.", + ( + ("delete_complete", "Delete complete"), + ("delete_failed", "Delete failed"), + ("rename_complete", "Rename complete"), + ("rename_failed", "Rename failed"), + ("folder_created", "Folder created"), + ("folder_create_failed", "Folder creation failed"), + ), + ), + ( + "General", + "General application feedback sounds.", + ( + ("success", "General success"), + ("error", "General error"), + ("notify", "General notification"), + ("startup", "App startup"), + ("exit", "App exit"), + ), + ), +) + +USER_MUTABLE_SOUND_EVENTS: tuple[tuple[str, str], ...] = tuple( + chain.from_iterable(events for _title, _description, events in SOUND_EVENT_SECTIONS) +) + +USER_MUTABLE_SOUND_EVENT_KEYS: frozenset[str] = frozenset( + event_key for event_key, _label in USER_MUTABLE_SOUND_EVENTS +) + +FRIENDLY_SOUND_EVENT_CHOICES: tuple[tuple[str, str], ...] = tuple( + (label, event_key) for event_key, label in USER_MUTABLE_SOUND_EVENTS +) + + +def normalize_muted_sound_events(events: Collection[str] | None) -> list[str]: + """Normalize muted event names while preserving order.""" + if not events: + return [] + + normalized: list[str] = [] + seen: set[str] = set() + for item in events: + event = str(item).strip() + if not event or event in seen: + continue + seen.add(event) + normalized.append(event) + return normalized + + +def normalize_known_muted_sound_events(events: Collection[str] | None) -> list[str]: + """Normalize muted events and drop unknown keys from the shared catalog.""" + return [ + event + for event in normalize_muted_sound_events(events) + if event in USER_MUTABLE_SOUND_EVENT_KEYS + ] diff --git a/src/portkeydrop/soundpack_paths.py b/src/portkeydrop/soundpack_paths.py new file mode 100644 index 0000000..138853c --- /dev/null +++ b/src/portkeydrop/soundpack_paths.py @@ -0,0 +1,66 @@ +"""Sound pack path helpers.""" + +from __future__ import annotations + +import json +from importlib import resources +from pathlib import Path + +from portkeydrop.portable import get_config_dir + + +def get_soundpacks_dir(config_dir: Path | None = None) -> Path: + """Return the writable soundpacks directory.""" + return (config_dir or get_config_dir()) / "soundpacks" + + +def _should_replace_pack_json(pack_json: Path) -> bool: + """Return whether an existing default pack manifest is still the old placeholder.""" + if not pack_json.exists(): + return True + try: + data = json.loads(pack_json.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + return data.get("sounds") == {} + + +def _copy_pack_resource(resource, target_dir: Path, *, replace_pack_json: bool) -> None: + """Copy packaged default sound pack files without overwriting user-edited assets.""" + for child in resource.iterdir(): + target = target_dir / child.name + if child.is_dir(): + target.mkdir(parents=True, exist_ok=True) + _copy_pack_resource(child, target, replace_pack_json=replace_pack_json) + continue + if target.exists() and not (child.name == "pack.json" and replace_pack_json): + continue + target.write_bytes(child.read_bytes()) + + +def ensure_default_soundpack(soundpacks_dir: Path | None = None) -> Path: + """Ensure the built-in default pack is available in the writable packs directory.""" + base_dir = soundpacks_dir or get_soundpacks_dir() + default_dir = base_dir / "default" + default_dir.mkdir(parents=True, exist_ok=True) + pack_json = default_dir / "pack.json" + replace_pack_json = _should_replace_pack_json(pack_json) + packaged_default = resources.files("portkeydrop").joinpath("default_soundpacks", "default") + if packaged_default.is_dir(): + _copy_pack_resource(packaged_default, default_dir, replace_pack_json=replace_pack_json) + elif replace_pack_json: + pack_json.write_text( + json.dumps( + { + "name": "Default", + "author": "Portkey Drop", + "description": "Default sound pack.", + "version": "1.0.0", + "sounds": {}, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + return base_dir diff --git a/src/portkeydrop/soundpacks.py b/src/portkeydrop/soundpacks.py new file mode 100644 index 0000000..4713771 --- /dev/null +++ b/src/portkeydrop/soundpacks.py @@ -0,0 +1,296 @@ +"""Sound pack lookup, management, and playback helpers.""" + +from __future__ import annotations + +import json +import logging +import shutil +import tempfile +import zipfile +from pathlib import Path +from typing import Any + +from portkeydrop.soundpack_paths import ensure_default_soundpack + +logger = logging.getLogger(__name__) + +AUDIO_WILDCARD = "Audio files (*.wav;*.mp3;*.ogg;*.flac)|*.wav;*.mp3;*.ogg;*.flac" +DEFAULT_PACK = "default" +SOUND_LIB_AVAILABLE = False +_sound_lib_output = None +_active_streams: list = [] + +try: + from sound_lib import output + + _sound_lib_output = output.Output() + SOUND_LIB_AVAILABLE = True +except ImportError: + pass +except Exception as exc: + logger.debug("sound_lib initialization failed: %s", exc) + + +def slugify_pack_name(value: str, fallback: str = "sound_pack") -> str: + """Return a filesystem-friendly pack identifier.""" + slug = value.strip().lower().replace("-", "_").replace(" ", "_") + slug = "".join(char for char in slug if char.isalnum() or char == "_") + while "__" in slug: + slug = slug.replace("__", "_") + return slug.strip("_") or fallback + + +def safe_extractall(zip_file: zipfile.ZipFile, target_dir: Path) -> None: + """Extract a zip archive after rejecting path traversal members.""" + target_dir = target_dir.resolve() + for member in zip_file.namelist(): + member_path = (target_dir / member).resolve() + try: + member_path.relative_to(target_dir) + except ValueError as exc: + raise ValueError( + f"Zip Slip detected: member '{member}' would extract outside target directory" + ) from exc + zip_file.extractall(target_dir) + + +def parse_sound_entry( + entry: str | dict[str, Any], event: str, volumes: dict[str, float] | None = None +) -> tuple[str, float]: + """Parse a pack sound entry and return a clamped filename/volume pair.""" + if isinstance(entry, dict): + filename = entry.get("file", f"{event}.wav") + volume = entry.get("volume", 1.0) + else: + filename = str(entry) if entry else f"{event}.wav" + volume = volumes[event] if volumes and event in volumes else 1.0 + + try: + volume = max(0.0, min(1.0, float(volume))) + except (TypeError, ValueError): + volume = 1.0 + return filename, volume + + +def load_pack_sounds(pack_json: Path) -> tuple[dict[str, Any], dict[str, float]]: + """Load sound and volume mappings from a pack.json file.""" + with open(pack_json, encoding="utf-8") as f: + meta: dict[str, Any] = json.load(f) + sounds = meta.get("sounds", {}) + volumes = meta.get("volumes", {}) + return sounds if isinstance(sounds, dict) else {}, volumes if isinstance(volumes, dict) else {} + + +def validate_sound_pack(pack_path: Path) -> tuple[bool, str]: + """Validate a sound pack directory and its pack.json contents.""" + if not pack_path.exists(): + return False, "Sound pack directory does not exist" + if not pack_path.is_dir(): + return False, "Sound pack path is not a directory" + + pack_json = pack_path / "pack.json" + if not pack_json.exists(): + return False, "Missing pack.json file" + + try: + with open(pack_json, encoding="utf-8") as f: + pack_data = json.load(f) + if "name" not in pack_data: + return False, "Missing 'name' field in pack.json" + if "sounds" not in pack_data: + return False, "Missing 'sounds' field in pack.json" + if not isinstance(pack_data["sounds"], dict): + return False, "'sounds' field must be a dictionary" + + missing_files = [] + for sound_name, sound_entry in pack_data["sounds"].items(): + filename = ( + sound_entry.get("file", f"{sound_name}.wav") + if isinstance(sound_entry, dict) + else str(sound_entry) + ) + if not filename or not (pack_path / filename).exists(): + missing_files.append(filename or sound_name) + if missing_files: + return False, f"Missing sound files: {', '.join(missing_files)}" + + volumes = pack_data.get("volumes", {}) + if not isinstance(volumes, dict): + return False, "'volumes' field must be a dictionary" + for event, volume in volumes.items(): + try: + value = float(volume) + except (TypeError, ValueError): + return False, f"Invalid volume value for '{event}': {volume}" + if value < 0.0 or value > 1.0: + return False, f"Volume for '{event}' must be between 0.0 and 1.0" + return True, "Sound pack is valid" + except json.JSONDecodeError as exc: + return False, f"Invalid JSON in pack.json: {exc}" + except Exception as exc: + return False, f"Error validating sound pack: {exc}" + + +def get_available_sound_packs(soundpacks_dir: Path) -> dict[str, dict[str, Any]]: + """Return all available sound packs with metadata.""" + ensure_default_soundpack(soundpacks_dir) + packs: dict[str, dict[str, Any]] = {} + for pack_dir in soundpacks_dir.iterdir(): + if not pack_dir.is_dir(): + continue + pack_json = pack_dir / "pack.json" + if not pack_json.exists(): + continue + try: + with open(pack_json, encoding="utf-8") as f: + data: dict[str, Any] = json.load(f) + data["directory"] = pack_dir.name + data["path"] = str(pack_dir) + packs[pack_dir.name] = data + except Exception as exc: + logger.error("Failed to load sound pack %s: %s", pack_dir.name, exc) + return packs + + +def get_sound_entry( + event: str, + pack_dir: str, + *, + soundpacks_dir: Path, + default_pack: str = DEFAULT_PACK, +) -> tuple[Path | None, float]: + """Resolve a sound file and volume for an event in a pack.""" + ensure_default_soundpack(soundpacks_dir) + for candidate_pack in (pack_dir, default_pack): + pack_path = soundpacks_dir / candidate_pack + pack_json = pack_path / "pack.json" + if not pack_json.exists(): + continue + try: + sounds, volumes = load_pack_sounds(pack_json) + entry = sounds.get(event) + if entry is None: + continue + filename, volume = parse_sound_entry(entry, event, volumes) + sound_file = pack_path / filename + if sound_file.exists(): + return sound_file, volume + except Exception as exc: + logger.error("Error reading sound pack %s: %s", candidate_pack, exc) + return None, 1.0 + + +class SoundPackInstaller: + """Handles local installation and management of sound packs.""" + + def __init__(self, soundpacks_dir: Path): + self.soundpacks_dir = ensure_default_soundpack(soundpacks_dir) + + def install_from_zip(self, zip_path: Path, pack_name: str | None = None) -> tuple[bool, str]: + """Install a sound pack from a ZIP file.""" + if not zip_path.exists(): + return False, f"ZIP file not found: {zip_path}" + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + try: + with zipfile.ZipFile(zip_path, "r") as zip_file: + safe_extractall(zip_file, temp_path) + pack_json_files = list(temp_path.rglob("pack.json")) + if not pack_json_files: + return False, "No pack.json file found in ZIP archive" + pack_dir = pack_json_files[0].parent + is_valid, message = validate_sound_pack(pack_dir) + if not is_valid: + return False, f"Invalid sound pack: {message}" + + with open(pack_dir / "pack.json", encoding="utf-8") as f: + pack_data = json.load(f) + target_name = slugify_pack_name(pack_name or pack_data.get("name", zip_path.stem)) + target_dir = self.soundpacks_dir / target_name + if target_dir.exists(): + return False, f"Sound pack '{target_name}' already exists" + shutil.copytree(pack_dir, target_dir) + return ( + True, + f"Successfully installed sound pack '{pack_data.get('name', target_name)}'", + ) + except zipfile.BadZipFile: + return False, "Invalid ZIP file" + except Exception as exc: + logger.error("Error installing sound pack: %s", exc) + return False, f"Installation failed: {exc}" + + def export_pack(self, pack_name: str, output_path: Path) -> tuple[bool, str]: + """Export a sound pack to a ZIP file.""" + pack_dir = self.soundpacks_dir / pack_name + if not pack_dir.exists(): + return False, f"Sound pack '{pack_name}' not found" + try: + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zip_file: + for file_path in pack_dir.rglob("*"): + if file_path.is_file(): + zip_file.write(file_path, file_path.relative_to(pack_dir)) + return True, f"Successfully exported sound pack to {output_path}" + except Exception as exc: + logger.error("Error exporting sound pack: %s", exc) + return False, f"Export failed: {exc}" + + def uninstall_pack(self, pack_name: str) -> tuple[bool, str]: + """Remove an installed sound pack.""" + if pack_name == DEFAULT_PACK: + return False, "Cannot uninstall the default sound pack" + pack_dir = self.soundpacks_dir / pack_name + if not pack_dir.exists(): + return False, f"Sound pack '{pack_name}' not found" + shutil.rmtree(pack_dir) + return True, f"Successfully uninstalled sound pack '{pack_name}'" + + +class SoundPlayer: + """Small optional-backend player for soundpack event sounds.""" + + def __init__(self, soundpacks_dir: Path, pack_name: str = DEFAULT_PACK) -> None: + self.soundpacks_dir = ensure_default_soundpack(soundpacks_dir) + self.pack_name = pack_name or DEFAULT_PACK + + def play_event( + self, event: str, *, enabled: bool = True, muted: set[str] | None = None + ) -> bool: + """Play a configured event sound when available.""" + if not enabled or event in (muted or set()): + return False + sound_file, volume = get_sound_entry( + event, + self.pack_name, + soundpacks_dir=self.soundpacks_dir, + ) + if sound_file is None: + return False + return play_sound_file(sound_file, volume=volume) + + +def play_sound_file(sound_file: Path, volume: float = 1.0) -> bool: + """Play a sound file with sound_lib, returning whether playback started.""" + if not sound_file.exists(): + return False + volume = max(0.0, min(1.0, volume)) + if volume <= 0.0: + return True + if not SOUND_LIB_AVAILABLE: + logger.warning("sound_lib audio backend unavailable") + return False + + try: + from sound_lib import stream + + _active_streams[:] = [active for active in _active_streams if active.is_playing] + sound_stream = stream.FileStream(file=str(sound_file)) + sound_stream.volume = volume + sound_stream.play() + _active_streams.append(sound_stream) + logger.debug("Played sound using sound_lib at volume %s: %s", volume, sound_file) + return True + except Exception as exc: + logger.warning("sound_lib playback failed: %s", exc) + return False diff --git a/tests/test_app.py b/tests/test_app.py index 04c265c..43da0b0 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -72,6 +72,7 @@ def _hydrate_frame(module): frame._show_transfer_queue = MagicMock() frame._refresh_local_files = MagicMock() frame._refresh_remote_files = MagicMock() + frame._play_sound_event = MagicMock(return_value=True) frame._get_selected_local_file = MagicMock() frame._get_selected_remote_file = MagicMock() frame._transfer_service = MagicMock() @@ -837,6 +838,7 @@ def test_delete_remote_updates_status_on_success(app_module): frame._update_status.assert_any_call("Deleting doc.txt...", "/remote") frame._update_status.assert_any_call("Delete complete.", "/remote") frame._refresh_remote_files.assert_called_once() + frame._play_sound_event.assert_called_with("delete_complete") def test_delete_remote_reports_failure(app_module): @@ -854,6 +856,7 @@ def test_delete_remote_reports_failure(app_module): frame._delete_remote() frame._update_status.assert_any_call("Delete failed.", "/remote") + frame._play_sound_event.assert_called_with("delete_failed") fake_wx.MessageBox.assert_called() @@ -876,6 +879,7 @@ def test_rename_remote_updates_status(app_module): frame._update_status.assert_any_call("Renaming old.txt...", "/remote") frame._update_status.assert_any_call("Rename complete.", "/remote") + frame._play_sound_event.assert_called_with("rename_complete") def test_rename_remote_handles_error(app_module): @@ -898,6 +902,7 @@ def test_rename_remote_handles_error(app_module): frame._rename_remote() frame._update_status.assert_any_call("Rename failed.", "/remote") + frame._play_sound_event.assert_called_with("rename_failed") fake_wx.MessageBox.assert_called() @@ -917,6 +922,7 @@ def test_mkdir_remote_updates_status(app_module): frame._update_status.assert_any_call("Creating directory new-dir...", "/remote") frame._update_status.assert_any_call("Directory created.", "/remote") + frame._play_sound_event.assert_called_with("folder_created") def test_mkdir_remote_reports_error(app_module): @@ -934,6 +940,7 @@ def test_mkdir_remote_reports_error(app_module): frame._mkdir_remote() frame._update_status.assert_any_call("Create directory failed.", "/remote") + frame._play_sound_event.assert_called_with("folder_create_failed") fake_wx.MessageBox.assert_called() @@ -2078,15 +2085,29 @@ def test_on_close_stops_auto_update_timer_and_skips_event(app_module, monkeypatc frame = object.__new__(app.MainFrame) frame._auto_update_check_timer = MagicMock(Stop=MagicMock()) frame._transfer_service = MagicMock() + frame._play_exit_sound_once = MagicMock() event = MagicMock(Skip=MagicMock()) monkeypatch.setattr(app, "save_queue", lambda *a, **kw: None) frame._on_close(event) + frame._play_exit_sound_once.assert_called_once() frame._auto_update_check_timer.Stop.assert_called_once() event.Skip.assert_called_once() +def test_play_exit_sound_once_deduplicates_menu_and_close_paths(app_module): + app, _ = app_module + frame = object.__new__(app.MainFrame) + frame._exit_sound_played = False + frame._play_sound_event = MagicMock(return_value=True) + + assert frame._play_exit_sound_once() is True + assert frame._play_exit_sound_once() is False + + frame._play_sound_event.assert_called_once_with("exit") + + def test_get_update_channel_falls_back_to_stable_on_exception(app_module): app, _ = app_module frame = object.__new__(app.MainFrame) diff --git a/tests/test_app_migration_startup.py b/tests/test_app_migration_startup.py index 7747110..730a697 100644 --- a/tests/test_app_migration_startup.py +++ b/tests/test_app_migration_startup.py @@ -22,6 +22,10 @@ def _app_instance(app): return instance +def _frame(): + return SimpleNamespace(Show=MagicMock(), _play_sound_event=MagicMock()) + + def test_on_init_portable_mode_runs_migration_when_user_confirms(tmp_path, app_module): app, fake_wx = app_module portable_dir = tmp_path / "portable" @@ -31,7 +35,7 @@ def test_on_init_portable_mode_runs_migration_when_user_confirms(tmp_path, app_m dialog = MagicMock() dialog.ShowModal.return_value = fake_wx.ID_OK dialog.get_selected_filenames.return_value = ["sites.json"] - frame = SimpleNamespace(Show=MagicMock()) + frame = _frame() site_manager = MagicMock() site_manager.should_offer_keyring_to_vault_migration.return_value = False @@ -50,6 +54,7 @@ def test_on_init_portable_mode_runs_migration_when_user_confirms(tmp_path, app_m migration_dialog_cls.assert_called_once_with(None, candidates) assert result is True + frame._play_sound_event.assert_called_once_with("startup") migrate_files.assert_called_once_with(["sites.json"], tmp_path / ".portkeydrop", portable_dir) dialog.Destroy.assert_called_once() @@ -62,7 +67,7 @@ def test_on_init_portable_mode_skips_migration_when_user_cancels(tmp_path, app_m dialog = MagicMock() dialog.ShowModal.return_value = fake_wx.ID_CANCEL - frame = SimpleNamespace(Show=MagicMock()) + frame = _frame() site_manager = MagicMock() site_manager.should_offer_keyring_to_vault_migration.return_value = False @@ -86,7 +91,7 @@ def test_on_init_portable_mode_skips_migration_when_user_cancels(tmp_path, app_m def test_on_init_non_portable_mode_does_not_show_migration_dialog(app_module): app, _ = app_module - frame = SimpleNamespace(Show=MagicMock()) + frame = _frame() with ( patch.object(app, "is_portable_mode", return_value=False), @@ -107,7 +112,7 @@ def test_on_init_prompts_for_keyring_to_vault_migration_and_marks_complete(tmp_p app, fake_wx = app_module portable_dir = tmp_path / "portable" portable_dir.mkdir() - frame = SimpleNamespace(Show=MagicMock()) + frame = _frame() site_manager = MagicMock() site_manager.should_offer_keyring_to_vault_migration.return_value = True @@ -132,7 +137,7 @@ def test_on_init_decline_keyring_to_vault_migration_still_writes_marker(tmp_path app, fake_wx = app_module portable_dir = tmp_path / "portable" portable_dir.mkdir() - frame = SimpleNamespace(Show=MagicMock()) + frame = _frame() site_manager = MagicMock() site_manager.should_offer_keyring_to_vault_migration.return_value = True @@ -157,7 +162,7 @@ def test_on_init_skips_keyring_prompt_when_marker_exists(tmp_path, app_module): portable_dir = tmp_path / "portable" portable_dir.mkdir() (portable_dir / ".keyring_migrated").touch() - frame = SimpleNamespace(Show=MagicMock()) + frame = _frame() with ( patch.object(app, "is_portable_mode", return_value=True), diff --git a/tests/test_settings.py b/tests/test_settings.py index b32127a..6aaa44c 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -6,6 +6,7 @@ from portkeydrop.settings import ( AppSettings, + AudioSettings, ConnectionDefaults, DisplaySettings, Settings, @@ -62,6 +63,14 @@ def test_defaults(self): assert s.verbosity == "normal" +class TestAudioSettings: + def test_defaults(self): + s = AudioSettings() + assert s.sound_enabled is True + assert s.sound_pack == "default" + assert s.muted_sound_events == [] + + class TestAppSettings: def test_defaults(self): s = AppSettings() @@ -81,6 +90,7 @@ def test_defaults(self): assert isinstance(s.display, DisplaySettings) assert isinstance(s.connection, ConnectionDefaults) assert isinstance(s.speech, SpeechSettings) + assert isinstance(s.audio, AudioSettings) assert isinstance(s.app, AppSettings) @@ -96,6 +106,9 @@ def test_save_and_load(self, tmp_path): settings.display.show_hidden_files = True settings.connection.timeout = 60 settings.speech.rate = 75 + settings.audio.sound_enabled = False + settings.audio.sound_pack = "custom" + settings.audio.muted_sound_events = ["transfer_complete"] settings.app.auto_update_enabled = False settings.app.update_check_interval_hours = 12 settings.app.update_channel = "nightly" @@ -109,6 +122,9 @@ def test_save_and_load(self, tmp_path): assert loaded.display.show_hidden_files is True assert loaded.connection.timeout == 60 assert loaded.speech.rate == 75 + assert loaded.audio.sound_enabled is False + assert loaded.audio.sound_pack == "custom" + assert loaded.audio.muted_sound_events == ["transfer_complete"] assert loaded.app.auto_update_enabled is False assert loaded.app.update_check_interval_hours == 12 assert loaded.app.update_channel == "nightly" @@ -128,6 +144,21 @@ def test_load_partial_settings(self, tmp_path): assert settings.transfer.concurrent_transfers == 10 assert settings.display.sort_by == "name" # default preserved + def test_load_audio_settings_drops_unknown_muted_events(self, tmp_path): + data = { + "audio": { + "sound_enabled": True, + "sound_pack": "my_pack", + "muted_sound_events": ["transfer_failed", "unknown_event"], + } + } + (tmp_path / "settings.json").write_text(json.dumps(data), encoding="utf-8") + + settings = load_settings(tmp_path) + + assert settings.audio.sound_pack == "my_pack" + assert settings.audio.muted_sound_events == ["transfer_failed"] + def test_load_ignores_unknown_keys(self, tmp_path): data = {"transfer": {"concurrent_transfers": 3, "unknown_key": True}} (tmp_path / "settings.json").write_text(json.dumps(data), encoding="utf-8") diff --git a/tests/test_settings_dialog_a11y.py b/tests/test_settings_dialog_a11y.py index b4c3155..586026e 100644 --- a/tests/test_settings_dialog_a11y.py +++ b/tests/test_settings_dialog_a11y.py @@ -111,9 +111,19 @@ def __init__(self, parent=None, choices=None, **_kw): def SetSelection(self, idx: int): self._selection = idx + def GetSelection(self) -> int: + return self._selection + def GetStringSelection(self) -> str: return self._choices[self._selection] + def Clear(self): + self._choices = [] + self._selection = 0 + + def Append(self, value: str): + self._choices.append(value) + class _CheckBox(_Control): pass @@ -223,6 +233,10 @@ def test_all_controls_have_unambiguous_accessible_names(monkeypatch): "update_interval_spin": "Update check interval", "update_channel_choice": "Update channel", "check_updates_button": "Check for updates now", + # Audio + "sound_enabled_check": "Enable sound notifications", + "sound_pack_choice": "Sound pack", + "manage_soundpacks_button": "Manage sound packs", # Speech "speech_rate_spin": "Speech rate", "speech_volume_spin": "Speech volume", @@ -254,6 +268,8 @@ def test_labeled_controls_have_label_for_links(monkeypatch): "verify_keys_choice", "update_interval_spin", "update_channel_choice", + "sound_pack_choice", + "manage_soundpacks_button", "speech_rate_spin", "speech_volume_spin", "verbosity_choice", @@ -343,6 +359,7 @@ def test_notebook_includes_dedicated_updates_tab(monkeypatch): "Display", "Connection", "Updates", + "Audio", "Speech", ] @@ -368,6 +385,9 @@ def test_get_settings_persists_updater_fields(monkeypatch): dlg.minimize_to_tray_check.SetValue(True) dlg.update_interval_spin.SetValue(12) dlg.update_channel_choice.SetSelection(1) + dlg.sound_enabled_check.SetValue(False) + if dlg._audio_event_checks: + dlg._audio_event_checks[0][1].SetValue(True) dlg.remember_local_folder_check.SetValue(False) dlg.speech_rate_spin.SetValue(80) @@ -378,6 +398,9 @@ def test_get_settings_persists_updater_fields(monkeypatch): assert settings.app.minimize_to_notification_area_on_close is True assert settings.app.update_check_interval_hours == 12 assert settings.app.update_channel == "nightly" + assert settings.audio.sound_enabled is False + assert settings.audio.sound_pack == "default" + assert settings.audio.muted_sound_events == [dlg._audio_event_checks[0][0]] assert settings.app.remember_last_local_folder_on_startup is False assert settings.speech.rate == 80 diff --git a/tests/test_soundpacks.py b/tests/test_soundpacks.py new file mode 100644 index 0000000..36a1366 --- /dev/null +++ b/tests/test_soundpacks.py @@ -0,0 +1,434 @@ +"""Tests for sound pack helpers.""" + +from __future__ import annotations + +import json +import sys +import types +import zipfile + +import pytest + +import portkeydrop.soundpacks as soundpacks_module +from portkeydrop.soundpack_paths import ensure_default_soundpack +from portkeydrop.soundpacks import ( + SoundPackInstaller, + get_available_sound_packs, + get_sound_entry, + parse_sound_entry, + play_sound_file, + safe_extractall, + slugify_pack_name, + validate_sound_pack, +) + + +def test_ensure_default_soundpack_installs_packaged_default(tmp_path): + soundpacks_dir = ensure_default_soundpack(tmp_path / "soundpacks") + + pack_json = soundpacks_dir / "default" / "pack.json" + pack_data = json.loads(pack_json.read_text(encoding="utf-8")) + + assert pack_json.exists() + assert pack_data["sounds"]["transfer_complete"] == "transfers/transfer_complete.ogg" + assert (soundpacks_dir / "default" / "transfers" / "transfer_complete.ogg").exists() + + +def test_ensure_default_soundpack_upgrades_placeholder_default(tmp_path): + soundpacks_dir = tmp_path / "soundpacks" + default_dir = soundpacks_dir / "default" + default_dir.mkdir(parents=True) + (default_dir / "pack.json").write_text( + json.dumps({"name": "Default", "sounds": {}}), + encoding="utf-8", + ) + + ensure_default_soundpack(soundpacks_dir) + + pack_data = json.loads((default_dir / "pack.json").read_text(encoding="utf-8")) + assert pack_data["sounds"]["connect_success"] == "connections/connect_success.ogg" + assert (default_dir / "connections" / "connect_success.ogg").exists() + + +def test_ensure_default_soundpack_preserves_custom_default_manifest(tmp_path): + soundpacks_dir = tmp_path / "soundpacks" + default_dir = soundpacks_dir / "default" + default_dir.mkdir(parents=True) + (default_dir / "custom.wav").write_bytes(b"RIFF") + (default_dir / "pack.json").write_text( + json.dumps({"name": "My Default", "sounds": {"success": "custom.wav"}}), + encoding="utf-8", + ) + + ensure_default_soundpack(soundpacks_dir) + + pack_data = json.loads((default_dir / "pack.json").read_text(encoding="utf-8")) + assert pack_data == {"name": "My Default", "sounds": {"success": "custom.wav"}} + assert (default_dir / "transfers" / "transfer_complete.ogg").exists() + + +def test_validate_sound_pack_accepts_inline_volume_format(tmp_path): + pack_dir = tmp_path / "pack" + pack_dir.mkdir() + (pack_dir / "complete.wav").write_bytes(b"RIFF") + (pack_dir / "pack.json").write_text( + json.dumps( + { + "name": "Transfers", + "sounds": {"transfer_complete": {"file": "complete.wav", "volume": 0.5}}, + } + ), + encoding="utf-8", + ) + + valid, message = validate_sound_pack(pack_dir) + + assert valid is True + assert message == "Sound pack is valid" + + +def test_validate_sound_pack_rejects_missing_mapped_file(tmp_path): + pack_dir = tmp_path / "pack" + pack_dir.mkdir() + (pack_dir / "pack.json").write_text( + json.dumps({"name": "Broken", "sounds": {"transfer_failed": "missing.wav"}}), + encoding="utf-8", + ) + + valid, message = validate_sound_pack(pack_dir) + + assert valid is False + assert "Missing sound files" in message + + +def test_validate_sound_pack_rejects_missing_directory(tmp_path): + valid, message = validate_sound_pack(tmp_path / "missing") + + assert valid is False + assert message == "Sound pack directory does not exist" + + +def test_validate_sound_pack_rejects_non_directory(tmp_path): + pack_path = tmp_path / "pack.zip" + pack_path.write_text("not a directory", encoding="utf-8") + + valid, message = validate_sound_pack(pack_path) + + assert valid is False + assert message == "Sound pack path is not a directory" + + +def test_validate_sound_pack_rejects_missing_pack_json(tmp_path): + pack_dir = tmp_path / "pack" + pack_dir.mkdir() + + valid, message = validate_sound_pack(pack_dir) + + assert valid is False + assert message == "Missing pack.json file" + + +def test_validate_sound_pack_rejects_invalid_manifest_shapes(tmp_path): + pack_dir = tmp_path / "pack" + pack_dir.mkdir() + pack_json = pack_dir / "pack.json" + + pack_json.write_text(json.dumps({"sounds": {}}), encoding="utf-8") + valid, message = validate_sound_pack(pack_dir) + assert valid is False + assert message == "Missing 'name' field in pack.json" + + pack_json.write_text(json.dumps({"name": "Broken"}), encoding="utf-8") + valid, message = validate_sound_pack(pack_dir) + assert valid is False + assert message == "Missing 'sounds' field in pack.json" + + pack_json.write_text(json.dumps({"name": "Broken", "sounds": []}), encoding="utf-8") + valid, message = validate_sound_pack(pack_dir) + assert valid is False + assert message == "'sounds' field must be a dictionary" + + +def test_validate_sound_pack_rejects_invalid_volume_data(tmp_path): + pack_dir = tmp_path / "pack" + pack_dir.mkdir() + (pack_dir / "cue.wav").write_bytes(b"RIFF") + pack_json = pack_dir / "pack.json" + + pack_json.write_text( + json.dumps({"name": "Broken", "sounds": {"success": "cue.wav"}, "volumes": []}), + encoding="utf-8", + ) + valid, message = validate_sound_pack(pack_dir) + assert valid is False + assert message == "'volumes' field must be a dictionary" + + pack_json.write_text( + json.dumps( + {"name": "Broken", "sounds": {"success": "cue.wav"}, "volumes": {"success": "loud"}} + ), + encoding="utf-8", + ) + valid, message = validate_sound_pack(pack_dir) + assert valid is False + assert "Invalid volume value" in message + + pack_json.write_text( + json.dumps( + {"name": "Broken", "sounds": {"success": "cue.wav"}, "volumes": {"success": 1.5}} + ), + encoding="utf-8", + ) + valid, message = validate_sound_pack(pack_dir) + assert valid is False + assert "must be between 0.0 and 1.0" in message + + +def test_get_sound_entry_falls_back_to_default_pack(tmp_path): + soundpacks_dir = ensure_default_soundpack(tmp_path / "soundpacks") + default_dir = soundpacks_dir / "default" + (default_dir / "error.wav").write_bytes(b"RIFF") + (default_dir / "pack.json").write_text( + json.dumps({"name": "Default", "sounds": {"transfer_failed": "error.wav"}}), + encoding="utf-8", + ) + custom_dir = soundpacks_dir / "custom" + custom_dir.mkdir() + (custom_dir / "pack.json").write_text( + json.dumps({"name": "Custom", "sounds": {}}), + encoding="utf-8", + ) + + sound_file, volume = get_sound_entry( + "transfer_failed", + "custom", + soundpacks_dir=soundpacks_dir, + ) + + assert sound_file == default_dir / "error.wav" + assert volume == 1.0 + + +def test_get_sound_entry_ignores_unreadable_pack_manifest(tmp_path): + soundpacks_dir = ensure_default_soundpack(tmp_path / "soundpacks") + broken_dir = soundpacks_dir / "broken" + broken_dir.mkdir() + (broken_dir / "pack.json").write_text("{", encoding="utf-8") + + sound_file, volume = get_sound_entry( + "transfer_failed", + "broken", + soundpacks_dir=soundpacks_dir, + ) + + assert sound_file is not None + assert sound_file.name == "transfer_failed.ogg" + assert volume == 1.0 + + +def test_installer_exports_pack_zip(tmp_path): + soundpacks_dir = ensure_default_soundpack(tmp_path / "soundpacks") + pack_dir = soundpacks_dir / "custom" + pack_dir.mkdir() + (pack_dir / "done.wav").write_bytes(b"RIFF") + (pack_dir / "pack.json").write_text( + json.dumps({"name": "Custom", "sounds": {"transfer_complete": "done.wav"}}), + encoding="utf-8", + ) + output = tmp_path / "custom.zip" + + ok, message = SoundPackInstaller(soundpacks_dir).export_pack("custom", output) + + assert ok is True + assert "Successfully exported" in message + with zipfile.ZipFile(output) as zf: + assert sorted(zf.namelist()) == ["done.wav", "pack.json"] + + +def test_installer_installs_pack_zip_from_nested_directory(tmp_path): + soundpacks_dir = ensure_default_soundpack(tmp_path / "soundpacks") + archive = tmp_path / "new-pack.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr( + "nested/pack.json", + json.dumps({"name": "Fancy Pack", "sounds": {"success": "done.wav"}}), + ) + zf.writestr("nested/done.wav", b"RIFF") + + ok, message = SoundPackInstaller(soundpacks_dir).install_from_zip(archive) + + assert ok is True + assert "Successfully installed" in message + assert (soundpacks_dir / "fancy_pack" / "pack.json").exists() + + +def test_installer_rejects_invalid_zip_inputs(tmp_path): + soundpacks_dir = ensure_default_soundpack(tmp_path / "soundpacks") + installer = SoundPackInstaller(soundpacks_dir) + + ok, message = installer.install_from_zip(tmp_path / "missing.zip") + assert ok is False + assert "ZIP file not found" in message + + archive = tmp_path / "not-a-pack.zip" + archive.write_text("not a zip", encoding="utf-8") + ok, message = installer.install_from_zip(archive) + assert ok is False + assert message == "Invalid ZIP file" + + empty_archive = tmp_path / "empty.zip" + with zipfile.ZipFile(empty_archive, "w") as zf: + zf.writestr("readme.txt", "hello") + ok, message = installer.install_from_zip(empty_archive) + assert ok is False + assert message == "No pack.json file found in ZIP archive" + + +def test_installer_rejects_invalid_or_duplicate_pack_zip(tmp_path): + soundpacks_dir = ensure_default_soundpack(tmp_path / "soundpacks") + archive = tmp_path / "broken-pack.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr( + "pack.json", json.dumps({"name": "Broken", "sounds": {"success": "missing.wav"}}) + ) + + ok, message = SoundPackInstaller(soundpacks_dir).install_from_zip(archive) + + assert ok is False + assert "Invalid sound pack" in message + + duplicate_archive = tmp_path / "duplicate-pack.zip" + with zipfile.ZipFile(duplicate_archive, "w") as zf: + zf.writestr("pack.json", json.dumps({"name": "Default", "sounds": {"success": "done.wav"}})) + zf.writestr("done.wav", b"RIFF") + ok, message = SoundPackInstaller(soundpacks_dir).install_from_zip(duplicate_archive) + assert ok is False + assert "already exists" in message + + +def test_installer_uninstalls_custom_pack_only(tmp_path): + soundpacks_dir = ensure_default_soundpack(tmp_path / "soundpacks") + pack_dir = soundpacks_dir / "custom" + pack_dir.mkdir() + (pack_dir / "pack.json").write_text( + json.dumps({"name": "Custom", "sounds": {}}), + encoding="utf-8", + ) + installer = SoundPackInstaller(soundpacks_dir) + + ok, message = installer.uninstall_pack("default") + assert ok is False + assert message == "Cannot uninstall the default sound pack" + + ok, message = installer.uninstall_pack("missing") + assert ok is False + assert "not found" in message + + ok, message = installer.uninstall_pack("custom") + assert ok is True + assert "Successfully uninstalled" in message + assert not pack_dir.exists() + + +def test_safe_extractall_rejects_zip_slip(tmp_path): + archive = tmp_path / "bad.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("../evil.txt", "bad") + + with zipfile.ZipFile(archive) as zf: + with pytest.raises(ValueError, match="Zip Slip"): + safe_extractall(zf, tmp_path / "out") + + +def test_slugify_pack_name_has_safe_fallback(): + assert slugify_pack_name("My Pack!") == "my_pack" + assert slugify_pack_name("!!!") == "sound_pack" + + +def test_get_available_sound_packs_includes_default(tmp_path): + soundpacks_dir = ensure_default_soundpack(tmp_path / "soundpacks") + + packs = get_available_sound_packs(soundpacks_dir) + + assert "default" in packs + + +def test_parse_sound_entry_clamps_volume(): + assert parse_sound_entry({"file": "x.wav", "volume": 2}, "event") == ("x.wav", 1.0) + + +def test_parse_sound_entry_uses_volume_mapping_and_fallbacks(): + assert parse_sound_entry("", "notify", {"notify": 0.5}) == ("notify.wav", 0.5) + assert parse_sound_entry({"volume": "quiet"}, "notify") == ("notify.wav", 1.0) + + +def test_sound_player_respects_disabled_and_muted_events(tmp_path, monkeypatch): + soundpacks_dir = ensure_default_soundpack(tmp_path / "soundpacks") + player = soundpacks_module.SoundPlayer(soundpacks_dir) + calls: list[tuple[object, float]] = [] + monkeypatch.setattr( + soundpacks_module, + "play_sound_file", + lambda path, volume=1.0: calls.append((path, volume)) or True, + ) + + assert player.play_event("success", enabled=False) is False + assert player.play_event("success", muted={"success"}) is False + assert player.play_event("success") is True + assert calls + + +def test_play_sound_file_handles_missing_backend_and_errors(tmp_path, monkeypatch): + sound_file = tmp_path / "cue.ogg" + sound_file.write_bytes(b"OggS") + + monkeypatch.setattr(soundpacks_module, "SOUND_LIB_AVAILABLE", False) + assert play_sound_file(sound_file) is False + assert play_sound_file(tmp_path / "missing.ogg") is False + assert play_sound_file(sound_file, volume=0) is True + + class BrokenFileStream: + is_playing = False + + def __init__(self, *, file: str) -> None: + raise RuntimeError(file) + + stream_module = types.ModuleType("sound_lib.stream") + stream_module.FileStream = BrokenFileStream + monkeypatch.setitem(sys.modules, "sound_lib", types.ModuleType("sound_lib")) + monkeypatch.setitem(sys.modules, "sound_lib.stream", stream_module) + monkeypatch.setattr(soundpacks_module, "SOUND_LIB_AVAILABLE", True) + assert play_sound_file(sound_file) is False + + +def test_play_sound_file_uses_sound_lib_only(tmp_path, monkeypatch): + sound_file = tmp_path / "cue.ogg" + sound_file.write_bytes(b"OggS") + calls: dict[str, object] = {} + + stream_module = types.ModuleType("sound_lib.stream") + + class FakeFileStream: + is_playing = True + + def __init__(self, *, file: str) -> None: + calls["file"] = file + self.volume = 0 + + def play(self) -> None: + calls["volume"] = self.volume + calls["played"] = True + + stream_module.FileStream = FakeFileStream + monkeypatch.setitem(sys.modules, "sound_lib", types.ModuleType("sound_lib")) + monkeypatch.setitem(sys.modules, "sound_lib.stream", stream_module) + monkeypatch.setattr(soundpacks_module, "SOUND_LIB_AVAILABLE", True) + monkeypatch.setattr(soundpacks_module, "_active_streams", []) + + assert play_sound_file(sound_file, volume=0.25) is True + assert calls == { + "file": str(sound_file), + "volume": 0.25, + "played": True, + } + assert len(soundpacks_module._active_streams) == 1 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..255fc9d --- /dev/null +++ b/uv.lock @@ -0,0 +1,1014 @@ +version = 1 +revision = 1 +requires-python = ">=3.11, <3.13" + +[[package]] +name = "asyncssh" +version = "2.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/d5/957886c316466349d55c4de6a688a10a98295c0b4429deb8db1a17f3eb19/asyncssh-2.22.0.tar.gz", hash = "sha256:c3ce72b01be4f97b40e62844dd384227e5ff5a401a3793007c42f86a5c8eb537", size = 540523 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/ae/0da2f2214fc183338af1afe5a103a2052fd03464e8eafbd827abff58a4d0/asyncssh-2.22.0-py3-none-any.whl", hash = "sha256:d16465ccdf1ed20eba1131b14415b155e047f6f5be0d19f39c2e0b61331ee0e7", size = 374938 }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181 }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721 }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900 }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344 }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560 }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613 }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476 }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374 }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597 }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574 }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971 }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972 }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078 }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076 }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820 }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635 }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271 }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048 }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529 }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097 }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983 }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519 }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572 }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963 }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361 }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 }, +] + +[[package]] +name = "chardet" +version = "6.0.0.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/42/fb9436c103a881a377e34b9f58d77b5f503461c702ff654ebe86151bcfe9/chardet-6.0.0.post1.tar.gz", hash = "sha256:6b78048c3c97c7b2ed1fbad7a18f76f5a6547f7d34dbab536cc13887c9a92fa4", size = 12521798 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/42/5de54f632c2de53cd3415b3703383d5fff43a94cbc0567ef362515261a21/chardet-6.0.0.post1-py3-none-any.whl", hash = "sha256:c894a36800549adf7bb5f2af47033281b75fdfcd2aa0f0243be0ad22a52e2dcb", size = 627245 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988 }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324 }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742 }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863 }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837 }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550 }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162 }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019 }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310 }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022 }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383 }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098 }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991 }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456 }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978 }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969 }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425 }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162 }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558 }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497 }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240 }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471 }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864 }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647 }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110 }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839 }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667 }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535 }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816 }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694 }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131 }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390 }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278 }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783 }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200 }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114 }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220 }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164 }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325 }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913 }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974 }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741 }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695 }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599 }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780 }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715 }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385 }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449 }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810 }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308 }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052 }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165 }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432 }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716 }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089 }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232 }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299 }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796 }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673 }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990 }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800 }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415 }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289 }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637 }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742 }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528 }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993 }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855 }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635 }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038 }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181 }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482 }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497 }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819 }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230 }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909 }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514 }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349 }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667 }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980 }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143 }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674 }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801 }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755 }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539 }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794 }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160 }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123 }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220 }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050 }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964 }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321 }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786 }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990 }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252 }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605 }, +] + +[[package]] +name = "diff-cover" +version = "10.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "chardet" }, + { name = "jinja2" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/b4/eee71d1e338bc1f9bd3539b46b70e303dac061324b759c9a80fa3c96d90d/diff_cover-10.2.0.tar.gz", hash = "sha256:61bf83025f10510c76ef6a5820680cf61b9b974e8f81de70c57ac926fa63872a", size = 102473 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2c/61eeb887055a37150db824b6bf830e821a736580769ac2fea4eadb0d613f/diff_cover-10.2.0-py3-none-any.whl", hash = "sha256:59c328595e0b8948617cc5269af9e484c86462e2844bfcafa3fb37f8fca0af87", size = 56748 }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708 }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865 }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777 }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065 }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481 }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010 }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160 }, +] + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365 }, + { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793 }, + { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362 }, + { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152 }, + { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539 }, + { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853 }, + { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133 }, + { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944 }, + { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535 }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343 }, + { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419 }, + { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008 }, + { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906 }, + { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357 }, + { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583 }, + { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591 }, + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887 }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818 }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807 }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179 }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044 }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685 }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127 }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958 }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541 }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426 }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917 }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795 }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759 }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666 }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989 }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456 }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793 }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836 }, + { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829 }, + { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277 }, + { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433 }, + { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119 }, + { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314 }, + { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631 }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058 }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287 }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940 }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887 }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692 }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471 }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923 }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572 }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077 }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876 }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 }, +] + +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667 }, +] + +[[package]] +name = "numpy" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478 }, + { url = "https://files.pythonhosted.org/packages/74/41/5d17d4058bd0cd96bcbd4d9ff0fb2e21f52702aab9a72e4a594efa18692f/numpy-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", size = 14965467 }, + { url = "https://files.pythonhosted.org/packages/49/48/fb1ce8136c19452ed15f033f8aee91d5defe515094e330ce368a0647846f/numpy-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", size = 5475172 }, + { url = "https://files.pythonhosted.org/packages/40/a9/3feb49f17bbd1300dd2570432961f5c8a4ffeff1db6f02c7273bd020a4c9/numpy-2.4.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", size = 6805145 }, + { url = "https://files.pythonhosted.org/packages/3f/39/fdf35cbd6d6e2fcad42fcf85ac04a85a0d0fbfbf34b30721c98d602fd70a/numpy-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", size = 15966084 }, + { url = "https://files.pythonhosted.org/packages/1b/46/6fa4ea94f1ddf969b2ee941290cca6f1bfac92b53c76ae5f44afe17ceb69/numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", size = 16899477 }, + { url = "https://files.pythonhosted.org/packages/09/a1/2a424e162b1a14a5bd860a464ab4e07513916a64ab1683fae262f735ccd2/numpy-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", size = 17323429 }, + { url = "https://files.pythonhosted.org/packages/ce/a2/73014149ff250628df72c58204822ac01d768697913881aacf839ff78680/numpy-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", size = 18635109 }, + { url = "https://files.pythonhosted.org/packages/6c/0c/73e8be2f1accd56df74abc1c5e18527822067dced5ec0861b5bb882c2ce0/numpy-2.4.2-cp311-cp311-win32.whl", hash = "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", size = 6237915 }, + { url = "https://files.pythonhosted.org/packages/76/ae/e0265e0163cf127c24c3969d29f1c4c64551a1e375d95a13d32eab25d364/numpy-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", size = 12607972 }, + { url = "https://files.pythonhosted.org/packages/29/a5/c43029af9b8014d6ea157f192652c50042e8911f4300f8f6ed3336bf437f/numpy-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", size = 10485763 }, + { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963 }, + { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571 }, + { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469 }, + { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820 }, + { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067 }, + { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782 }, + { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128 }, + { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324 }, + { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282 }, + { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210 }, + { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171 }, + { url = "https://files.pythonhosted.org/packages/f4/f8/50e14d36d915ef64d8f8bc4a087fc8264d82c785eda6711f80ab7e620335/numpy-2.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", size = 16833179 }, + { url = "https://files.pythonhosted.org/packages/17/17/809b5cad63812058a8189e91a1e2d55a5a18fd04611dbad244e8aeae465c/numpy-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", size = 14889755 }, + { url = "https://files.pythonhosted.org/packages/3e/ea/181b9bcf7627fc8371720316c24db888dcb9829b1c0270abf3d288b2e29b/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", size = 5399500 }, + { url = "https://files.pythonhosted.org/packages/33/9f/413adf3fc955541ff5536b78fcf0754680b3c6d95103230252a2c9408d23/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", size = 6714252 }, + { url = "https://files.pythonhosted.org/packages/91/da/643aad274e29ccbdf42ecd94dafe524b81c87bcb56b83872d54827f10543/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", size = 15797142 }, + { url = "https://files.pythonhosted.org/packages/66/27/965b8525e9cb5dc16481b30a1b3c21e50c7ebf6e9dbd48d0c4d0d5089c7e/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", size = 16727979 }, + { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577 }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 }, +] + +[[package]] +name = "platform-utils" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/35/1388d9d259b53c4359d79f85afb0cac9ac40efdd8dee360ae18c220d226d/platform_utils-1.6.2.tar.gz", hash = "sha256:649bce9741c2cc99ab8065dc677f16faeb039ed2904a602c89aded8da14cd2c9", size = 14938 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/b0/005ec9008a0fd6c06e789cd8bd73b97b1aadae84b1dfc6ebd15b071ea71d/platform_utils-1.6.2-py3-none-any.whl", hash = "sha256:a040c646eb64152ab8598b6d4997eb857c49d32d0ca115f38920bd006befd195", size = 10200 }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "portkeydrop" +version = "0.3.0" +source = { editable = "." } +dependencies = [ + { name = "asyncssh" }, + { name = "keyring" }, + { name = "platform-utils" }, + { name = "prismatoid" }, + { name = "puttykeys" }, + { name = "sound-lib" }, + { name = "webdavclient3" }, + { name = "wxpython" }, +] + +[package.optional-dependencies] +dev = [ + { name = "diff-cover" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-xdist" }, + { name = "ruff" }, +] +webdav = [ + { name = "webdavclient3" }, +] + +[package.dev-dependencies] +dev = [ + { name = "diff-cover" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-xdist" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "asyncssh", specifier = ">=2.14" }, + { name = "diff-cover", marker = "extra == 'dev'", specifier = ">=9.0.0" }, + { name = "keyring", specifier = ">=25.0" }, + { name = "platform-utils", specifier = ">=1.6.0" }, + { name = "prismatoid" }, + { name = "puttykeys" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.0" }, + { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.0" }, + { name = "sound-lib", git = "https://github.com/samtupy/sound_lib_macos_fixes.git" }, + { name = "webdavclient3", specifier = ">=3.14" }, + { name = "webdavclient3", marker = "extra == 'webdav'", specifier = ">=3.14" }, + { name = "wxpython", specifier = ">=4.2" }, +] +provides-extras = ["webdav", "dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "diff-cover", specifier = ">=9.0.0" }, + { name = "pytest", specifier = ">=9.0" }, + { name = "pytest-cov", specifier = ">=7.0" }, + { name = "pytest-xdist", specifier = ">=3.0" }, + { name = "ruff", specifier = ">=0.15.0" }, +] + +[[package]] +name = "prismatoid" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "win32more", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/f5/39561d6176dceb6a67f27172431a198422cea7aba252b4afef3b42d97426/prismatoid-0.7.1.tar.gz", hash = "sha256:1d4a26c050f2f10dd8e46f2059dce073ae1b59046af7bc213392f28e94779130", size = 989444 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/d1/885a452b78031405ccb2d3d08baf626ba64c9c70da89322eac5d17b0667f/prismatoid-0.7.1-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:51d901413cf7ac9abb2578a60ab8a4e305d422ee7d056575b164ec8d9cf5380e", size = 177332 }, + { url = "https://files.pythonhosted.org/packages/55/88/2ceb30788b7572db353ec86d3455550d03b5fd20fb5552ea7d4a31649456/prismatoid-0.7.1-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:2f9d722ea20ee9cf90c4f3491b36a1c1ea2fb51be12674ce3140929af573414e", size = 294571 }, + { url = "https://files.pythonhosted.org/packages/cc/22/a0a5e9ea7c7026a4a9644ab68bc3c9feb93b629c727619fceb93300dfe73/prismatoid-0.7.1-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:a61430f4a9f241cf7cd753a838d9bb38d92f2b2c74614253c5490488f6db103e", size = 1553008 }, + { url = "https://files.pythonhosted.org/packages/af/99/99d3401d3767d9d91203c2d0026b048dfb5b8b2475cf910caf09fb117e62/prismatoid-0.7.1-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:cbb457acd7359216976e4f8fbb5588fcfcd7961d82e77759f742bb5c28844e49", size = 1726072 }, + { url = "https://files.pythonhosted.org/packages/11/19/bcbbcd9bd9032b2fe4a802db223bd812b170d3933d57ee955dd8cc12b0eb/prismatoid-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0725c75bce984a9d501a1b13dc3f3b0fdc5442c62963695698815a7262b6744c", size = 3239432 }, + { url = "https://files.pythonhosted.org/packages/9e/de/ad80803c24dfd30834c24a5b63abc8c3536ec78c608c3966f9025cd30d35/prismatoid-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82ee642074474c20f7a6ee0e57062f2a2d2f3968badf9ebff329c6ead6ba5505", size = 3500060 }, + { url = "https://files.pythonhosted.org/packages/04/08/98af055c551b4975af9135f9dfbc7919638df4112dc16bb5ff2482d346a5/prismatoid-0.7.1-cp311-cp311-win32.whl", hash = "sha256:824d430ade1cdd32033ffdb538aeff146c5af350c06c77d01e2af409ee392854", size = 2003163 }, + { url = "https://files.pythonhosted.org/packages/2e/d6/a2a538717906fc9d38d3860629c724b62b75700823472a8fffc20bbaedb2/prismatoid-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:ba7218e83ba52ca479c64d3d8c37478df88f932b27c16499196c9a8cb8a274cf", size = 2003165 }, + { url = "https://files.pythonhosted.org/packages/45/28/76ee83b3ea7ee61cdf3dfc961569e53a8e29a44a899f2834a969b5abbdc3/prismatoid-0.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:382ce81a1136bd5756a40aae6b9257023939875d41c3507ae697f5820e98c8bb", size = 2003206 }, + { url = "https://files.pythonhosted.org/packages/81/77/5028ff254f1c5dfb78ef5a3aa8748f6432a1240b5cc61603a2e56a562c60/prismatoid-0.7.1-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:729026190b1c8a020e561d07f78e0b3c8ff43f67a8c95a1e68d77a4e665ef822", size = 177331 }, + { url = "https://files.pythonhosted.org/packages/8d/3c/58ed90d2ecb7dc7efbfed3d5eef6c27fd0b941ef6999e57ad1bbb6c341c8/prismatoid-0.7.1-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:4f4ad3370b836be30eb6ec853b2c5a43efced21c484350ce89f750cd9a832f22", size = 294573 }, + { url = "https://files.pythonhosted.org/packages/28/fe/313fadd4f81dc39c09963d617736e803a5fd958ab5548345833f65f5b3b5/prismatoid-0.7.1-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:95c885c8b7bfef1104bb6b65a3c3038f1fdc2be0a0d584b9a06dbdf365768d83", size = 1553005 }, + { url = "https://files.pythonhosted.org/packages/d8/1d/2e2540ca113adc5018f2644bb08a0971eb16f17bef27de22e5784319dae9/prismatoid-0.7.1-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:5dc4476f78fa60ee571a76219f06407c0f14c4d4e935ccdc88250df3b010fba0", size = 1726072 }, + { url = "https://files.pythonhosted.org/packages/b0/e6/848dd266b992f69c110358d53bdfcf7878dacbd7c3ae8e9b30db995785c7/prismatoid-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b2a990ea2f25ac867ee4b5d848dd0532d9a0d5c8d6219108ea91f2653c40fb2d", size = 3239437 }, + { url = "https://files.pythonhosted.org/packages/71/39/369c0dd039a0d23e8762b1649271abc6e7aa381bd11bd997a240803fa859/prismatoid-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2ef0086e603ef0e4ae6e06c2dffa2b4c93cb9826c80f519164c79857227a9267", size = 3500064 }, + { url = "https://files.pythonhosted.org/packages/b3/f1/532c076eb0cc7167359927ce7c1b0038146ef6c464098caef09713bccecb/prismatoid-0.7.1-cp312-cp312-win32.whl", hash = "sha256:2263a1160e65b89730b66ef059f353e7e70f9173ef5991b86d2287c5103a22f4", size = 2003161 }, + { url = "https://files.pythonhosted.org/packages/9d/29/34a9bb7bdef3fccf9635ba5c477045ab9d5c6f43e47bd30ed2661e030c1e/prismatoid-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:55bb12f959f2789e828f08f09fccf3b6df629691c26e4359cf74a0978b5e1a3a", size = 2003166 }, + { url = "https://files.pythonhosted.org/packages/23/c6/99df138eb1096e735dfec60889f546eedcbafd2c926c3fd8daea7e884a8b/prismatoid-0.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:6edc1f3c9162a2a81e7aa25898ca150701354ef1774c2d2e535ffa9c567aab26", size = 2003206 }, + { url = "https://files.pythonhosted.org/packages/b3/24/f6ecd29f2976b3373f7b1dd5f339413f2fdcb5aa27f8434f68a791b021ae/prismatoid-0.7.1-pp311-pypy311_pp73-macosx_15_0_arm64.whl", hash = "sha256:2b8395f7e3550987a1058284b0b26ad80933fb0e87b2d243d97d4d5e03500365", size = 177338 }, + { url = "https://files.pythonhosted.org/packages/57/66/4b9e852146cc58e2b942faa6d015805046de7703e57ef6d2d54895d98aa8/prismatoid-0.7.1-pp311-pypy311_pp73-macosx_15_0_x86_64.whl", hash = "sha256:02050e340eefec614dffc8195049c07f1e95fb7df1f22109ffd41df53a24183d", size = 294580 }, + { url = "https://files.pythonhosted.org/packages/e9/62/dfebb6512be6f9df677a211b718c5934b5c8f5270e7807469cd385a339ef/prismatoid-0.7.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9f801a177f6751754252847f9a36b887220c81f672985eb2f62f94c4a9fba975", size = 1553003 }, + { url = "https://files.pythonhosted.org/packages/b6/b7/d08f5bae93146f8d09f718d64da91f6bc173fbabb44f3ac3f8331018b3ef/prismatoid-0.7.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:a4c6e3bcb99dab12c3c15dfb521db0da1ae85e3803060f95f2060bda4f8abf6a", size = 1726100 }, + { url = "https://files.pythonhosted.org/packages/18/ed/574176ac85fa3eb1d7c1dd883b426abd8ad17addb7dab157e572270f3c4d/prismatoid-0.7.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:92fed4db1fc2d13b2af9b2a2cab3bd745c77bc9dc69ae2e65f5da88bd6211eb8", size = 2003173 }, +] + +[[package]] +name = "puttykeys" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/5a/6c90d271492841c73bc6587e1091cd43d1d7b7da5b66fe3b26ccebe6db73/puttykeys-1.0.3.tar.gz", hash = "sha256:c168f70e2ceb7245df9a1e8003854b44e6c6c93282b04bbbf3cd51933d45722d", size = 8091 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/a8/7d28a8308cd2be99f714341d8e092c380792edecc6c7dd15c72a7559a5e4/puttykeys-1.0.3-py2.py3-none-any.whl", hash = "sha256:e634f740f3b0eda2e49d258070445b8b192f8ab272bce799031b80a46d5a5d7e", size = 11927 }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 }, +] + +[[package]] +name = "pytest-cov" +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/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424 }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031 }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308 }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930 }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543 }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040 }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102 }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 }, +] + +[[package]] +name = "ruff" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819 }, + { url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618 }, + { url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518 }, + { url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811 }, + { url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169 }, + { url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491 }, + { url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280 }, + { url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336 }, + { url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288 }, + { url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681 }, + { url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401 }, + { url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452 }, + { url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900 }, + { url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302 }, + { url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555 }, + { url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956 }, + { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604 }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "sound-lib" +version = "0.8.8" +source = { git = "https://github.com/samtupy/sound_lib_macos_fixes.git#2b4f6ee2036928f9b4e9e87df7424014734ab4d3" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "lxml" }, + { name = "requests" }, + { name = "tqdm" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304 }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663 }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469 }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039 }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007 }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875 }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271 }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770 }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626 }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842 }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894 }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053 }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481 }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720 }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014 }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820 }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712 }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296 }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553 }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477 }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374 }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584 }, +] + +[[package]] +name = "webdavclient3" +version = "3.14.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "python-dateutil" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/d8/ca3981053ed553363322f71745f543186b93439b6417f5d6ca91d4b4fec7/webdavclient3-3.14.7.tar.gz", hash = "sha256:6c04252b579bc015cec78081480c63eadf1030f382768248777c6203f059b3f5", size = 30836 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/5e/0b1c2f494d03c4acbc44567fa68b954cd0fa3f21eb3f9528011da371f9b1/webdavclient3-3.14.7-py3-none-any.whl", hash = "sha256:a904381da8e3ae77b4ca9e11e05058d91a07704254d71c193c797f7c2fb15025", size = 22887 }, +] + +[[package]] +name = "win32more" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "win32more-appsdk" }, + { name = "win32more-core" }, + { name = "win32more-microsoft-graphics-win2d" }, + { name = "win32more-microsoft-web-webview2" }, + { name = "win32more-microsoft-windows-sdk-contracts" }, + { name = "win32more-microsoft-windows-sdk-win32metadata" }, + { name = "win32more-microsoft-windowsappsdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/01/428ea2da9f39b533fdb3705173b4436a83de466242989b3dbedb319beeee/win32more-0.8.0.tar.gz", hash = "sha256:793f19ba9777bdb4f5de36c16702a4f8d340539f466feca7bc247863007de9d6", size = 4837 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/13/9e835fba47b95fb14a9e7b9038f5a30cbe089d19781cbd1c3df3cf97d0f2/win32more-0.8.0-py3-none-any.whl", hash = "sha256:d5a188b5280442b813266a1346761fa880e2250bdd93532dc07751515b21ae9d", size = 3671 }, +] + +[[package]] +name = "win32more-appsdk" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "win32more-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/62/8202755151f8bd4a4597d879474f516732b0b2b6591a21ff9a1fcc1a96e1/win32more_appsdk-0.8.0.tar.gz", hash = "sha256:0bfbd698795d3add19316774e743cf2800835d6d27c6dc26471ebcc614baab45", size = 10827 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/29/bbbfda259e93e8702df63fe8e4d9873ee4a4285185f57a34497cda71218a/win32more_appsdk-0.8.0-py2.py3-none-any.whl", hash = "sha256:f6fe556657bb63d9bb0a7febb30e8011c0b7aaaf408afe40751beaf8385401ef", size = 12140 }, +] + +[[package]] +name = "win32more-core" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/33/eb613b2bab588089ac46d9ff831eecae3165d445299804a1dc9403f52d78/win32more_core-0.8.0.tar.gz", hash = "sha256:04af7281b86f8822be41bd48d4519dcc3445c1f7d0ff9cee29ad386309759585", size = 21598 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/55/6433b9f416b5ab669faf3e2ec0d490b5887250a204c9d4dd26a5dc172e21/win32more_core-0.8.0-py2.py3-none-any.whl", hash = "sha256:1718a785af1ffe481771f8a23d555514e2816752ed6d1020b98f8179d8dfc0c1", size = 25849 }, +] + +[[package]] +name = "win32more-microsoft-graphics-win2d" +version = "0.8.1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "win32more-core" }, + { name = "win32more-microsoft-windowsappsdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1f/fd8fbd8fab6f31e9745734e87abd77b1cdb1090de3d29520eb9f5f6348ff/win32more_microsoft_graphics_win2d-0.8.1.3.2.tar.gz", hash = "sha256:067fe6b8793607f3116924d115162dd7d158b4761edd304f62a4d063c9b4d115", size = 1906474 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/f0/10ec842d00e7e255ac9ae9cbcbe8f2e45e9d7edab83ccdfac4830eaa3214/win32more_microsoft_graphics_win2d-0.8.1.3.2-py2.py3-none-any.whl", hash = "sha256:6f8981ac1e002986a45f7ee1dfeceec375defc353330ca71064bcdb6b5032990", size = 1964022 }, +] + +[[package]] +name = "win32more-microsoft-web-webview2" +version = "0.8.1.0.3719.77" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "win32more-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/f3/c520671c3714de457b63bf132c889499cfbc36c44fecaacbf51ebf3a427f/win32more_microsoft_web_webview2-0.8.1.0.3719.77.tar.gz", hash = "sha256:d4e00c3f6d0db5428c1811c0b3c83b4e8d3a24a5311963b3fbecccd8cd0db366", size = 838738 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/be/9908385b79b50fe6035a174fa838d7e30b62604f0e4050d8c4026e684610/win32more_microsoft_web_webview2-0.8.1.0.3719.77-py2.py3-none-any.whl", hash = "sha256:d9d27475ad33b8f7e0cbd456b296621b3e14a914a919761f9dbe8b44f0173143", size = 860823 }, +] + +[[package]] +name = "win32more-microsoft-windows-sdk-contracts" +version = "0.8.10.0.26100.7705" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "win32more-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/95/126b9cdbc7787731f98b4c342dd1749b56157bf0abff5caec5f22708dfa4/win32more_microsoft_windows_sdk_contracts-0.8.10.0.26100.7705.tar.gz", hash = "sha256:45877a2108cadf24c7a2731478fb4077f9e3519e58d41e35bd6edf3e964bea60", size = 1637805 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/0d/ecd52d6a382f1ac1130e560e84e65b7907b7e5e877debc88f489b8477525/win32more_microsoft_windows_sdk_contracts-0.8.10.0.26100.7705-py2.py3-none-any.whl", hash = "sha256:0902a4fc22e645ad4d6862bb413fe4169a793a4f10fc3beb55a0db1f38675e2e", size = 1807772 }, +] + +[[package]] +name = "win32more-microsoft-windows-sdk-win32metadata" +version = "0.8.69.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "win32more-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/ba/6f878d388485279bd261ee24c7be58b1e5ada7811939959b5abda6d5ee0a/win32more_microsoft_windows_sdk_win32metadata-0.8.69.0.7.tar.gz", hash = "sha256:6bd3dc0dcac629e116d82a3f5108550dcb0280a18b76b9b1db2b5af65b1287fb", size = 3881452 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/aa/a2d087d17ff89e746ae21169583a64ec90ebec93f8091326b2430d2b6df0/win32more_microsoft_windows_sdk_win32metadata-0.8.69.0.7-py2.py3-none-any.whl", hash = "sha256:06be05e3d160828bf4b46edc14baeb8e35aacc16d27dafdea7b5179e59f3baec", size = 4026046 }, +] + +[[package]] +name = "win32more-microsoft-windowsappsdk" +version = "0.8.1.8.260209005" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "win32more-core" }, + { name = "win32more-microsoft-web-webview2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/63/ca74a5617692b576be9df477ba6fb54b0c83b2374c34c8955515c36a3676/win32more_microsoft_windowsappsdk-0.8.1.8.260209005.tar.gz", hash = "sha256:2148dfe0d51ebef8ee1f139f3d5ac4af67e5a4e4a7818f5d54039bbdc23a911a", size = 1157559 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/1a/e2e02f7c9f55639367976f3e406d2f2a4dbafb5b0cd3c46bbbea03e7b94d/win32more_microsoft_windowsappsdk-0.8.1.8.260209005-py2.py3-none-any.whl", hash = "sha256:a073a6c21b3e8946ac25a35c6424855aea7bff08c0356d592b235bcfe3d76d05", size = 1214540 }, +] + +[[package]] +name = "wxpython" +version = "4.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/6e/b70e6dbdd7cb4f154b7ca424b4c7799f7b067f7a9f4204b8d16d6464648f/wxpython-4.2.4.tar.gz", hash = "sha256:2eb123979c87bcb329e8a2452269d60ff8f9f651e9bf25c67579e53c4ebbae3c", size = 58583054 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/c0/c2d0e427cc2f071f7426086fc4f5b9d0a41fa379fc60c692bf97a668fbeb/wxpython-4.2.4-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:99478006f60ecf622ba55f8cda9177d3f2933f513589404a1e1c87eb8b0924b6", size = 18730131 }, + { url = "https://files.pythonhosted.org/packages/73/83/6d35eabecaf8856280aa5d0d87f3194bb36733ba0c745989f4bce0aeaebb/wxpython-4.2.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ec7d264dbb2301fdb6ee7bbe722652b06ab1aa4b6e25a76cba99d8a38aea8855", size = 17827962 }, + { url = "https://files.pythonhosted.org/packages/b0/c3/2c34ec1796592f4dc393a7153131a770e117054cd07e7ba1e5594c6078b2/wxpython-4.2.4-cp311-cp311-win32.whl", hash = "sha256:b86b0258074f4ec16b234274fa0c32c42e039124c9f6f36529091c9a00ebff4d", size = 14497499 }, + { url = "https://files.pythonhosted.org/packages/4a/13/20311125881142802ca3f2fc743e82696dee562f26d7e35da649c26de7cc/wxpython-4.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:f48fe7b9f22c7733a06b5901559b5b7e4835fa852cbef0f620ffd0a0030aaf73", size = 16538730 }, + { url = "https://files.pythonhosted.org/packages/0c/35/b25b712097115ba734adafc26ac543840da90dc2f1b0c67257fed3d3ba63/wxpython-4.2.4-cp311-cp311-win_arm64.whl", hash = "sha256:fe83813241dfb94780f18dff1678d1ffd097116a8b8fea0272a332c387f69ef8", size = 15524815 }, + { url = "https://files.pythonhosted.org/packages/eb/83/4359885c6f390235fefffb01bec0c1aa24a61cdcdbba0e857a5dcfbd5042/wxpython-4.2.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a42807f84d504554a78bf7c4d0b8b18ec72de098b578bd4276bf5144b5a698ef", size = 18773068 }, + { url = "https://files.pythonhosted.org/packages/90/d8/9d55ef72e004d70a395402391aa5f9bd362253dd560f4c934efb02abe4f6/wxpython-4.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8b1c5f5c173a90c861f4f3453f2e066e29f258472c76d40f8e2ec16b0389971f", size = 17836963 }, + { url = "https://files.pythonhosted.org/packages/ef/1f/ddbb597c3d821d4206f85234736a4f19ef1b8875eefbf8d072ffcc01c1a4/wxpython-4.2.4-cp312-cp312-win32.whl", hash = "sha256:83e45e4d5d139638260c2f23108a94cb8d40bd5eb714d41b1009452e4ec4229a", size = 14507902 }, + { url = "https://files.pythonhosted.org/packages/90/c3/dfe74d7eb046612a3e475dce8ffda70341516129a7cb17fd60c8ae143304/wxpython-4.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:c333113be1fcb4e4252890b3e66af016f03432b2fe15b370f38f2a505f5daa74", size = 16549996 }, + { url = "https://files.pythonhosted.org/packages/bb/29/5286f960de8079264e7a89ca4b4beae86844520b9521a1497c4908a8cae6/wxpython-4.2.4-cp312-cp312-win_arm64.whl", hash = "sha256:88b7e8cbdb141ebb4e361cd6f3d5595160764f0c05d2d7e03506b53860eb01ab", size = 15534326 }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276 }, +]