Compare commits

..

No commits in common. "49776731bf6533a4cd4c81528de5c923c54f5916" and "77a9baa34ff55f763629e8e4a29de40665463091" have entirely different histories.

13 changed files with 262 additions and 443 deletions

View File

@ -123,11 +123,11 @@ class Config(object):
ROWS_FROM_ZERO = True
SCROLL_TOP_MARGIN = 3
SECTION_ENDINGS = ("-", "+-", "-+")
SECTION_HEADER = "[Section header]"
SECTION_STARTS = ("+", "+-", "-+")
SONGFACTS_ON_NEXT = False
START_GAP_WARNING_THRESHOLD = 300
SUBTOTAL_ON_ROW_ZERO = "[No subtotal on first row]"
TEXT_NO_TRACK_NO_NOTE = "[Section header]"
TOD_TIME_FORMAT = "%H:%M:%S"
TRACK_TIME_FORMAT = "%H:%M:%S"
VLC_MAIN_PLAYER_NAME = "MusicMuster Main Player"

View File

@ -53,7 +53,7 @@ class NoteColoursTable(Model):
__tablename__ = "notecolours"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
substring: Mapped[str] = mapped_column(String(256), index=True, unique=True)
substring: Mapped[str] = mapped_column(String(256), index=True)
colour: Mapped[str] = mapped_column(String(21), index=False)
enabled: Mapped[bool] = mapped_column(default=True, index=True)
foreground: Mapped[Optional[str]] = mapped_column(String(21), index=False)

View File

@ -6,18 +6,16 @@ import logging.config
import logging.handlers
import os
import sys
import traceback
from traceback import print_exception
import yaml
# PyQt imports
from PyQt6.QtWidgets import QApplication, QMessageBox
# Third party imports
import stackprinter # type: ignore
# App imports
from config import Config
from classes import ApplicationError
class FunctionFilter(logging.Filter):
@ -78,34 +76,26 @@ with open("app/logging.yaml", "r") as f:
log = logging.getLogger(Config.LOG_NAME)
def handle_exception(exc_type, exc_value, exc_traceback):
error = str(exc_value)
if issubclass(exc_type, ApplicationError):
log.error(error)
else:
# Handle unexpected errors (log and display)
error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
def log_uncaught_exceptions(type_, value, traceback):
from helpers import send_mail
print(stackprinter.format(exc_value, suppressed_paths=['/.venv'], style='darkbg'))
msg = stackprinter.format(exc_value)
log.error(msg)
log.error(error_msg)
print("Critical error:", error_msg) # Consider logging instead of print
if os.environ["MM_ENV"] == "PRODUCTION":
from helpers import send_mail
send_mail(
Config.ERRORS_TO,
Config.ERRORS_FROM,
"Exception (log_uncaught_exceptions) from musicmuster",
msg,
)
if QApplication.instance() is not None:
fname = os.path.split(exc_traceback.tb_frame.f_code.co_filename)[1]
msg = f"ApplicationError: {error}\nat {fname}:{exc_traceback.tb_lineno}"
QMessageBox.critical(None, "Application Error", msg)
print("\033[1;31;47m")
print_exception(type_, value, traceback)
print("\033[1;37;40m")
print(
stackprinter.format(
value, suppressed_paths=["/pypoetry/virtualenvs/"], style="darkbg"
)
)
if os.environ["MM_ENV"] == "PRODUCTION":
msg = stackprinter.format(value)
send_mail(
Config.ERRORS_TO,
Config.ERRORS_FROM,
"Exception (log_uncaught_exceptions) from musicmuster",
msg,
)
log.debug(msg)
sys.excepthook = handle_exception
sys.excepthook = log_uncaught_exceptions

View File

@ -10,8 +10,6 @@ import sys
# PyQt imports
# Third party imports
from dogpile.cache import make_region
from dogpile.cache.api import NO_VALUE
from sqlalchemy import (
bindparam,
delete,
@ -42,11 +40,6 @@ if "unittest" in sys.modules and "sqlite" not in DATABASE_URL:
raise ValueError("Unit tests running on non-Sqlite database")
db = DatabaseManager.get_instance(DATABASE_URL, engine_options=Config.ENGINE_OPTIONS).db
# Configure the cache region
cache_region = make_region().configure(
'dogpile.cache.memory', # Use in-memory caching for now (switch to Redis if needed)
expiration_time=600 # Cache expires after 10 minutes
)
def run_sql(session: Session, sql: str) -> Sequence[RowMapping]:
"""
@ -61,7 +54,6 @@ def run_sql(session: Session, sql: str) -> Sequence[RowMapping]:
# Database classes
class NoteColours(dbtables.NoteColoursTable):
def __init__(
self,
session: Session,
@ -88,28 +80,13 @@ class NoteColours(dbtables.NoteColoursTable):
Return all records
"""
cache_key = "note_colours_all"
cached_result = cache_region.get(cache_key)
if cached_result is not NO_VALUE:
return cached_result
# Query the database
result = session.scalars(
select(cls)
.where(
cls.enabled.is_(True),
)
.order_by(cls.order)
).all()
cache_region.set(cache_key, result)
result = session.scalars(select(cls)).all()
return result
@staticmethod
def get_colour(
session: Session, text: str, foreground: bool = False
) -> str:
) -> Optional[str]:
"""
Parse text and return background (foreground if foreground==True) colour
string if matched, else None
@ -117,10 +94,16 @@ class NoteColours(dbtables.NoteColoursTable):
"""
if not text:
return ""
return None
match = False
for rec in NoteColours.get_all(session):
for rec in session.scalars(
select(NoteColours)
.where(
NoteColours.enabled.is_(True),
)
.order_by(NoteColours.order)
).all():
if rec.is_regex:
flags = re.UNICODE
if not rec.is_casesensitive:
@ -138,16 +121,10 @@ class NoteColours(dbtables.NoteColoursTable):
if match:
if foreground:
return rec.foreground or ""
return rec.foreground
else:
return rec.colour
return ""
@staticmethod
def invalidate_cache() -> None:
"""Invalidate dogpile cache"""
cache_region.delete("note_colours_all")
return None
class Playdates(dbtables.PlaydatesTable):

View File

@ -439,12 +439,6 @@ class RowAndTrack:
self.row_number = playlist_row.row_number
self.track_id = playlist_row.track_id
# Playlist display data
self.row_fg: Optional[str] = None
self.row_bg: Optional[str] = None
self.note_fg: Optional[str] = None
self.note_bg: Optional[str] = None
# Collect track data if there's a track
if playlist_row.track_id:
self.artist = playlist_row.track.artist

View File

@ -57,6 +57,7 @@ from PyQt6.QtWidgets import (
)
# Third party imports
import line_profiler
from pygame import mixer
from sqlalchemy.orm.session import Session
import stackprinter # type: ignore
@ -693,7 +694,6 @@ class PreviewManager:
mixer.init()
self.intro: Optional[int] = None
self.path: str = ""
self.row_number: Optional[int] = None
self.start_time: Optional[dt.datetime] = None
self.track_id: int = 0
@ -2114,6 +2114,7 @@ class Window(QMainWindow):
webbrowser.get("browser").open_new_tab(url)
@line_profiler.profile
def paste_rows(self, dummy_for_profiling: int | None = None) -> None:
"""
Paste earlier cut rows.
@ -2254,7 +2255,6 @@ class Window(QMainWindow):
return
if not track_info:
return
self.preview_manager.row_number = track_info.row_number
with db.Session() as session:
track = session.get(Tracks, track_info.track_id)
if not track:
@ -2278,9 +2278,7 @@ class Window(QMainWindow):
def preview_arm(self):
"""Manager arm button for setting intro length"""
self.footer_section.btnPreviewMark.setEnabled(
self.footer_section.btnPreviewArm.isChecked()
)
self.footer_section.btnPreviewMark.setEnabled(self.btnPreviewArm.isChecked())
def preview_back(self) -> None:
"""Wind back preview file"""
@ -2317,10 +2315,7 @@ class Window(QMainWindow):
session.commit()
self.preview_manager.set_intro(intro)
self.current.base_model.refresh_row(session, row_number)
roles = [
Qt.ItemDataRole.DisplayRole,
]
self.current.base_model.invalidate_row(row_number, roles)
self.current.base_model.invalidate_row(row_number)
def preview_start(self) -> None:
"""Restart preview"""
@ -2827,8 +2822,8 @@ if __name__ == "__main__":
with db.Session() as session:
update_bitrates(session)
else:
app = QApplication(sys.argv)
try:
app = QApplication(sys.argv)
# PyQt6 defaults to a grey for labels
palette = app.palette()
palette.setColor(
@ -2846,7 +2841,6 @@ if __name__ == "__main__":
win.show()
status = app.exec()
sys.exit(status)
except Exception as exc:
if os.environ["MM_ENV"] == "PRODUCTION":
from helpers import send_mail
@ -2860,8 +2854,10 @@ if __name__ == "__main__":
)
log.debug(msg)
else:
print("\033[1;31;47mUnhandled exception starts")
print(
stackprinter.format(
exc, suppressed_paths=["/pypoetry/virtualenvs/"], style="darkbg"
)
)
print("Unhandled exception ends\033[1;37;40m")

View File

@ -1,4 +1,5 @@
# Standard library imports
# Allow forward reference to PlaylistModel
from __future__ import annotations
from operator import attrgetter
@ -11,6 +12,7 @@ import re
from PyQt6.QtCore import (
QAbstractTableModel,
QModelIndex,
QObject,
QRegularExpression,
QSortFilterProxyModel,
Qt,
@ -24,6 +26,7 @@ from PyQt6.QtGui import (
)
# Third party imports
import line_profiler
from sqlalchemy.orm.session import Session
import obswebsocket # type: ignore
@ -74,13 +77,14 @@ class PlaylistModel(QAbstractTableModel):
self,
playlist_id: int,
is_template: bool,
*args: Optional[QObject],
**kwargs: Optional[QObject],
) -> None:
super().__init__()
log.debug("PlaylistModel.__init__()")
self.playlist_id = playlist_id
self.is_template = is_template
super().__init__(*args, **kwargs)
self.playlist_rows: dict[int, RowAndTrack] = {}
self.signals = MusicMusterSignals()
@ -98,17 +102,13 @@ class PlaylistModel(QAbstractTableModel):
def __repr__(self) -> str:
return (
f"<PlaylistModel: playlist_id={self.playlist_id}, "
f"is_template={self.is_template}, "
f"{self.rowCount()} rows>"
f"<PlaylistModel: playlist_id={self.playlist_id}, {self.rowCount()} rows>"
)
def active_section_header(self) -> int:
"""
Return the row number of the first header that has any of the following below it:
- unplayed tracks
- the currently being played track
- the track marked as next to play
Return the row number of the first header that has either unplayed tracks
or currently being played track below it.
"""
header_row = 0
@ -120,20 +120,23 @@ class PlaylistModel(QAbstractTableModel):
if not self.is_played_row(row_number):
break
# Here means that row_number points to a played track. The
# current track will be marked as played when we start
# playing it. It's also possible that the track marked as
# next has already been played. Check for either of those.
# If track is played, we need to check it's not the current
# next or previous track because we don't want to scroll them
# out of view
for ts in [track_sequence.next, track_sequence.current]:
for ts in [
track_sequence.next,
track_sequence.current,
]:
if (
ts
and ts.row_number == row_number
and ts.playlist_id == self.playlist_id
):
# We've found the current or next track, so return
# the last-found header row
return header_row
break
else:
continue # continue iterating over playlist_rows
break # current row is in one of the track sequences
return header_row
@ -150,54 +153,44 @@ class PlaylistModel(QAbstractTableModel):
try:
rat = self.playlist_rows[row_number]
except KeyError:
raise ApplicationError(
log.error(
f"{self}: KeyError in add_track_to_header ({row_number=}, {track_id=})"
)
return
if rat.path:
raise ApplicationError(
log.error(
f"{self}: Header row already has track associated ({rat=}, {track_id=})"
)
return
with db.Session() as session:
playlistrow = session.get(PlaylistRows, rat.playlistrow_id)
if not playlistrow:
raise ApplicationError(
f"{self}: Failed to retrieve playlist row ({rat.playlistrow_id=}"
)
# Add track to PlaylistRows
playlistrow.track_id = track_id
# Add any further note (header will already have a note)
if note:
playlistrow.note += " " + note
session.commit()
# Update local copy
self.refresh_row(session, row_number)
# Repaint row
roles = [
Qt.ItemDataRole.BackgroundRole,
Qt.ItemDataRole.DisplayRole,
Qt.ItemDataRole.FontRole,
Qt.ItemDataRole.ForegroundRole,
]
# only invalidate required roles
self.invalidate_row(row_number, roles)
if playlistrow:
# Add track to PlaylistRows
playlistrow.track_id = track_id
# Add any further note (header will already have a note)
if note:
playlistrow.note += "\n" + note
# Update local copy
self.refresh_row(session, row_number)
# Repaint row
self.invalidate_row(row_number)
session.commit()
self.signals.resize_rows_signal.emit(self.playlist_id)
def _background_role(self, row: int, column: int, rat: RowAndTrack) -> QBrush:
def background_role(self, row: int, column: int, rat: RowAndTrack) -> QBrush:
"""Return background setting"""
# Handle entire row colouring
# Header row
if self.is_header_row(row):
# Check for specific header colouring
if rat.row_bg is None:
with db.Session() as session:
rat.row_bg = NoteColours.get_colour(session, rat.note)
if rat.row_bg:
return QBrush(QColor(rat.row_bg))
else:
return QBrush(QColor(Config.COLOUR_NOTES_PLAYLIST))
with db.Session() as session:
note_background = NoteColours.get_colour(session, rat.note)
if note_background:
return QBrush(QColor(note_background))
else:
return QBrush(QColor(Config.COLOUR_NOTES_PLAYLIST))
# Unreadable track file
if file_is_unreadable(rat.path):
return QBrush(QColor(Config.COLOUR_UNREADABLE))
@ -229,11 +222,10 @@ class PlaylistModel(QAbstractTableModel):
return QBrush(QColor(Config.COLOUR_BITRATE_OK))
if column == Col.NOTE.value:
if rat.note:
if rat.note_bg is None:
with db.Session() as session:
rat.note_bg = NoteColours.get_colour(session, rat.note)
if rat.note_bg:
return QBrush(QColor(rat.note_bg))
with db.Session() as session:
note_background = NoteColours.get_colour(session, rat.note)
if note_background:
return QBrush(QColor(note_background))
return QBrush()
@ -266,28 +258,26 @@ class PlaylistModel(QAbstractTableModel):
- update track times
"""
log.debug(f"{self}: current_track_started()")
if not track_sequence.current:
return
row_number = track_sequence.current.row_number
# Check for OBS scene change
log.debug(f"{self}: Call OBS scene change")
self.obs_scene_change(row_number)
# Sanity check that we have a track_id
track_id = track_sequence.current.track_id
if not track_id:
raise ApplicationError(
f"{self}: current_track_started() called with {track_id=}"
if not track_sequence.current.track_id:
log.error(
f"{self}: current_track_started() called with {track_sequence.current.track_id=}"
)
return
with db.Session() as session:
# Update Playdates in database
log.debug(f"{self}: update playdates {track_id=}")
Playdates(session, track_id)
session.commit()
log.debug(f"{self}: update playdates")
Playdates(session, track_sequence.current.track_id)
# Mark track as played in playlist
log.debug(f"{self}: Mark track as played")
@ -301,16 +291,11 @@ class PlaylistModel(QAbstractTableModel):
)
# Update colour and times for current row
# only invalidate required roles
roles = [
Qt.ItemDataRole.DisplayRole
]
self.invalidate_row(row_number, roles)
self.invalidate_row(row_number)
# Update previous row in case we're hiding played rows
if track_sequence.previous and track_sequence.previous.row_number:
# only invalidate required roles
self.invalidate_row(track_sequence.previous.row_number, roles)
self.invalidate_row(track_sequence.previous.row_number)
# Update all other track times
self.update_track_times()
@ -331,25 +316,14 @@ class PlaylistModel(QAbstractTableModel):
if next_row is not None:
self.set_next_row(next_row)
session.commit()
def data(
self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole
) -> QVariant | QFont | QBrush | str | int:
) -> QVariant:
"""Return data to view"""
if (
not index.isValid()
or not (0 <= index.row() < len(self.playlist_rows))
or role
in [
Qt.ItemDataRole.DecorationRole,
Qt.ItemDataRole.StatusTipRole,
Qt.ItemDataRole.WhatsThisRole,
Qt.ItemDataRole.SizeHintRole,
Qt.ItemDataRole.TextAlignmentRole,
Qt.ItemDataRole.CheckStateRole,
Qt.ItemDataRole.InitialSortOrderRole,
]
):
if not index.isValid() or not (0 <= index.row() < len(self.playlist_rows)):
return QVariant()
row = index.row()
@ -357,21 +331,32 @@ class PlaylistModel(QAbstractTableModel):
# rat for playlist row data as it's used a lot
rat = self.playlist_rows[row]
# These are ordered in approximately the frequency with which
# they are called
if role == Qt.ItemDataRole.BackgroundRole:
return self._background_role(row, column, rat)
elif role == Qt.ItemDataRole.DisplayRole:
return self._display_role(row, column, rat)
elif role == Qt.ItemDataRole.EditRole:
return self._edit_role(row, column, rat)
elif role == Qt.ItemDataRole.FontRole:
return self._font_role(row, column, rat)
elif role == Qt.ItemDataRole.ForegroundRole:
return self._foreground_role(row, column, rat)
elif role == Qt.ItemDataRole.ToolTipRole:
return self._tooltip_role(row, column, rat)
# Dispatch to role-specific functions
dispatch_table = {
int(Qt.ItemDataRole.BackgroundRole): self.background_role,
int(Qt.ItemDataRole.DisplayRole): self.display_role,
int(Qt.ItemDataRole.EditRole): self.edit_role,
int(Qt.ItemDataRole.FontRole): self.font_role,
int(Qt.ItemDataRole.ForegroundRole): self.foreground_role,
int(Qt.ItemDataRole.ToolTipRole): self.tooltip_role,
}
if role in dispatch_table:
return QVariant(dispatch_table[role](row, column, rat))
# Document other roles but don't use them
if role in [
Qt.ItemDataRole.DecorationRole,
Qt.ItemDataRole.StatusTipRole,
Qt.ItemDataRole.WhatsThisRole,
Qt.ItemDataRole.SizeHintRole,
Qt.ItemDataRole.TextAlignmentRole,
Qt.ItemDataRole.CheckStateRole,
Qt.ItemDataRole.InitialSortOrderRole,
]:
return QVariant()
# Fall through to no-op
return QVariant()
def delete_rows(self, row_numbers: list[int]) -> None:
@ -401,9 +386,8 @@ class PlaylistModel(QAbstractTableModel):
super().endRemoveRows()
self.reset_track_sequence_row_numbers()
self.update_track_times()
def _display_role(self, row: int, column: int, rat: RowAndTrack) -> str:
def display_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant:
"""
Return text for display
"""
@ -423,45 +407,45 @@ class PlaylistModel(QAbstractTableModel):
if column == HEADER_NOTES_COLUMN:
header_text = self.header_text(rat)
if not header_text:
return Config.SECTION_HEADER
return QVariant(Config.TEXT_NO_TRACK_NO_NOTE)
else:
formatted_header = self.header_text(rat)
trimmed_header = self.remove_section_timer_markers(formatted_header)
return trimmed_header
return QVariant(trimmed_header)
else:
return ""
return QVariant("")
if column == Col.START_TIME.value:
start_time = rat.forecast_start_time
if start_time:
return start_time.strftime(Config.TRACK_TIME_FORMAT)
return ""
return QVariant(start_time.strftime(Config.TRACK_TIME_FORMAT))
return QVariant()
if column == Col.END_TIME.value:
end_time = rat.forecast_end_time
if end_time:
return end_time.strftime(Config.TRACK_TIME_FORMAT)
return ""
return QVariant(end_time.strftime(Config.TRACK_TIME_FORMAT))
return QVariant()
if column == Col.INTRO.value:
if rat.intro:
return f"{rat.intro / 1000:{Config.INTRO_SECONDS_FORMAT}}"
return QVariant(f"{rat.intro / 1000:{Config.INTRO_SECONDS_FORMAT}}")
else:
return ""
return QVariant("")
dispatch_table: dict[int, str] = {
Col.ARTIST.value: rat.artist,
Col.BITRATE.value: str(rat.bitrate),
Col.DURATION.value: ms_to_mmss(rat.duration),
Col.LAST_PLAYED.value: get_relative_date(rat.lastplayed),
Col.NOTE.value: rat.note,
Col.START_GAP.value: str(rat.start_gap),
Col.TITLE.value: rat.title,
dispatch_table = {
Col.ARTIST.value: QVariant(rat.artist),
Col.BITRATE.value: QVariant(rat.bitrate),
Col.DURATION.value: QVariant(ms_to_mmss(rat.duration)),
Col.LAST_PLAYED.value: QVariant(get_relative_date(rat.lastplayed)),
Col.NOTE.value: QVariant(rat.note),
Col.START_GAP.value: QVariant(rat.start_gap),
Col.TITLE.value: QVariant(rat.title),
}
if column in dispatch_table:
return dispatch_table[column]
return ""
return QVariant()
def end_reset_model(self, playlist_id: int) -> None:
"""
@ -478,38 +462,37 @@ class PlaylistModel(QAbstractTableModel):
super().endResetModel()
self.reset_track_sequence_row_numbers()
def _edit_role(self, row: int, column: int, rat: RowAndTrack) -> str | int:
def edit_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant:
"""
Return value for editing
Return text for editing
"""
# If this is a header row and we're being asked for the
# HEADER_NOTES_COLUMN, return the note value
if self.is_header_row(row) and column == HEADER_NOTES_COLUMN:
return rat.note
return QVariant(rat.note)
if column == Col.INTRO.value:
return rat.intro or 0
return QVariant(rat.intro)
if column == Col.TITLE.value:
return rat.title
return QVariant(rat.title)
if column == Col.ARTIST.value:
return rat.artist
return QVariant(rat.artist)
if column == Col.NOTE.value:
return rat.note
return QVariant(rat.note)
return ""
return QVariant()
def _foreground_role(self, row: int, column: int, rat: RowAndTrack) -> QBrush:
def foreground_role(self, row: int, column: int, rat: RowAndTrack) -> QBrush:
"""Return header foreground colour or QBrush() if none"""
if self.is_header_row(row):
if rat.row_fg is None:
with db.Session() as session:
rat.row_fg = NoteColours.get_colour(
session, rat.note, foreground=True
)
if rat.row_fg:
return QBrush(QColor(rat.row_fg))
with db.Session() as session:
note_foreground = NoteColours.get_colour(
session, rat.note, foreground=True
)
if note_foreground:
return QBrush(QColor(note_foreground))
return QBrush()
@ -536,19 +519,19 @@ class PlaylistModel(QAbstractTableModel):
return default
def _font_role(self, row: int, column: int, rat: RowAndTrack) -> QFont:
def font_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant:
"""
Return font
"""
# Notes column is never bold
if column == Col.NOTE.value:
return QFont()
return QVariant()
boldfont = QFont()
boldfont.setBold(not self.playlist_rows[row].played)
return boldfont
return QVariant(boldfont)
def get_duplicate_rows(self) -> list[int]:
"""
@ -637,6 +620,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=}")
return result
def headerData(
@ -644,22 +628,22 @@ class PlaylistModel(QAbstractTableModel):
section: int,
orientation: Qt.Orientation,
role: int = Qt.ItemDataRole.DisplayRole,
) -> str | int | QFont | QVariant:
) -> QVariant:
"""
Return text for headers
"""
display_dispatch_table = {
Col.START_GAP.value: Config.HEADER_START_GAP,
Col.INTRO.value: Config.HEADER_INTRO,
Col.TITLE.value: Config.HEADER_TITLE,
Col.ARTIST.value: Config.HEADER_ARTIST,
Col.DURATION.value: Config.HEADER_DURATION,
Col.START_TIME.value: Config.HEADER_START_TIME,
Col.END_TIME.value: Config.HEADER_END_TIME,
Col.LAST_PLAYED.value: Config.HEADER_LAST_PLAYED,
Col.BITRATE.value: Config.HEADER_BITRATE,
Col.NOTE.value: Config.HEADER_NOTE,
Col.START_GAP.value: QVariant(Config.HEADER_START_GAP),
Col.INTRO.value: QVariant(Config.HEADER_INTRO),
Col.TITLE.value: QVariant(Config.HEADER_TITLE),
Col.ARTIST.value: QVariant(Config.HEADER_ARTIST),
Col.DURATION.value: QVariant(Config.HEADER_DURATION),
Col.START_TIME.value: QVariant(Config.HEADER_START_TIME),
Col.END_TIME.value: QVariant(Config.HEADER_END_TIME),
Col.LAST_PLAYED.value: QVariant(Config.HEADER_LAST_PLAYED),
Col.BITRATE.value: QVariant(Config.HEADER_BITRATE),
Col.NOTE.value: QVariant(Config.HEADER_NOTE),
}
if role == Qt.ItemDataRole.DisplayRole:
@ -667,14 +651,14 @@ class PlaylistModel(QAbstractTableModel):
return display_dispatch_table[section]
else:
if Config.ROWS_FROM_ZERO:
return section
return QVariant(str(section))
else:
return section + 1
return QVariant(str(section + 1))
elif role == Qt.ItemDataRole.FontRole:
boldfont = QFont()
boldfont.setBold(True)
return boldfont
return QVariant(boldfont)
return QVariant()
@ -712,11 +696,7 @@ class PlaylistModel(QAbstractTableModel):
self.played_tracks_hidden = hide
for row_number in range(len(self.playlist_rows)):
if self.is_played_row(row_number):
# only invalidate required roles
roles = [
Qt.ItemDataRole.DisplayRole,
]
self.invalidate_row(row_number, roles)
self.invalidate_row(row_number)
def insert_row(
self,
@ -748,16 +728,10 @@ class PlaylistModel(QAbstractTableModel):
self.signals.resize_rows_signal.emit(self.playlist_id)
self.reset_track_sequence_row_numbers()
# only invalidate required roles
roles = [
Qt.ItemDataRole.BackgroundRole,
Qt.ItemDataRole.DisplayRole,
Qt.ItemDataRole.FontRole,
Qt.ItemDataRole.ForegroundRole,
]
self.invalidate_rows(list(range(new_row_number, len(self.playlist_rows))), roles)
self.invalidate_rows(list(range(new_row_number, len(self.playlist_rows))))
def invalidate_row(self, modified_row: int, roles: list[Qt.ItemDataRole]) -> None:
@line_profiler.profile
def invalidate_row(self, modified_row: int) -> None:
"""
Signal to view to refresh invalidated row
"""
@ -767,10 +741,10 @@ class PlaylistModel(QAbstractTableModel):
self.dataChanged.emit(
self.index(modified_row, 0),
self.index(modified_row, self.columnCount() - 1),
roles
)
def invalidate_rows(self, modified_rows: list[int], roles: list[Qt.ItemDataRole]) -> None:
@line_profiler.profile
def invalidate_rows(self, modified_rows: list[int]) -> None:
"""
Signal to view to refresh invlidated rows
"""
@ -778,8 +752,7 @@ class PlaylistModel(QAbstractTableModel):
log.debug(f"issue285: {self}: invalidate_rows({modified_rows=})")
for modified_row in modified_rows:
# only invalidate required roles
self.invalidate_row(modified_row, roles)
self.invalidate_row(modified_row)
def is_header_row(self, row_number: int) -> bool:
"""
@ -854,11 +827,7 @@ class PlaylistModel(QAbstractTableModel):
self.refresh_row(session, row_number)
self.update_track_times()
# only invalidate required roles
roles = [
Qt.ItemDataRole.FontRole,
]
self.invalidate_rows(row_numbers, roles)
self.invalidate_rows(row_numbers)
def move_rows(self, from_rows: list[int], to_row_number: int) -> None:
"""
@ -923,11 +892,7 @@ class PlaylistModel(QAbstractTableModel):
# Update display
self.reset_track_sequence_row_numbers()
self.update_track_times()
# only invalidate required roles
roles = [
Qt.ItemDataRole.DisplayRole,
]
self.invalidate_rows(list(row_map.keys()), roles)
self.invalidate_rows(list(row_map.keys()))
def move_rows_between_playlists(
self,
@ -1104,11 +1069,7 @@ class PlaylistModel(QAbstractTableModel):
return
# Update display
# only invalidate required roles
roles = [
Qt.ItemDataRole.BackgroundRole,
]
self.invalidate_row(track_sequence.previous.row_number, roles)
self.invalidate_row(track_sequence.previous.row_number)
def refresh_data(self, session: Session) -> None:
"""
@ -1161,11 +1122,7 @@ class PlaylistModel(QAbstractTableModel):
playlist_row.track_id = None
session.commit()
self.refresh_row(session, row_number)
# only invalidate required roles
roles = [
Qt.ItemDataRole.DisplayRole,
]
self.invalidate_row(row_number, roles)
self.invalidate_row(row_number)
def rescan_track(self, row_number: int) -> None:
"""
@ -1179,15 +1136,11 @@ class PlaylistModel(QAbstractTableModel):
set_track_metadata(track)
self.refresh_row(session, row_number)
self.update_track_times()
roles = [
Qt.ItemDataRole.BackgroundRole,
Qt.ItemDataRole.DisplayRole,
]
# only invalidate required roles
self.invalidate_row(row_number, roles)
self.invalidate_row(row_number)
self.signals.resize_rows_signal.emit(self.playlist_id)
session.commit()
@line_profiler.profile
def reset_track_sequence_row_numbers(self) -> None:
"""
Signal handler for when row ordering has changed.
@ -1244,13 +1197,7 @@ class PlaylistModel(QAbstractTableModel):
# self.playlist_rows directly.
self.playlist_rows[row_number].note = ""
session.commit()
# only invalidate required roles
roles = [
Qt.ItemDataRole.BackgroundRole,
Qt.ItemDataRole.DisplayRole,
Qt.ItemDataRole.ForegroundRole,
]
self.invalidate_rows(row_numbers, roles)
self.invalidate_rows(row_numbers)
def _reversed_contiguous_row_groups(
self, row_numbers: list[int]
@ -1328,6 +1275,7 @@ class PlaylistModel(QAbstractTableModel):
return header_text
@line_profiler.profile
def rowCount(self, index: QModelIndex = QModelIndex()) -> int:
"""Standard function for view"""
@ -1459,14 +1407,9 @@ class PlaylistModel(QAbstractTableModel):
self.signals.search_songfacts_signal.emit(
self.playlist_rows[row_number].title
)
roles = [
Qt.ItemDataRole.BackgroundRole,
]
if old_next_row is not None:
# only invalidate required roles
self.invalidate_row(old_next_row, roles)
# only invalidate required roles
self.invalidate_row(row_number, roles)
self.invalidate_row(old_next_row)
self.invalidate_row(row_number)
self.signals.next_track_changed_signal.emit()
self.update_track_times()
@ -1616,23 +1559,25 @@ class PlaylistModel(QAbstractTableModel):
def supportedDropActions(self) -> Qt.DropAction:
return Qt.DropAction.MoveAction | Qt.DropAction.CopyAction
def _tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> str:
def tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant:
"""
Return tooltip. Currently only used for last_played column.
"""
if column != Col.LAST_PLAYED.value:
return ""
return QVariant()
with db.Session() as session:
track_id = self.playlist_rows[row].track_id
if not track_id:
return ""
return QVariant()
playdates = Playdates.last_playdates(session, track_id)
return "<br>".join(
[
a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT)
for a in playdates
]
return QVariant(
"<br>".join(
[
a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT)
for a in playdates
]
)
)
def update_or_insert(self, track_id: int, row_number: int) -> None:
@ -1648,17 +1593,11 @@ class PlaylistModel(QAbstractTableModel):
with db.Session() as session:
for row in track_rows:
self.refresh_row(session, row)
# only invalidate required roles
roles = [
Qt.ItemDataRole.BackgroundRole,
Qt.ItemDataRole.DisplayRole,
Qt.ItemDataRole.FontRole,
Qt.ItemDataRole.ForegroundRole,
]
self.invalidate_rows(track_rows, roles)
self.invalidate_rows(track_rows)
else:
self.insert_row(proposed_row_number=row_number, track_id=track_id)
@line_profiler.profile
def update_track_times(self) -> None:
"""
Update track start/end times in self.playlist_rows
@ -1804,13 +1743,9 @@ class PlaylistProxyModel(QSortFilterProxyModel):
# milliseconds so that it hides then. We add
# 100mS on so that the if clause above is
# true next time through.
# only invalidate required roles
roles = [
Qt.ItemDataRole.DisplayRole,
]
QTimer.singleShot(
Config.HIDE_AFTER_PLAYING_OFFSET + 100,
lambda: self.sourceModel().invalidate_row(source_row, roles),
lambda: self.sourceModel().invalidate_row(source_row),
)
return True
# Next track not playing yet so don't hide previous

View File

@ -31,6 +31,7 @@ from PyQt6.QtWidgets import (
)
# Third party imports
import line_profiler
# App imports
from audacity_controller import AudacityController
@ -183,11 +184,11 @@ class PlaylistDelegate(QStyledItemDelegate):
data_modified = False
if isinstance(editor, QTextEdit):
data_modified = (
self.original_model_data != editor.toPlainText()
self.original_model_data.value() != editor.toPlainText()
)
elif isinstance(editor, QDoubleSpinBox):
data_modified = (
self.original_model_data != int(editor.value()) * 1000
self.original_model_data.value() != int(editor.value()) * 1000
)
if not data_modified:
self.closeEditor.emit(editor)
@ -246,10 +247,10 @@ class PlaylistDelegate(QStyledItemDelegate):
edit_index, Qt.ItemDataRole.EditRole
)
if index.column() == Col.INTRO.value:
if self.original_model_data:
editor.setValue(self.original_model_data / 1000)
if self.original_model_data.value():
editor.setValue(self.original_model_data.value() / 1000)
else:
editor.setPlainText(self.original_model_data)
editor.setPlainText(self.original_model_data.value())
def setModelData(self, editor, model, index):
proxy_model = index.model()
@ -357,6 +358,7 @@ class PlaylistTab(QTableView):
# Deselect edited line
self.clear_selection()
@line_profiler.profile
def dropEvent(self, event: Optional[QDropEvent], dummy: int | None = None) -> None:
"""
Move dropped rows

View File

@ -15,7 +15,6 @@ from PyQt6.QtCore import (
QVariant,
)
from PyQt6.QtGui import (
QBrush,
QColor,
QFont,
)
@ -83,27 +82,27 @@ class QuerylistModel(QAbstractTableModel):
def __repr__(self) -> str:
return f"<QuerylistModel: filter={self.filter}, {self.rowCount()} rows>"
def _background_role(self, row: int, column: int, qrow: QueryRow) -> QBrush:
def background_role(self, row: int, column: int, qrow: QueryRow) -> QVariant:
"""Return background setting"""
# Unreadable track file
if file_is_unreadable(qrow.path):
return QBrush(QColor(Config.COLOUR_UNREADABLE))
return QVariant(QColor(Config.COLOUR_UNREADABLE))
# Selected row
if row in self._selected_rows:
return QBrush(QColor(Config.COLOUR_QUERYLIST_SELECTED))
return QVariant(QColor(Config.COLOUR_QUERYLIST_SELECTED))
# Individual cell colouring
if column == QueryCol.BITRATE.value:
if not qrow.bitrate or qrow.bitrate < Config.BITRATE_LOW_THRESHOLD:
return QBrush(QColor(Config.COLOUR_BITRATE_LOW))
return QVariant(QColor(Config.COLOUR_BITRATE_LOW))
elif qrow.bitrate < Config.BITRATE_OK_THRESHOLD:
return QBrush(QColor(Config.COLOUR_BITRATE_MEDIUM))
return QVariant(QColor(Config.COLOUR_BITRATE_MEDIUM))
else:
return QBrush(QColor(Config.COLOUR_BITRATE_OK))
return QVariant(QColor(Config.COLOUR_BITRATE_OK))
return QBrush()
return QVariant()
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
"""Standard function for view"""
@ -115,23 +114,7 @@ class QuerylistModel(QAbstractTableModel):
) -> QVariant:
"""Return data to view"""
if (
not index.isValid()
or not (0 <= index.row() < len(self.querylist_rows))
or role
in [
Qt.ItemDataRole.CheckStateRole,
Qt.ItemDataRole.DecorationRole,
Qt.ItemDataRole.EditRole,
Qt.ItemDataRole.FontRole,
Qt.ItemDataRole.ForegroundRole,
Qt.ItemDataRole.InitialSortOrderRole,
Qt.ItemDataRole.SizeHintRole,
Qt.ItemDataRole.StatusTipRole,
Qt.ItemDataRole.TextAlignmentRole,
Qt.ItemDataRole.WhatsThisRole,
]
):
if not index.isValid() or not (0 <= index.row() < len(self.querylist_rows)):
return QVariant()
row = index.row()
@ -141,33 +124,48 @@ class QuerylistModel(QAbstractTableModel):
# Dispatch to role-specific functions
dispatch_table: dict[int, Callable] = {
int(Qt.ItemDataRole.BackgroundRole): self._background_role,
int(Qt.ItemDataRole.DisplayRole): self._display_role,
int(Qt.ItemDataRole.ToolTipRole): self._tooltip_role,
int(Qt.ItemDataRole.BackgroundRole): self.background_role,
int(Qt.ItemDataRole.DisplayRole): self.display_role,
int(Qt.ItemDataRole.ToolTipRole): self.tooltip_role,
}
if role in dispatch_table:
return QVariant(dispatch_table[role](row, column, qrow))
# Document other roles but don't use them
if role in [
Qt.ItemDataRole.DecorationRole,
Qt.ItemDataRole.EditRole,
Qt.ItemDataRole.FontRole,
Qt.ItemDataRole.ForegroundRole,
Qt.ItemDataRole.InitialSortOrderRole,
Qt.ItemDataRole.SizeHintRole,
Qt.ItemDataRole.StatusTipRole,
Qt.ItemDataRole.TextAlignmentRole,
Qt.ItemDataRole.ToolTipRole,
Qt.ItemDataRole.WhatsThisRole,
]:
return QVariant()
# Fall through to no-op
return QVariant()
def _display_role(self, row: int, column: int, qrow: QueryRow) -> str:
def display_role(self, row: int, column: int, qrow: QueryRow) -> QVariant:
"""
Return text for display
"""
dispatch_table = {
QueryCol.ARTIST.value: qrow.artist,
QueryCol.BITRATE.value: str(qrow.bitrate),
QueryCol.DURATION.value: ms_to_mmss(qrow.duration),
QueryCol.LAST_PLAYED.value: get_relative_date(qrow.lastplayed),
QueryCol.TITLE.value: qrow.title,
QueryCol.ARTIST.value: QVariant(qrow.artist),
QueryCol.BITRATE.value: QVariant(qrow.bitrate),
QueryCol.DURATION.value: QVariant(ms_to_mmss(qrow.duration)),
QueryCol.LAST_PLAYED.value: QVariant(get_relative_date(qrow.lastplayed)),
QueryCol.TITLE.value: QVariant(qrow.title),
}
if column in dispatch_table:
return dispatch_table[column]
return ""
return QVariant()
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
"""
@ -268,7 +266,7 @@ class QuerylistModel(QAbstractTableModel):
bottom_right = self.index(row, self.columnCount() - 1)
self.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.BackgroundRole])
def _tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> str | QVariant:
def tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant:
"""
Return tooltip. Currently only used for last_played column.
"""
@ -280,7 +278,7 @@ class QuerylistModel(QAbstractTableModel):
if not track_id:
return QVariant()
playdates = Playdates.last_playdates(session, track_id)
return (
return QVariant(
"<br>".join(
[
a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT)

View File

@ -31,7 +31,6 @@ dependencies = [
"pyyaml (>=6.0.2,<7.0.0)",
"audioop-lts>=0.2.1",
"types-pyyaml>=6.0.12.20241230",
"dogpile-cache>=1.3.4",
]
[dependency-groups]

View File

@ -21,9 +21,7 @@ from app.models import (
class TestMMModels(unittest.TestCase):
def setUp(self):
"""Runs before each test"""
db.create_all()
NoteColours.invalidate_cache()
with db.Session() as session:
track1_path = "testdata/isa.mp3"
@ -33,7 +31,6 @@ class TestMMModels(unittest.TestCase):
self.track2 = Tracks(session, **helpers.get_all_track_metadata(track2_path))
def tearDown(self):
"""Runs after each test"""
db.drop_all()
def test_track_repr(self):
@ -73,7 +70,7 @@ class TestMMModels(unittest.TestCase):
NoteColours(session, substring="substring", colour=note_colour)
result = NoteColours.get_colour(session, "xyz")
assert result == ""
assert result is None
def test_notecolours_get_colour_match(self):
note_colour = "#4bcdef"
@ -203,7 +200,7 @@ class TestMMModels(unittest.TestCase):
nc = NoteColours(session, substring="x", colour="x")
_ = str(nc)
def test_get_colour_1(self):
def test_get_colour(self):
"""Test for errors in execution"""
GOOD_STRING = "cantelope"
@ -216,42 +213,22 @@ class TestMMModels(unittest.TestCase):
session, substring=SUBSTR, colour=COLOUR, is_casesensitive=True
)
session.commit()
_ = nc1.get_colour(session, "")
colour = nc1.get_colour(session, GOOD_STRING)
assert colour == COLOUR
colour = nc1.get_colour(session, BAD_STRING)
assert colour == ""
assert colour is None
def test_get_colour_2(self):
"""Test for errors in execution"""
GOOD_STRING = "cantelope"
BAD_STRING = "ericTheBee"
SUBSTR = "ant"
COLOUR = "blue"
with db.Session() as session:
nc2 = NoteColours(
session, substring=".*" + SUBSTR, colour=COLOUR, is_regex=True
)
session.commit()
colour = nc2.get_colour(session, GOOD_STRING)
assert colour == COLOUR
colour = nc2.get_colour(session, BAD_STRING)
assert colour == ""
assert colour is None
def test_get_colour_3(self):
"""Test for errors in execution"""
GOOD_STRING = "cantelope"
BAD_STRING = "ericTheBee"
SUBSTR = "ant"
COLOUR = "blue"
with db.Session() as session:
nc3 = NoteColours(
session,
substring=".*" + SUBSTR,
@ -259,13 +236,12 @@ class TestMMModels(unittest.TestCase):
is_regex=True,
is_casesensitive=True,
)
session.commit()
colour = nc3.get_colour(session, GOOD_STRING)
assert colour == COLOUR
colour = nc3.get_colour(session, BAD_STRING)
assert colour == ""
assert colour is None
def test_name_available(self):
PLAYLIST_NAME = "a name"

View File

@ -66,10 +66,10 @@ class TestMMMiscTracks(unittest.TestCase):
self.model.insert_row(proposed_row_number=END_ROW, note="-")
prd = self.model.playlist_rows[START_ROW]
qv_value = self.model._display_role(
qv_value = self.model.display_role(
START_ROW, playlistmodel.HEADER_NOTES_COLUMN, prd
)
assert qv_value == "start [1 tracks, 4:23 unplayed]"
assert qv_value.value() == "start [1 tracks, 4:23 unplayed]"
class TestMMMiscNoPlaylist(unittest.TestCase):
@ -109,7 +109,7 @@ class TestMMMiscNoPlaylist(unittest.TestCase):
_ = str(prd)
assert (
model._edit_role(
model.edit_role(
model.rowCount() - 1, playlistmodel.Col.TITLE.value, prd
)
== metadata["title"]
@ -262,7 +262,7 @@ class TestMMMiscRowMove(unittest.TestCase):
# Test against edit_role because display_role for headers is
# handled differently (sets up row span)
assert (
self.model._edit_role(
self.model.edit_role(
self.model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd
)
== note_text
@ -280,7 +280,7 @@ class TestMMMiscRowMove(unittest.TestCase):
# Test against edit_role because display_role for headers is
# handled differently (sets up row span)
assert (
self.model._edit_role(
self.model.edit_role(
self.model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd
)
== note_text
@ -353,7 +353,7 @@ class TestMMMiscRowMove(unittest.TestCase):
index = model_dst.index(
row_number, playlistmodel.Col.TITLE.value, QModelIndex()
)
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole))
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole).value())
assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows)
assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows)
@ -380,7 +380,7 @@ class TestMMMiscRowMove(unittest.TestCase):
index = model_dst.index(
row_number, playlistmodel.Col.TITLE.value, QModelIndex()
)
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole))
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole).value())
assert len(model_src.playlist_rows) == self.ROWS_TO_CREATE - len(from_rows)
assert len(model_dst.playlist_rows) == self.ROWS_TO_CREATE + len(from_rows)

48
uv.lock
View File

@ -168,19 +168,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 },
]
[[package]]
name = "dogpile-cache"
version = "1.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "decorator" },
{ name = "stevedore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/b7/2fa37f52b4f38bc8eb6d4923163dd822ca6f9e2f817378478a5de73b239e/dogpile_cache-1.3.4.tar.gz", hash = "sha256:4f0295575f5fdd3f7e13c84ba8e36656971d1869a2081b4737ec99ede378a8c0", size = 933234 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/82/d118accb66f9048acbc4ff91592755c24d52f54cce40d6b0b2a0ce351cf5/dogpile.cache-1.3.4-py3-none-any.whl", hash = "sha256:a393412f93d24a8942fdf9248dc80678127d54c5e60a7be404027aa193cafe12", size = 62859 },
]
[[package]]
name = "entrypoints"
version = "0.4"
@ -462,7 +449,6 @@ dependencies = [
{ name = "alembic" },
{ name = "audioop-lts" },
{ name = "colorlog" },
{ name = "dogpile-cache" },
{ name = "fuzzywuzzy" },
{ name = "mutagen" },
{ name = "mysqlclient" },
@ -506,7 +492,6 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.14.0" },
{ name = "audioop-lts", specifier = ">=0.2.1" },
{ name = "colorlog", specifier = ">=6.9.0" },
{ name = "dogpile-cache", specifier = ">=1.3.4" },
{ name = "fuzzywuzzy", specifier = ">=0.18.0" },
{ name = "mutagen", specifier = ">=1.47.0" },
{ name = "mysqlclient", specifier = ">=2.2.5" },
@ -656,18 +641,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 },
]
[[package]]
name = "pbr"
version = "6.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/01/d2/510cc0d218e753ba62a1bc1434651db3cd797a9716a0a66cc714cb4f0935/pbr-6.1.1.tar.gz", hash = "sha256:93ea72ce6989eb2eed99d0f75721474f69ad88128afdef5ac377eb797c4bf76b", size = 125702 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/ac/684d71315abc7b1214d59304e23a982472967f6bf4bde5a98f1503f648dc/pbr-6.1.1-py2.py3-none-any.whl", hash = "sha256:38d4daea5d9fa63b3f626131b9d34947fd0c8be9b05a29276870580050a25a76", size = 108997 },
]
[[package]]
name = "pexpect"
version = "4.9.0"
@ -1055,15 +1028,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 },
]
[[package]]
name = "setuptools"
version = "75.8.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/53/43d99d7687e8cdef5ab5f9ec5eaf2c0423c2b35133a2b7e7bc276fc32b21/setuptools-75.8.2.tar.gz", hash = "sha256:4880473a969e5f23f2a2be3646b2dfd84af9028716d398e46192f84bc36900d2", size = 1344083 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/38/7d7362e031bd6dc121e5081d8cb6aa6f6fedf2b67bf889962134c6da4705/setuptools-75.8.2-py3-none-any.whl", hash = "sha256:558e47c15f1811c1fa7adbd0096669bf76c1d3f433f58324df69f3f5ecac4e8f", size = 1229385 },
]
[[package]]
name = "sqlalchemy"
version = "2.0.38"
@ -1108,18 +1072,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/15/485186e37a06d28b7fc9020ad57ba1e3778ee9e8930ff6c9ea350946ffe1/stackprinter-0.2.12-py3-none-any.whl", hash = "sha256:0a0623d46a5babd7a8a9787f605f4dd4a42d6ff7aee140541d5e9291a506e8d9", size = 29282 },
]
[[package]]
name = "stevedore"
version = "5.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pbr" },
]
sdist = { url = "https://files.pythonhosted.org/packages/28/3f/13cacea96900bbd31bb05c6b74135f85d15564fc583802be56976c940470/stevedore-5.4.1.tar.gz", hash = "sha256:3135b5ae50fe12816ef291baff420acb727fcd356106e3e9cbfa9e5985cd6f4b", size = 513858 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/45/8c4ebc0c460e6ec38e62ab245ad3c7fc10b210116cea7c16d61602aa9558/stevedore-5.4.1-py3-none-any.whl", hash = "sha256:d10a31c7b86cba16c1f6e8d15416955fc797052351a56af15e608ad20811fcfe", size = 49533 },
]
[[package]]
name = "text-unidecode"
version = "1.3"