Compare commits

...

5 Commits

Author SHA1 Message Date
Keith Edmunds
b8fcc79f8e Black formatting and mypy fixups 2024-08-04 17:18:08 +01:00
Keith Edmunds
27012a9658 Fix non-release of player when at natural end of track 2024-08-04 11:57:46 +01:00
Keith Edmunds
1d5fe3e57e Update black 2024-08-04 11:51:53 +01:00
Keith Edmunds
40cad1c98f Fix resource leak
After around 1.5h of operation, we'd get messages such as:

vlcpulse audio output error: PulseAudio server connection failure: Connection terminated

Tracked down to not correctly releasing vlc player resources when
track had finished playing. Fixed now, and much simplified the fadeout
code as well.
2024-08-02 18:35:33 +01:00
Keith Edmunds
5f5bb27a5f . 2024-08-02 18:35:33 +01:00
5 changed files with 242 additions and 210 deletions

View File

@ -6,7 +6,6 @@ from dataclasses import dataclass, field
import datetime as dt
from enum import auto, Enum
import platform
import threading
from time import sleep
from typing import Any, Optional, NamedTuple
@ -19,9 +18,7 @@ import vlc # type: ignore
from PyQt6.QtCore import (
pyqtSignal,
QObject,
QRunnable,
QThread,
QThreadPool,
)
from pyqtgraph import PlotWidget
from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore
@ -37,18 +34,18 @@ from helpers import (
show_warning,
singleton,
)
lock = threading.Lock()
from vlcmanager import VLCManager
# Define the VLC callback function type
VLC_LOG_CB = ctypes.CFUNCTYPE(
None,
ctypes.c_void_p,
ctypes.c_int,
ctypes.c_void_p,
ctypes.c_char_p,
ctypes.c_void_p,
)
# VLC logging is very noisy so comment out unless needed
# VLC_LOG_CB = ctypes.CFUNCTYPE(
# None,
# ctypes.c_void_p,
# ctypes.c_int,
# ctypes.c_void_p,
# ctypes.c_char_p,
# ctypes.c_void_p,
# )
# Determine the correct C library for vsnprintf based on the platform
if platform.system() == "Windows":
@ -138,8 +135,8 @@ class _FadeCurve:
0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.end_ms: int = track_silence_at
self.audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(self.audio_segment.get_array_of_samples())
audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(audio_segment.get_array_of_samples())
# Calculate the factor to map milliseconds of track to array
self.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms)
@ -181,11 +178,14 @@ class _FadeCurve:
# Update region position
if self.region:
log.debug("issue223: _FadeCurve: update region")
# Next line is very noisy
# log.debug("issue223: _FadeCurve: update region")
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
class _FadeTrack(QRunnable):
class _FadeTrack(QThread):
finished = pyqtSignal()
def __init__(self, player: vlc.MediaPlayer, fade_seconds: int) -> None:
super().__init__()
self.player = player
@ -201,18 +201,22 @@ class _FadeTrack(QRunnable):
# Reduce volume logarithmically
total_steps = self.fade_seconds * Config.FADEOUT_STEPS_PER_SECOND
db_reduction_per_step = Config.FADEOUT_DB / total_steps
reduction_factor_per_step = pow(10, (db_reduction_per_step / 20))
if total_steps > 0:
db_reduction_per_step = Config.FADEOUT_DB / total_steps
reduction_factor_per_step = pow(10, (db_reduction_per_step / 20))
volume = self.player.audio_get_volume()
volume = self.player.audio_get_volume()
for i in range(1, total_steps + 1):
self.player.audio_set_volume(
int(volume * pow(reduction_factor_per_step, i))
)
sleep(1 / Config.FADEOUT_STEPS_PER_SECOND)
for i in range(1, total_steps + 1):
self.player.audio_set_volume(
int(volume * pow(reduction_factor_per_step, i))
)
sleep(1 / Config.FADEOUT_STEPS_PER_SECOND)
self.player.stop()
self.finished.emit()
vlc_instance = VLCManager().vlc_instance
class _Music:
@ -221,38 +225,37 @@ class _Music:
"""
def __init__(self, name: str) -> None:
self.VLC = vlc.Instance()
self.VLC.set_user_agent(name, name)
vlc_instance.set_user_agent(name, name)
self.player: Optional[vlc.MediaPlayer] = None
self.name = name
self.max_volume: int = Config.VLC_VOLUME_DEFAULT
self.start_dt: Optional[dt.datetime] = None
self.player_count: int = 0
# Set up logging
self._set_vlc_log()
# self._set_vlc_log()
@VLC_LOG_CB
def log_callback(data, level, ctx, fmt, args):
try:
# Create a ctypes string buffer to hold the formatted message
buf = ctypes.create_string_buffer(1024)
# VLC logging very noisy so comment out unless needed
# @VLC_LOG_CB
# def log_callback(data, level, ctx, fmt, args):
# try:
# # Create a ctypes string buffer to hold the formatted message
# buf = ctypes.create_string_buffer(1024)
# Use vsnprintf to format the string with the va_list
libc.vsnprintf(buf, len(buf), fmt, args)
# # Use vsnprintf to format the string with the va_list
# libc.vsnprintf(buf, len(buf), fmt, args)
# Decode the formatted message
message = buf.value.decode("utf-8", errors="replace")
log.debug("VLC: " + message)
except Exception as e:
log.error(f"Error in VLC log callback: {e}")
# # Decode the formatted message
# message = buf.value.decode("utf-8", errors="replace")
# log.debug("VLC: " + message)
# except Exception as e:
# log.error(f"Error in VLC log callback: {e}")
def _set_vlc_log(self):
try:
vlc.libvlc_log_set(self.VLC, self.log_callback, None)
log.debug("VLC logging set up successfully")
except Exception as e:
log.error(f"Failed to set up VLC logging: {e}")
# def _set_vlc_log(self):
# try:
# vlc.libvlc_log_set(vlc_instance, self.log_callback, None)
# log.debug("VLC logging set up successfully")
# except Exception as e:
# log.error(f"Failed to set up VLC logging: {e}")
def adjust_by_ms(self, ms: int) -> None:
"""Move player position by ms milliseconds"""
@ -275,27 +278,6 @@ class _Music:
else:
self.start_dt = dt.datetime.now() - dt.timedelta(milliseconds=ms)
def stop(self) -> None:
"""Immediately stop playing"""
log.debug(f"Music[{self.name}].stop()")
self.start_dt = None
if not self.player:
return
p = self.player
self.player = None
self.start_dt = None
with lock:
p.stop()
p.release()
self.player_count -= 1
log.debug(f"_Music.stop: Releasing player {p=}, {self.player_count=}")
p = None
def fade(self, fade_seconds: int) -> None:
"""
Fade the currently playing track.
@ -310,23 +292,10 @@ class _Music:
if not self.player.get_position() > 0 and self.player.is_playing():
return
if fade_seconds <= 0:
self.stop()
return
# Take a copy of current player to allow another track to be
# started without interfering here
with lock:
p = self.player
self.player = None
pool = QThreadPool.globalInstance()
if pool:
fader = _FadeTrack(p, fade_seconds=fade_seconds)
pool.start(fader)
self.start_dt = None
else:
log.error("_Music: failed to allocate QThreadPool")
self.fader_worker = _FadeTrack(self.player, fade_seconds=fade_seconds)
self.fader_worker.finished.connect(self.player.release)
self.fader_worker.start()
self.start_dt = None
def get_playtime(self) -> int:
"""
@ -388,38 +357,35 @@ class _Music:
log.error(f"play({path}): path not readable")
return None
media = self.VLC.media_new_path(path)
if media is None:
log.error(f"_Music:play: failed to create media ({path=})")
show_warning(None, "Error loading file", f"Cannot play file ({path})")
self.player = vlc.MediaPlayer(vlc_instance, path)
if self.player is None:
log.error(f"_Music:play: failed to create MediaPlayer ({path=})")
show_warning(
None, "Error creating MediaPlayer", f"Cannot play file ({path})"
)
return
self.player = media.player_new_from_media()
if self.player:
_ = self.player.play()
self.set_volume(self.max_volume)
self.player_count += 1
log.debug(f"_Music.play: {self.player_count=}")
if position:
self.player.set_position(position)
self.start_dt = start_time
_ = self.player.play()
self.set_volume(self.max_volume)
# For as-yet unknown reasons. sometimes the volume gets
# reset to zero within 200mS or so of starting play. This
# only happened since moving to Debian 12, which uses
# Pipewire for sound (which may be irrelevant).
# It has been known for the volume to need correcting more
# than once in the first 200mS.
for _ in range(3):
if self.player:
volume = self.player.audio_get_volume()
if volume < Config.VLC_VOLUME_DEFAULT:
self.set_volume(Config.VLC_VOLUME_DEFAULT)
log.error(f"Reset from {volume=}")
sleep(0.1)
else:
log.error("_Music:play: failed to create media player")
show_warning(None, "Media player", "Unable to create media player")
if position:
self.player.set_position(position)
self.start_dt = start_time
# For as-yet unknown reasons. sometimes the volume gets
# reset to zero within 200mS or so of starting play. This
# only happened since moving to Debian 12, which uses
# Pipewire for sound (which may be irrelevant).
# It has been known for the volume to need correcting more
# than once in the first 200mS.
# Update August 2024: This no longer seems to be an issue
# for _ in range(3):
# if self.player:
# volume = self.player.audio_get_volume()
# if volume < Config.VLC_VOLUME_DEFAULT:
# self.set_volume(Config.VLC_VOLUME_DEFAULT)
# log.error(f"Reset from {volume=}")
# sleep(0.1)
def set_position(self, position: float) -> None:
"""
@ -456,6 +422,21 @@ class _Music:
log.debug(f"Reset from {volume=}")
sleep(0.1)
def stop(self) -> None:
"""Immediately stop playing"""
log.debug(f"Music[{self.name}].stop()")
self.start_dt = None
if not self.player:
return
if self.player.is_playing():
self.player.stop()
self.player.release()
self.player = None
@singleton
@dataclass
@ -514,7 +495,9 @@ class RowAndTrack:
self.fade_at = playlist_row.track.fade_at
self.intro = playlist_row.track.intro
if playlist_row.track.playdates:
self.lastplayed = max([a.lastplayed for a in playlist_row.track.playdates])
self.lastplayed = max(
[a.lastplayed for a in playlist_row.track.playdates]
)
else:
self.lastplayed = Config.EPOCH
self.path = playlist_row.track.path
@ -544,6 +527,7 @@ class RowAndTrack:
self.start_time: Optional[dt.datetime] = None
# Other object initialisation
self.music = _Music(name=Config.VLC_MAIN_PLAYER_NAME)
self.signals = MusicMusterSignals()
def __repr__(self) -> str:
@ -564,12 +548,16 @@ class RowAndTrack:
if self.end_of_track_signalled:
return
if not self.player.is_playing():
self.start_time = None
if self.fade_graph:
self.fade_graph.clear()
self.signal_end_of_track()
self.end_of_track_signalled = True
if self.music.is_playing():
return
self.start_time = None
if self.fade_graph:
self.fade_graph.clear()
# Ensure that player is released
self.music.fade(0)
self.signals.track_ended_signal.emit()
self.end_of_track_signalled = True
def create_fade_graph(self) -> None:
"""
@ -596,16 +584,16 @@ class RowAndTrack:
"""
if enable:
self.player.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False)
self.music.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False)
else:
self.player.set_volume(volume=Config.VLC_VOLUME_DEFAULT, set_default=False)
self.music.set_volume(volume=Config.VLC_VOLUME_DEFAULT, set_default=False)
def fade(self, fade_seconds: int = Config.FADEOUT_SECONDS) -> None:
"""Fade music"""
self.resume_marker = self.player.get_position()
self.player.fade(fade_seconds)
self.signal_end_of_track()
self.resume_marker = self.music.get_position()
self.music.fade(fade_seconds)
self.signals.track_ended_signal.emit()
def is_playing(self) -> bool:
"""
@ -615,21 +603,21 @@ class RowAndTrack:
if self.start_time is None:
return False
return self.player.is_playing()
return self.music.is_playing()
def move_back(self, ms: int = Config.PREVIEW_BACK_MS) -> None:
"""
Rewind player by ms milliseconds
"""
self.player.adjust_by_ms(ms * -1)
self.music.adjust_by_ms(ms * -1)
def move_forward(self, ms: int = Config.PREVIEW_ADVANCE_MS) -> None:
"""
Rewind player by ms milliseconds
"""
self.player.adjust_by_ms(ms)
self.music.adjust_by_ms(ms)
def play(self, position: Optional[float] = None) -> None:
"""Play track"""
@ -639,8 +627,7 @@ class RowAndTrack:
self.start_time = now
# Initialise player
self.player = _Music(name=Config.VLC_MAIN_PLAYER_NAME)
self.player.play(self.path, start_time=now, position=position)
self.music.play(self.path, start_time=now, position=position)
self.end_time = now + dt.timedelta(milliseconds=self.duration)
@ -658,7 +645,7 @@ class RowAndTrack:
Restart player
"""
self.player.adjust_by_ms(self.time_playing() * -1)
self.music.adjust_by_ms(self.time_playing() * -1)
def set_forecast_start_time(
self, modified_rows: list[int], start: Optional[dt.datetime]
@ -693,19 +680,12 @@ class RowAndTrack:
return new_start_time
def signal_end_of_track(self) -> None:
"""
Send end of track signal unless we are a preview player
"""
self.signals.track_ended_signal.emit()
def stop(self, fade_seconds: int = 0) -> None:
"""
Stop this track playing
"""
self.resume_marker = self.player.get_position()
self.resume_marker = self.music.get_position()
self.fade(fade_seconds)
# Reset fade graph
@ -720,7 +700,7 @@ class RowAndTrack:
if self.start_time is None:
return 0
return self.player.get_playtime()
return self.music.get_playtime()
def time_remaining_intro(self) -> int:
"""

View File

@ -316,6 +316,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.action_quicklog.activated.connect(self.quicklog)
self.load_last_playlists()
self.stop_autoplay = False
def about(self) -> None:
"""Get git tag and database name"""
@ -659,9 +660,13 @@ class Window(QMainWindow, Ui_MainWindow):
- Enable controls
"""
# Reset track_sequence objects
track_sequence.previous = track_sequence.current
track_sequence.current = None
if track_sequence.current:
# Dereference the fade curve so it can be garbage collected
track_sequence.current.fade_graph = None
# Reset track_sequence objects
track_sequence.previous = track_sequence.current
track_sequence.current = None
# Tell model previous track has finished
self.active_proxy_model().previous_track_ended()
@ -669,7 +674,6 @@ class Window(QMainWindow, Ui_MainWindow):
# Reset clocks
self.frame_fade.setStyleSheet("")
self.frame_silent.setStyleSheet("")
self.label_elapsed_timer.setText("00:00 / 00:00")
self.label_fade_timer.setText("00:00")
self.label_silent_timer.setText("00:00")
@ -680,6 +684,10 @@ class Window(QMainWindow, Ui_MainWindow):
self.catch_return_key = False
self.show_status_message("Play controls: Enabled", 0)
# autoplay
# if not self.stop_autoplay:
# self.play_next()
def export_playlist_tab(self) -> None:
"""Export the current playlist to an m3u file"""
@ -1053,7 +1061,7 @@ class Window(QMainWindow, Ui_MainWindow):
if Config.USE_INTERNAL_BROWSER:
self.tabInfolist.open_tab(url, title)
else:
webbrowser.get('browser').open_new_tab(url)
webbrowser.get("browser").open_new_tab(url)
def open_wikipedia_browser(self, title: str) -> None:
"""Search Wikipedia for title"""
@ -1065,7 +1073,7 @@ class Window(QMainWindow, Ui_MainWindow):
if Config.USE_INTERNAL_BROWSER:
self.tabInfolist.open_tab(url, title)
else:
webbrowser.get('browser').open_new_tab(url)
webbrowser.get("browser").open_new_tab(url)
def paste_rows(self) -> None:
"""
@ -1151,6 +1159,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.btnDrop3db.setChecked(False)
# Play (new) current track
log.info(f"Play: {track_sequence.current.title}")
track_sequence.current.play(position)
# Update clocks now, don't wait for next tick
@ -1159,7 +1168,9 @@ class Window(QMainWindow, Ui_MainWindow):
# Show closing volume graph
if track_sequence.current.fade_graph:
log.debug(f"issue223: play_next: set up fade_graph, {track_sequence.current.title=}")
log.debug(
f"issue223: play_next: set up fade_graph, {track_sequence.current.title=}"
)
track_sequence.current.fade_graph.GraphWidget = self.widgetFadeVolume
track_sequence.current.fade_graph.clear()
track_sequence.current.fade_graph.plot()
@ -1200,7 +1211,7 @@ class Window(QMainWindow, Ui_MainWindow):
if not track_info:
# Otherwise get track_id to next track to play
if track_sequence.next:
if track_sequence.next.path:
if track_sequence.next.track_id:
track_info = TrackInfo(
track_sequence.next.track_id, track_sequence.next.row_number
)
@ -1412,7 +1423,8 @@ class Window(QMainWindow, Ui_MainWindow):
return
# Return if no saved position
if not track_sequence.previous.resume_marker:
resume_marker = track_sequence.previous.resume_marker
if not resume_marker:
log.error("No previous track position")
return
@ -1421,7 +1433,7 @@ class Window(QMainWindow, Ui_MainWindow):
track_sequence.set_next(track_sequence.previous)
# Now resume playing the now-next track
self.play_next(track_sequence.next.resume_marker)
self.play_next(resume_marker)
# Adjust track info so that clocks and graph are correct.
# We need to fake the start time to reflect where we resumed the
@ -1623,6 +1635,7 @@ class Window(QMainWindow, Ui_MainWindow):
def stop(self) -> None:
"""Stop playing immediately"""
self.stop_autoplay = True
if track_sequence.current:
track_sequence.current.stop()

View File

@ -561,7 +561,7 @@ class PlaylistModel(QAbstractTableModel):
for a in self.playlist_rows.values()
if not a.played and a.track_id is not None
]
log.debug(f"{self}: get_unplayed_rows() returned: {result=}")
# log.debug(f"{self}: get_unplayed_rows() returned: {result=}")
return result
def headerData(
@ -884,7 +884,9 @@ class PlaylistModel(QAbstractTableModel):
Move existing_rat track to new_row_number and append note to any existing note
"""
log.debug(f"{self}: move_track_add_note({new_row_number=}, {existing_rat=}, {note=}")
log.debug(
f"{self}: move_track_add_note({new_row_number=}, {existing_rat=}, {note=}"
)
if note:
with db.Session() as session:
@ -911,7 +913,9 @@ class PlaylistModel(QAbstractTableModel):
Add the existing_rat track details to the existing header at header_row_number
"""
log.debug(f"{self}: move_track_to_header({header_row_number=}, {existing_rat=}, {note=}")
log.debug(
f"{self}: move_track_to_header({header_row_number=}, {existing_rat=}, {note=}"
)
if existing_rat.track_id:
if note and existing_rat.note:
@ -968,7 +972,9 @@ class PlaylistModel(QAbstractTableModel):
# Sanity check
if not track_sequence.previous:
log.error(f"{self}: playlistmodel:previous_track_ended called with no current track")
log.error(
f"{self}: playlistmodel:previous_track_ended called with no current track"
)
return
if track_sequence.previous.row_number is None:
log.error(
@ -1243,7 +1249,9 @@ class PlaylistModel(QAbstractTableModel):
PlaylistRows, self.playlist_rows[row_number].playlistrow_id
)
if not playlist_row:
log.error(f"{self}: Error saving data: {row_number=}, {column=}, {value=}")
log.error(
f"{self}: Error saving data: {row_number=}, {column=}, {value=}"
)
return False
if playlist_row.track_id:
@ -1294,7 +1302,9 @@ class PlaylistModel(QAbstractTableModel):
shortlist_rows = {k: self.playlist_rows[k] for k in row_numbers}
sorted_list = [
playlist_row.row_number
for playlist_row in sorted(shortlist_rows.values(), key=attrgetter(attr_name))
for playlist_row in sorted(
shortlist_rows.values(), key=attrgetter(attr_name)
)
]
self.move_rows(sorted_list, min(sorted_list))

29
app/vlcmanager.py Normal file
View File

@ -0,0 +1,29 @@
# Standard library imports
# PyQt imports
# Third party imports
import vlc # type: ignore
# App imports
class VLCManager:
"""
Singleton class to ensure we only ever have one vlc Instance
"""
__instance = None
def __init__(self) -> None:
if VLCManager.__instance is None:
self.vlc_instance = vlc.Instance()
VLCManager.__instance = self
else:
raise Exception("Attempted to create a second VLCManager instance")
@staticmethod
def get_instance() -> vlc.Instance:
if VLCManager.__instance is None:
VLCManager()
return VLCManager.__instance

104
poetry.lock generated
View File

@ -81,33 +81,33 @@ dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"]
[[package]]
name = "black"
version = "24.4.2"
version = "24.8.0"
description = "The uncompromising code formatter."
optional = false
python-versions = ">=3.8"
files = [
{file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"},
{file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"},
{file = "black-24.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063"},
{file = "black-24.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96"},
{file = "black-24.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474"},
{file = "black-24.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c"},
{file = "black-24.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb"},
{file = "black-24.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1"},
{file = "black-24.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d"},
{file = "black-24.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04"},
{file = "black-24.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc"},
{file = "black-24.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0"},
{file = "black-24.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7"},
{file = "black-24.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94"},
{file = "black-24.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8"},
{file = "black-24.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c"},
{file = "black-24.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1"},
{file = "black-24.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741"},
{file = "black-24.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e"},
{file = "black-24.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7"},
{file = "black-24.4.2-py3-none-any.whl", hash = "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c"},
{file = "black-24.4.2.tar.gz", hash = "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d"},
{file = "black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6"},
{file = "black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb"},
{file = "black-24.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42"},
{file = "black-24.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a"},
{file = "black-24.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1"},
{file = "black-24.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af"},
{file = "black-24.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4"},
{file = "black-24.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af"},
{file = "black-24.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368"},
{file = "black-24.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed"},
{file = "black-24.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018"},
{file = "black-24.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2"},
{file = "black-24.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd"},
{file = "black-24.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2"},
{file = "black-24.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e"},
{file = "black-24.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920"},
{file = "black-24.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c"},
{file = "black-24.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e"},
{file = "black-24.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47"},
{file = "black-24.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb"},
{file = "black-24.8.0-py3-none-any.whl", hash = "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed"},
{file = "black-24.8.0.tar.gz", hash = "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f"},
]
[package.dependencies]
@ -864,43 +864,43 @@ files = [
[[package]]
name = "mypy"
version = "1.10.1"
version = "1.11.1"
description = "Optional static typing for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "mypy-1.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e36f229acfe250dc660790840916eb49726c928e8ce10fbdf90715090fe4ae02"},
{file = "mypy-1.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:51a46974340baaa4145363b9e051812a2446cf583dfaeba124af966fa44593f7"},
{file = "mypy-1.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:901c89c2d67bba57aaaca91ccdb659aa3a312de67f23b9dfb059727cce2e2e0a"},
{file = "mypy-1.10.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0cd62192a4a32b77ceb31272d9e74d23cd88c8060c34d1d3622db3267679a5d9"},
{file = "mypy-1.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:a2cbc68cb9e943ac0814c13e2452d2046c2f2b23ff0278e26599224cf164e78d"},
{file = "mypy-1.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bd6f629b67bb43dc0d9211ee98b96d8dabc97b1ad38b9b25f5e4c4d7569a0c6a"},
{file = "mypy-1.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1bbb3a6f5ff319d2b9d40b4080d46cd639abe3516d5a62c070cf0114a457d84"},
{file = "mypy-1.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8edd4e9bbbc9d7b79502eb9592cab808585516ae1bcc1446eb9122656c6066f"},
{file = "mypy-1.10.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6166a88b15f1759f94a46fa474c7b1b05d134b1b61fca627dd7335454cc9aa6b"},
{file = "mypy-1.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:5bb9cd11c01c8606a9d0b83ffa91d0b236a0e91bc4126d9ba9ce62906ada868e"},
{file = "mypy-1.10.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d8681909f7b44d0b7b86e653ca152d6dff0eb5eb41694e163c6092124f8246d7"},
{file = "mypy-1.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:378c03f53f10bbdd55ca94e46ec3ba255279706a6aacaecac52ad248f98205d3"},
{file = "mypy-1.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bacf8f3a3d7d849f40ca6caea5c055122efe70e81480c8328ad29c55c69e93e"},
{file = "mypy-1.10.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:701b5f71413f1e9855566a34d6e9d12624e9e0a8818a5704d74d6b0402e66c04"},
{file = "mypy-1.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c4c2992f6ea46ff7fce0072642cfb62af7a2484efe69017ed8b095f7b39ef31"},
{file = "mypy-1.10.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:604282c886497645ffb87b8f35a57ec773a4a2721161e709a4422c1636ddde5c"},
{file = "mypy-1.10.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37fd87cab83f09842653f08de066ee68f1182b9b5282e4634cdb4b407266bade"},
{file = "mypy-1.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8addf6313777dbb92e9564c5d32ec122bf2c6c39d683ea64de6a1fd98b90fe37"},
{file = "mypy-1.10.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5cc3ca0a244eb9a5249c7c583ad9a7e881aa5d7b73c35652296ddcdb33b2b9c7"},
{file = "mypy-1.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:1b3a2ffce52cc4dbaeee4df762f20a2905aa171ef157b82192f2e2f368eec05d"},
{file = "mypy-1.10.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fe85ed6836165d52ae8b88f99527d3d1b2362e0cb90b005409b8bed90e9059b3"},
{file = "mypy-1.10.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2ae450d60d7d020d67ab440c6e3fae375809988119817214440033f26ddf7bf"},
{file = "mypy-1.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6be84c06e6abd72f960ba9a71561c14137a583093ffcf9bbfaf5e613d63fa531"},
{file = "mypy-1.10.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2189ff1e39db399f08205e22a797383613ce1cb0cb3b13d8bcf0170e45b96cc3"},
{file = "mypy-1.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:97a131ee36ac37ce9581f4220311247ab6cba896b4395b9c87af0675a13a755f"},
{file = "mypy-1.10.1-py3-none-any.whl", hash = "sha256:71d8ac0b906354ebda8ef1673e5fde785936ac1f29ff6987c7483cfbd5a4235a"},
{file = "mypy-1.10.1.tar.gz", hash = "sha256:1f8f492d7db9e3593ef42d4f115f04e556130f2819ad33ab84551403e97dd4c0"},
{file = "mypy-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a32fc80b63de4b5b3e65f4be82b4cfa362a46702672aa6a0f443b4689af7008c"},
{file = "mypy-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c1952f5ea8a5a959b05ed5f16452fddadbaae48b5d39235ab4c3fc444d5fd411"},
{file = "mypy-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1e30dc3bfa4e157e53c1d17a0dad20f89dc433393e7702b813c10e200843b03"},
{file = "mypy-1.11.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2c63350af88f43a66d3dfeeeb8d77af34a4f07d760b9eb3a8697f0386c7590b4"},
{file = "mypy-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:a831671bad47186603872a3abc19634f3011d7f83b083762c942442d51c58d58"},
{file = "mypy-1.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7b6343d338390bb946d449677726edf60102a1c96079b4f002dedff375953fc5"},
{file = "mypy-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4fe9f4e5e521b458d8feb52547f4bade7ef8c93238dfb5bbc790d9ff2d770ca"},
{file = "mypy-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886c9dbecc87b9516eff294541bf7f3655722bf22bb898ee06985cd7269898de"},
{file = "mypy-1.11.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fca4a60e1dd9fd0193ae0067eaeeb962f2d79e0d9f0f66223a0682f26ffcc809"},
{file = "mypy-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:0bd53faf56de9643336aeea1c925012837432b5faf1701ccca7fde70166ccf72"},
{file = "mypy-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f39918a50f74dc5969807dcfaecafa804fa7f90c9d60506835036cc1bc891dc8"},
{file = "mypy-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bc71d1fb27a428139dd78621953effe0d208aed9857cb08d002280b0422003a"},
{file = "mypy-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b868d3bcff720dd7217c383474008ddabaf048fad8d78ed948bb4b624870a417"},
{file = "mypy-1.11.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a707ec1527ffcdd1c784d0924bf5cb15cd7f22683b919668a04d2b9c34549d2e"},
{file = "mypy-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:64f4a90e3ea07f590c5bcf9029035cf0efeae5ba8be511a8caada1a4893f5525"},
{file = "mypy-1.11.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:749fd3213916f1751fff995fccf20c6195cae941dc968f3aaadf9bb4e430e5a2"},
{file = "mypy-1.11.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b639dce63a0b19085213ec5fdd8cffd1d81988f47a2dec7100e93564f3e8fb3b"},
{file = "mypy-1.11.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c956b49c5d865394d62941b109728c5c596a415e9c5b2be663dd26a1ff07bc0"},
{file = "mypy-1.11.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45df906e8b6804ef4b666af29a87ad9f5921aad091c79cc38e12198e220beabd"},
{file = "mypy-1.11.1-cp38-cp38-win_amd64.whl", hash = "sha256:d44be7551689d9d47b7abc27c71257adfdb53f03880841a5db15ddb22dc63edb"},
{file = "mypy-1.11.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2684d3f693073ab89d76da8e3921883019ea8a3ec20fa5d8ecca6a2db4c54bbe"},
{file = "mypy-1.11.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79c07eb282cb457473add5052b63925e5cc97dfab9812ee65a7c7ab5e3cb551c"},
{file = "mypy-1.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11965c2f571ded6239977b14deebd3f4c3abd9a92398712d6da3a772974fad69"},
{file = "mypy-1.11.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a2b43895a0f8154df6519706d9bca8280cda52d3d9d1514b2d9c3e26792a0b74"},
{file = "mypy-1.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:1a81cf05975fd61aec5ae16501a091cfb9f605dc3e3c878c0da32f250b74760b"},
{file = "mypy-1.11.1-py3-none-any.whl", hash = "sha256:0624bdb940255d2dd24e829d99a13cfeb72e4e9031f9492148f410ed30bcab54"},
{file = "mypy-1.11.1.tar.gz", hash = "sha256:f404a0b069709f18bbdb702eb3dcfe51910602995de00bd39cea3050b5772d08"},
]
[package.dependencies]
mypy-extensions = ">=1.0.0"
typing-extensions = ">=4.1.0"
typing-extensions = ">=4.6.0"
[package.extras]
dmypy = ["psutil (>=4.0)"]