Migrate to Alchemical and unittest+pytest framework

This commit is contained in:
Keith Edmunds 2024-04-05 17:47:26 +01:00
commit 836d812ef3
34 changed files with 1987 additions and 1478 deletions

2
.envrc
View File

@ -15,6 +15,6 @@ elif on_git_branch master; then
export MM_DB="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod" export MM_DB="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod"
else else
export MM_ENV="DEVELOPMENT" export MM_ENV="DEVELOPMENT"
export MM_DB="mysql+mysqldb://dev_musicmuster:dev_musicmuster@localhost/dev_musicmuster" export ALCHEMICAL_DATABASE_URI="mysql+mysqldb://dev_musicmuster:dev_musicmuster@localhost/dev_musicmuster"
export PYTHONBREAKPOINT="pudb.set_trace" export PYTHONBREAKPOINT="pudb.set_trace"
fi fi

1
.gitignore vendored
View File

@ -11,3 +11,4 @@ StudioPlaylist.png
*.howto *.howto
.direnv .direnv
tmp/ tmp/
.coverage

View File

@ -53,7 +53,6 @@ class MyTableWidget(QTableView):
class MyModel(QAbstractTableModel): class MyModel(QAbstractTableModel):
def columnCount(self, index): def columnCount(self, index):
return 2 return 2
@ -71,7 +70,11 @@ class MyModel(QAbstractTableModel):
return QVariant() return QVariant()
def flags(self, index): def flags(self, index):
return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEditable return (
Qt.ItemFlag.ItemIsEnabled
| Qt.ItemFlag.ItemIsSelectable
| Qt.ItemFlag.ItemIsEditable
)
class MainWindow(QMainWindow): class MainWindow(QMainWindow):

View File

@ -1,13 +1,18 @@
# Standard library imports
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional from typing import Optional
import datetime as dt
# PyQt imports
from PyQt6.QtCore import pyqtSignal, QObject, QThread from PyQt6.QtCore import pyqtSignal, QObject, QThread
# Third party imports
import numpy as np import numpy as np
import pyqtgraph as pg # type: ignore import pyqtgraph as pg # type: ignore
from sqlalchemy.orm import scoped_session
# App imports
from config import Config from config import Config
from dbconfig import scoped_session
from models import PlaylistRows from models import PlaylistRows
import helpers import helpers
@ -108,7 +113,7 @@ class PlaylistTrack:
self.artist: Optional[str] = None self.artist: Optional[str] = None
self.duration: Optional[int] = None self.duration: Optional[int] = None
self.end_time: Optional[datetime] = None self.end_time: Optional[dt.datetime] = None
self.fade_at: Optional[int] = None self.fade_at: Optional[int] = None
self.fade_graph: Optional[FadeCurve] = None self.fade_graph: Optional[FadeCurve] = None
self.fade_length: Optional[int] = None self.fade_length: Optional[int] = None
@ -119,7 +124,7 @@ class PlaylistTrack:
self.resume_marker: Optional[float] = None self.resume_marker: Optional[float] = None
self.silence_at: Optional[int] = None self.silence_at: Optional[int] = None
self.start_gap: Optional[int] = None self.start_gap: Optional[int] = None
self.start_time: Optional[datetime] = None self.start_time: Optional[dt.datetime] = None
self.title: Optional[str] = None self.title: Optional[str] = None
self.track_id: Optional[int] = None self.track_id: Optional[int] = None
@ -177,9 +182,9 @@ class PlaylistTrack:
Called when track starts playing Called when track starts playing
""" """
self.start_time = datetime.now() self.start_time = dt.datetime.now()
if self.duration: if self.duration:
self.end_time = self.start_time + timedelta(milliseconds=self.duration) self.end_time = self.start_time + dt.timedelta(milliseconds=self.duration)
class AddFadeCurve(QObject): class AddFadeCurve(QObject):

View File

@ -1,4 +1,4 @@
import datetime import datetime as dt
import logging import logging
import os import os
from typing import List, Optional from typing import List, Optional
@ -35,10 +35,10 @@ class Config(object):
COLOUR_WARNING_TIMER = "#ffc107" COLOUR_WARNING_TIMER = "#ffc107"
DBFS_SILENCE = -50 DBFS_SILENCE = -50
DEBUG_FUNCTIONS: List[Optional[str]] = [] DEBUG_FUNCTIONS: List[Optional[str]] = []
DEBUG_MODULES: List[Optional[str]] = ["dbconfig"] DEBUG_MODULES: List[Optional[str]] = []
DEFAULT_COLUMN_WIDTH = 200 DEFAULT_COLUMN_WIDTH = 200
DISPLAY_SQL = False DISPLAY_SQL = False
EPOCH = datetime.datetime(1970, 1, 1) EPOCH = dt.datetime(1970, 1, 1)
ERRORS_FROM = ["noreply@midnighthax.com"] ERRORS_FROM = ["noreply@midnighthax.com"]
ERRORS_TO = ["kae@midnighthax.com"] ERRORS_TO = ["kae@midnighthax.com"]
FADE_CURVE_BACKGROUND = "lightyellow" FADE_CURVE_BACKGROUND = "lightyellow"

View File

@ -1,38 +0,0 @@
import inspect
import os
from config import Config
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session
from typing import Generator
from log import log
MYSQL_CONNECT = os.environ.get("MM_DB")
if MYSQL_CONNECT is None:
raise ValueError("MYSQL_CONNECT is undefined")
else:
dbname = MYSQL_CONNECT.split("/")[-1]
log.debug(f"Database: {dbname}")
engine = create_engine(
MYSQL_CONNECT,
echo=Config.DISPLAY_SQL,
pool_pre_ping=True,
future=True,
connect_args={"charset": "utf8mb4"},
)
@contextmanager
def Session() -> Generator[scoped_session, None, None]:
frame = inspect.stack()[2]
file = frame.filename
function = frame.function
lineno = frame.lineno
Session = scoped_session(sessionmaker(bind=engine))
log.debug(f"Session acquired: {file}:{function}:{lineno} " f"[{hex(id(Session))}]")
yield Session
log.debug(f" Session released [{hex(id(Session))}]")
Session.commit()
Session.close()

180
app/dbtables.py Normal file
View File

@ -0,0 +1,180 @@
# Standard library imports
import os
import sys
from typing import List, Optional
import datetime as dt
# PyQt imports
# Third party imports
from alchemical import Alchemical, Model # type: ignore
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
String,
)
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import (
Mapped,
mapped_column,
relationship,
)
# App imports
# Database classes
class CartsTable(Model):
__tablename__ = "carts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
cart_number: Mapped[int] = mapped_column(unique=True)
name: Mapped[str] = mapped_column(String(256), index=True)
duration: Mapped[Optional[int]] = mapped_column(index=True)
path: Mapped[Optional[str]] = mapped_column(String(2048), index=False)
enabled: Mapped[Optional[bool]] = mapped_column(default=False)
def __repr__(self) -> str:
return (
f"<Carts(id={self.id}, cart={self.cart_number}, "
f"name={self.name}, path={self.path}>"
)
class NoteColoursTable(Model):
__tablename__ = "notecolours"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
substring: Mapped[str] = mapped_column(String(256), index=False)
colour: Mapped[str] = mapped_column(String(21), index=False)
enabled: Mapped[bool] = mapped_column(default=True, index=True)
is_regex: Mapped[bool] = mapped_column(default=False, index=False)
is_casesensitive: Mapped[bool] = mapped_column(default=False, index=False)
order: Mapped[Optional[int]] = mapped_column(index=True)
def __repr__(self) -> str:
return (
f"<NoteColours(id={self.id}, substring={self.substring}, "
f"colour={self.colour}>"
)
class PlaydatesTable(Model):
__tablename__ = "playdates"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
lastplayed: Mapped[dt.datetime] = mapped_column(index=True)
track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id"))
track: Mapped["TracksTable"] = relationship(
"TracksTable", back_populates="playdates"
)
def __repr__(self) -> str:
return (
f"<Playdates(id={self.id}, track_id={self.track_id} "
f"lastplayed={self.lastplayed}>"
)
class PlaylistsTable(Model):
"""
Manage playlists
"""
__tablename__ = "playlists"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(32), unique=True)
last_used: Mapped[Optional[dt.datetime]] = mapped_column(DateTime, default=None)
tab: Mapped[Optional[int]] = mapped_column(default=None)
open: Mapped[bool] = mapped_column(default=False)
is_template: Mapped[bool] = mapped_column(default=False)
deleted: Mapped[bool] = mapped_column(default=False)
rows: Mapped[List["PlaylistRowsTable"]] = relationship(
"PlaylistRowsTable",
back_populates="playlist",
cascade="all, delete-orphan",
order_by="PlaylistRowsTable.plr_rownum",
)
def __repr__(self) -> str:
return (
f"<Playlists(id={self.id}, name={self.name}, "
f"is_templatee={self.is_template}, open={self.open}>"
)
class PlaylistRowsTable(Model):
__tablename__ = "playlist_rows"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
plr_rownum: Mapped[int]
note: Mapped[str] = mapped_column(
String(2048), index=False, default="", nullable=False
)
playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id"))
playlist: Mapped[PlaylistsTable] = relationship(back_populates="rows")
track_id: Mapped[Optional[int]] = mapped_column(ForeignKey("tracks.id"))
track: Mapped["TracksTable"] = relationship(
"TracksTable",
back_populates="playlistrows",
)
played: Mapped[bool] = mapped_column(
Boolean, nullable=False, index=False, default=False
)
def __repr__(self) -> str:
return (
f"<PlaylistRow(id={self.id}, playlist_id={self.playlist_id}, "
f"track_id={self.track_id}, "
f"note={self.note}, plr_rownum={self.plr_rownum}>"
)
class SettingsTable(Model):
"""Manage settings"""
__tablename__ = "settings"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(64), unique=True)
f_datetime: Mapped[Optional[dt.datetime]] = mapped_column(default=None)
f_int: Mapped[Optional[int]] = mapped_column(default=None)
f_string: Mapped[Optional[str]] = mapped_column(String(128), default=None)
def __repr__(self) -> str:
return (
f"<Settings(id={self.id}, name={self.name}, "
f"f_datetime={self.f_datetime}, f_int={self.f_int}, f_string={self.f_string}>"
)
class TracksTable(Model):
__tablename__ = "tracks"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(256), index=True)
artist: Mapped[str] = mapped_column(String(256), index=True)
bitrate: Mapped[Optional[int]] = mapped_column(default=None)
duration: Mapped[int] = mapped_column(index=True)
fade_at: Mapped[int] = mapped_column(index=False)
mtime: Mapped[float] = mapped_column(index=True)
path: Mapped[str] = mapped_column(String(2048), index=False, unique=True)
silence_at: Mapped[int] = mapped_column(index=False)
start_gap: Mapped[int] = mapped_column(index=False)
playlistrows: Mapped[List[PlaylistRowsTable]] = relationship(
"PlaylistRowsTable", back_populates="track"
)
playlists = association_proxy("playlistrows", "playlist")
playdates: Mapped[List[PlaydatesTable]] = relationship(
"PlaydatesTable",
back_populates="track",
lazy="joined",
)
def __repr__(self) -> str:
return (
f"<Track(id={self.id}, title={self.title}, "
f"artist={self.artist}, path={self.path}>"
)

View File

@ -1,10 +1,17 @@
# Standard library imports
# PyQt imports
# Third party imports
# App imports
from typing import Optional from typing import Optional
from PyQt6.QtCore import QEvent, Qt from PyQt6.QtCore import QEvent, Qt
from PyQt6.QtWidgets import QDialog, QListWidgetItem from PyQt6.QtWidgets import QDialog, QListWidgetItem
from classes import MusicMusterSignals from classes import MusicMusterSignals
from dbconfig import scoped_session from sqlalchemy.orm import scoped_session
from helpers import ( from helpers import (
ask_yes_no, ask_yes_no,
get_relative_date, get_relative_date,
@ -104,7 +111,9 @@ class TrackSelectDialog(QDialog):
if self.add_to_header: if self.add_to_header:
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
self.source_model.move_track_to_header(self.new_row_number, existing_prd, note) self.source_model.move_track_to_header(
self.new_row_number, existing_prd, note
)
else: else:
self.source_model.add_track_to_header(self.new_row_number, track_id) self.source_model.add_track_to_header(self.new_row_number, track_id)
# Close dialog - we can only add one track to a header # Close dialog - we can only add one track to a header
@ -112,7 +121,9 @@ class TrackSelectDialog(QDialog):
else: else:
# Adding a new track row # Adding a new track row
if move_existing and existing_prd: # "and existing_prd" for mypy's benefit if move_existing and existing_prd: # "and existing_prd" for mypy's benefit
self.source_model.move_track_add_note(self.new_row_number, existing_prd, note) self.source_model.move_track_add_note(
self.new_row_number, existing_prd, note
)
else: else:
self.source_model.insert_row(self.new_row_number, track_id, note) self.source_model.insert_row(self.new_row_number, track_id, note)

View File

@ -1,4 +1,4 @@
from datetime import datetime import datetime as dt
from email.message import EmailMessage from email.message import EmailMessage
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import functools import functools
@ -99,7 +99,7 @@ def get_audio_segment(path: str) -> Optional[AudioSegment]:
return None return None
def get_embedded_time(text: str) -> Optional[datetime]: def get_embedded_time(text: str) -> Optional[dt.datetime]:
"""Return datetime specified as @hh:mm in text""" """Return datetime specified as @hh:mm in text"""
try: try:
@ -110,7 +110,7 @@ def get_embedded_time(text: str) -> Optional[datetime]:
return None return None
try: try:
return datetime.strptime(match.group(0)[1:], Config.NOTE_TIME_FORMAT) return dt.datetime.strptime(match.group(0)[1:], Config.NOTE_TIME_FORMAT)
except ValueError: except ValueError:
return None return None
@ -143,7 +143,7 @@ def get_file_metadata(filepath: str) -> dict:
def get_relative_date( def get_relative_date(
past_date: Optional[datetime], reference_date: Optional[datetime] = None past_date: Optional[dt.datetime], reference_date: Optional[dt.datetime] = None
) -> str: ) -> str:
""" """
Return how long before reference_date past_date is as string. Return how long before reference_date past_date is as string.
@ -158,7 +158,7 @@ def get_relative_date(
if not past_date or past_date == Config.EPOCH: if not past_date or past_date == Config.EPOCH:
return "Never" return "Never"
if not reference_date: if not reference_date:
reference_date = datetime.now() reference_date = dt.datetime.now()
# Check parameters # Check parameters
if past_date > reference_date: if past_date > reference_date:

View File

@ -1,6 +1,6 @@
import urllib.parse import urllib.parse
from datetime import datetime import datetime as dt
from slugify import slugify # type: ignore from slugify import slugify # type: ignore
from typing import Dict from typing import Dict
from PyQt6.QtCore import QUrl # type: ignore from PyQt6.QtCore import QUrl # type: ignore
@ -24,14 +24,14 @@ class InfoTabs(QTabWidget):
self.signals.search_songfacts_signal.connect(self.open_in_songfacts) self.signals.search_songfacts_signal.connect(self.open_in_songfacts)
self.signals.search_wikipedia_signal.connect(self.open_in_wikipedia) self.signals.search_wikipedia_signal.connect(self.open_in_wikipedia)
# re-use the oldest one later) # re-use the oldest one later)
self.last_update: Dict[QWebEngineView, datetime] = {} self.last_update: Dict[QWebEngineView, dt.datetime] = {}
self.tabtitles: Dict[int, str] = {} self.tabtitles: Dict[int, str] = {}
# Create one tab which (for some reason) creates flickering if # Create one tab which (for some reason) creates flickering if
# done later # done later
widget = QWebEngineView() widget = QWebEngineView()
widget.setZoomFactor(Config.WEB_ZOOM_FACTOR) widget.setZoomFactor(Config.WEB_ZOOM_FACTOR)
self.last_update[widget] = datetime.now() self.last_update[widget] = dt.datetime.now()
_ = self.addTab(widget, "") _ = self.addTab(widget, "")
def open_in_songfacts(self, title): def open_in_songfacts(self, title):
@ -80,7 +80,7 @@ class InfoTabs(QTabWidget):
self.setTabText(tab_index, short_title) self.setTabText(tab_index, short_title)
widget.setUrl(QUrl(url)) widget.setUrl(QUrl(url))
self.last_update[widget] = datetime.now() self.last_update[widget] = dt.datetime.now()
self.tabtitles[tab_index] = url self.tabtitles[tab_index] = url
# Show newly updated tab # Show newly updated tab

View File

@ -1,68 +1,46 @@
#!/usr/bin/python3 # Standard library imports
import re
from config import Config
from dbconfig import scoped_session
from datetime import datetime
from pprint import pprint
from typing import List, Optional, Sequence from typing import List, Optional, Sequence
import datetime as dt
import os
import re
import sys
from sqlalchemy.ext.associationproxy import association_proxy # PyQt imports
# Third party imports
from alchemical import Alchemical # type:ignore
from sqlalchemy import ( from sqlalchemy import (
bindparam, bindparam,
Boolean,
DateTime,
delete, delete,
ForeignKey,
func, func,
select, select,
String,
update, update,
) )
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.session import Session
from sqlalchemy.orm import ( # App imports
DeclarativeBase, import dbtables
joinedload, from config import Config
Mapped,
mapped_column,
relationship,
)
from sqlalchemy.orm.exc import (
NoResultFound,
)
from sqlalchemy.exc import (
IntegrityError,
)
from log import log from log import log
class Base(DeclarativeBase): # Establish database connection
pass ALCHEMICAL_DATABASE_URI = os.environ.get("ALCHEMICAL_DATABASE_URI")
if ALCHEMICAL_DATABASE_URI is None:
raise ValueError("ALCHEMICAL_DATABASE_URI is undefined")
if "unittest" in sys.modules and "sqlite" not in ALCHEMICAL_DATABASE_URI:
raise ValueError("Unit tests running on non-Sqlite database")
db = Alchemical(ALCHEMICAL_DATABASE_URI)
# Database classes # Database classes
class Carts(Base): class Carts(dbtables.CartsTable):
__tablename__ = "carts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
cart_number: Mapped[int] = mapped_column(unique=True)
name: Mapped[str] = mapped_column(String(256), index=True)
duration: Mapped[Optional[int]] = mapped_column(index=True)
path: Mapped[Optional[str]] = mapped_column(String(2048), index=False)
enabled: Mapped[Optional[bool]] = mapped_column(default=False)
def __repr__(self) -> str:
return (
f"<Carts(id={self.id}, cart={self.cart_number}, "
f"name={self.name}, path={self.path}>"
)
def __init__( def __init__(
self, self,
session: scoped_session, session: Session,
cart_number: int, cart_number: int,
name: str, name: str,
duration: Optional[int] = None, duration: Optional[int] = None,
@ -81,26 +59,10 @@ class Carts(Base):
session.commit() session.commit()
class NoteColours(Base): class NoteColours(dbtables.NoteColoursTable):
__tablename__ = "notecolours"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
substring: Mapped[str] = mapped_column(String(256), index=False)
colour: Mapped[str] = mapped_column(String(21), index=False)
enabled: Mapped[bool] = mapped_column(default=True, index=True)
is_regex: Mapped[bool] = mapped_column(default=False, index=False)
is_casesensitive: Mapped[bool] = mapped_column(default=False, index=False)
order: Mapped[Optional[int]] = mapped_column(index=True)
def __repr__(self) -> str:
return (
f"<NoteColour(id={self.id}, substring={self.substring}, "
f"colour={self.colour}>"
)
def __init__( def __init__(
self, self,
session: scoped_session, session: Session,
substring: str, substring: str,
colour: str, colour: str,
enabled: bool = True, enabled: bool = True,
@ -116,10 +78,10 @@ class NoteColours(Base):
self.order = order self.order = order
session.add(self) session.add(self)
session.flush() session.commit()
@classmethod @classmethod
def get_all(cls, session: scoped_session) -> Sequence["NoteColours"]: def get_all(cls, session: Session) -> Sequence["NoteColours"]:
""" """
Return all records Return all records
""" """
@ -127,7 +89,7 @@ class NoteColours(Base):
return session.scalars(select(cls)).all() return session.scalars(select(cls)).all()
@staticmethod @staticmethod
def get_colour(session: scoped_session, text: str) -> Optional[str]: def get_colour(session: Session, text: str) -> Optional[str]:
""" """
Parse text and return colour string if matched, else empty string Parse text and return colour string if matched, else empty string
""" """
@ -158,30 +120,17 @@ class NoteColours(Base):
return None return None
class Playdates(Base): class Playdates(dbtables.PlaydatesTable):
__tablename__ = "playdates" def __init__(self, session: Session, track_id: int) -> None:
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
lastplayed: Mapped[datetime] = mapped_column(index=True)
track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id"))
track: Mapped["Tracks"] = relationship("Tracks", back_populates="playdates")
def __repr__(self) -> str:
return (
f"<Playdates(id={self.id}, track_id={self.track_id} "
f"lastplayed={self.lastplayed}>"
)
def __init__(self, session: scoped_session, track_id: int) -> None:
"""Record that track was played""" """Record that track was played"""
self.lastplayed = datetime.now() self.lastplayed = dt.datetime.now()
self.track_id = track_id self.track_id = track_id
session.add(self) session.add(self)
session.commit() session.commit()
@staticmethod @staticmethod
def last_played(session: scoped_session, track_id: int) -> datetime: def last_played(session: Session, track_id: int) -> dt.datetime:
"""Return datetime track last played or None""" """Return datetime track last played or None"""
last_played = session.execute( last_played = session.execute(
@ -194,10 +143,12 @@ class Playdates(Base):
if last_played: if last_played:
return last_played[0] return last_played[0]
else: else:
return Config.EPOCH # Should never be reached as we create record with a
# last_played value
return Config.EPOCH # pragma: no cover
@staticmethod @staticmethod
def played_after(session: scoped_session, since: datetime) -> Sequence["Playdates"]: def played_after(session: Session, since: dt.datetime) -> Sequence["Playdates"]:
"""Return a list of Playdates objects since passed time""" """Return a list of Playdates objects since passed time"""
return session.scalars( return session.scalars(
@ -207,50 +158,20 @@ class Playdates(Base):
).all() ).all()
class Playlists(Base): class Playlists(dbtables.PlaylistsTable):
""" def __init__(self, session: Session, name: str):
Manage playlists
"""
__tablename__ = "playlists"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(32), unique=True)
last_used: Mapped[Optional[datetime]] = mapped_column(DateTime, default=None)
tab: Mapped[Optional[int]] = mapped_column(default=None)
open: Mapped[bool] = mapped_column(default=False)
is_template: Mapped[bool] = mapped_column(default=False)
deleted: Mapped[bool] = mapped_column(default=False)
rows: Mapped[List["PlaylistRows"]] = relationship(
"PlaylistRows",
back_populates="playlist",
cascade="all, delete-orphan",
order_by="PlaylistRows.plr_rownum",
)
def __repr__(self) -> str:
return (
f"<Playlists(id={self.id}, name={self.name}, "
f"is_templatee={self.is_template}, open={self.open}>"
)
def __init__(self, session: scoped_session, name: str):
self.name = name self.name = name
session.add(self) session.add(self)
session.flush() session.commit()
@staticmethod @staticmethod
def clear_tabs(session: scoped_session, playlist_ids: List[int]) -> None: def clear_tabs(session: Session, playlist_ids: List[int]) -> None:
""" """
Make all tab records NULL Make all tab records NULL
""" """
session.execute( session.execute(
update(Playlists) update(Playlists).where((Playlists.id.in_(playlist_ids))).values(tab=None)
.where(
(Playlists.id.in_(playlist_ids))
)
.values(tab=None)
) )
def close(self) -> None: def close(self) -> None:
@ -260,7 +181,7 @@ class Playlists(Base):
@classmethod @classmethod
def create_playlist_from_template( def create_playlist_from_template(
cls, session: scoped_session, template: "Playlists", playlist_name: str cls, session: Session, template: "Playlists", playlist_name: str
) -> Optional["Playlists"]: ) -> Optional["Playlists"]:
"""Create a new playlist from template""" """Create a new playlist from template"""
@ -274,16 +195,16 @@ class Playlists(Base):
return playlist return playlist
def delete(self, session: scoped_session) -> None: def delete(self, session: Session) -> None:
""" """
Mark as deleted Mark as deleted
""" """
self.deleted = True self.deleted = True
session.flush() session.commit()
@classmethod @classmethod
def get_all(cls, session: scoped_session) -> Sequence["Playlists"]: def get_all(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all playlists ordered by last use""" """Returns a list of all playlists ordered by last use"""
return session.scalars( return session.scalars(
@ -293,7 +214,7 @@ class Playlists(Base):
).all() ).all()
@classmethod @classmethod
def get_all_templates(cls, session: scoped_session) -> Sequence["Playlists"]: def get_all_templates(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all templates ordered by name""" """Returns a list of all templates ordered by name"""
return session.scalars( return session.scalars(
@ -301,7 +222,7 @@ class Playlists(Base):
).all() ).all()
@classmethod @classmethod
def get_closed(cls, session: scoped_session) -> Sequence["Playlists"]: def get_closed(cls, session: Session) -> Sequence["Playlists"]:
"""Returns a list of all closed playlists ordered by last use""" """Returns a list of all closed playlists ordered by last use"""
return session.scalars( return session.scalars(
@ -315,7 +236,7 @@ class Playlists(Base):
).all() ).all()
@classmethod @classmethod
def get_open(cls, session: scoped_session) -> Sequence[Optional["Playlists"]]: def get_open(cls, session: Session) -> Sequence[Optional["Playlists"]]:
""" """
Return a list of loaded playlists ordered by tab. Return a list of loaded playlists ordered by tab.
""" """
@ -328,10 +249,10 @@ class Playlists(Base):
"""Mark playlist as loaded and used now""" """Mark playlist as loaded and used now"""
self.open = True self.open = True
self.last_used = datetime.now() self.last_used = dt.datetime.now()
@staticmethod @staticmethod
def name_is_available(session: scoped_session, name: str) -> bool: def name_is_available(session: Session, name: str) -> bool:
""" """
Return True if no playlist of this name exists else false. Return True if no playlist of this name exists else false.
""" """
@ -341,17 +262,17 @@ class Playlists(Base):
is None is None
) )
def rename(self, session: scoped_session, new_name: str) -> None: def rename(self, session: Session, new_name: str) -> None:
""" """
Rename playlist Rename playlist
""" """
self.name = new_name self.name = new_name
session.flush() session.commit()
@staticmethod @staticmethod
def save_as_template( def save_as_template(
session: scoped_session, playlist_id: int, template_name: str session: Session, playlist_id: int, template_name: str
) -> None: ) -> None:
"""Save passed playlist as new template""" """Save passed playlist as new template"""
@ -365,35 +286,10 @@ class Playlists(Base):
PlaylistRows.copy_playlist(session, playlist_id, template.id) PlaylistRows.copy_playlist(session, playlist_id, template.id)
class PlaylistRows(Base): class PlaylistRows(dbtables.PlaylistRowsTable):
__tablename__ = "playlist_rows"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
plr_rownum: Mapped[int]
note: Mapped[str] = mapped_column(
String(2048), index=False, default="", nullable=False
)
playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id"))
playlist: Mapped[Playlists] = relationship(back_populates="rows")
track_id: Mapped[Optional[int]] = mapped_column(ForeignKey("tracks.id"))
track: Mapped["Tracks"] = relationship(
"Tracks",
back_populates="playlistrows",
)
played: Mapped[bool] = mapped_column(
Boolean, nullable=False, index=False, default=False
)
def __repr__(self) -> str:
return (
f"<PlaylistRow(id={self.id}, playlist_id={self.playlist_id}, "
f"track_id={self.track_id}, "
f"note={self.note}, plr_rownum={self.plr_rownum}>"
)
def __init__( def __init__(
self, self,
session: scoped_session, session: Session,
playlist_id: int, playlist_id: int,
row_number: int, row_number: int,
note: str = "", note: str = "",
@ -406,7 +302,7 @@ class PlaylistRows(Base):
self.plr_rownum = row_number self.plr_rownum = row_number
self.note = note self.note = note
session.add(self) session.add(self)
session.flush() session.commit()
def append_note(self, extra_note: str) -> None: def append_note(self, extra_note: str) -> None:
"""Append passed note to any existing note""" """Append passed note to any existing note"""
@ -418,7 +314,7 @@ class PlaylistRows(Base):
self.note = extra_note self.note = extra_note
@staticmethod @staticmethod
def copy_playlist(session: scoped_session, src_id: int, dst_id: int) -> None: def copy_playlist(session: Session, src_id: int, dst_id: int) -> None:
"""Copy playlist entries""" """Copy playlist entries"""
src_rows = session.scalars( src_rows = session.scalars(
@ -436,7 +332,7 @@ class PlaylistRows(Base):
@classmethod @classmethod
def deep_row( def deep_row(
cls, session: scoped_session, playlist_id: int, row_number: int cls, session: Session, playlist_id: int, row_number: int
) -> "PlaylistRows": ) -> "PlaylistRows":
""" """
Return a playlist row that includes full track and lastplayed data for Return a playlist row that includes full track and lastplayed data for
@ -456,9 +352,7 @@ class PlaylistRows(Base):
return session.execute(stmt).unique().scalar_one() return session.execute(stmt).unique().scalar_one()
@classmethod @classmethod
def deep_rows( def deep_rows(cls, session: Session, playlist_id: int) -> Sequence["PlaylistRows"]:
cls, session: scoped_session, playlist_id: int
) -> Sequence["PlaylistRows"]:
""" """
Return a list of playlist rows that include full track and lastplayed data for Return a list of playlist rows that include full track and lastplayed data for
given playlist_id., Sequence given playlist_id., Sequence
@ -475,9 +369,7 @@ class PlaylistRows(Base):
return session.scalars(stmt).unique().all() return session.scalars(stmt).unique().all()
@staticmethod @staticmethod
def delete_higher_rows( def delete_higher_rows(session: Session, playlist_id: int, maxrow: int) -> None:
session: scoped_session, playlist_id: int, maxrow: int
) -> None:
""" """
Delete rows in given playlist that have a higher row number Delete rows in given playlist that have a higher row number
than 'maxrow' than 'maxrow'
@ -489,10 +381,10 @@ class PlaylistRows(Base):
PlaylistRows.plr_rownum > maxrow, PlaylistRows.plr_rownum > maxrow,
) )
) )
session.flush() session.commit()
@staticmethod @staticmethod
def delete_row(session: scoped_session, playlist_id: int, row_number: int) -> None: def delete_row(session: Session, playlist_id: int, row_number: int) -> None:
""" """
Delete passed row in given playlist. Delete passed row in given playlist.
""" """
@ -505,7 +397,7 @@ class PlaylistRows(Base):
) )
@staticmethod @staticmethod
def fixup_rownumbers(session: scoped_session, playlist_id: int) -> None: def fixup_rownumbers(session: Session, playlist_id: int) -> None:
""" """
Ensure the row numbers for passed playlist have no gaps Ensure the row numbers for passed playlist have no gaps
""" """
@ -524,7 +416,7 @@ class PlaylistRows(Base):
@classmethod @classmethod
def plrids_to_plrs( def plrids_to_plrs(
cls, session: scoped_session, playlist_id: int, plr_ids: List[int] cls, session: Session, playlist_id: int, plr_ids: List[int]
) -> Sequence["PlaylistRows"]: ) -> Sequence["PlaylistRows"]:
""" """
Take a list of PlaylistRows ids and return a list of corresponding Take a list of PlaylistRows ids and return a list of corresponding
@ -540,7 +432,7 @@ class PlaylistRows(Base):
return plrs return plrs
@staticmethod @staticmethod
def get_last_used_row(session: scoped_session, playlist_id: int) -> Optional[int]: def get_last_used_row(session: Session, playlist_id: int) -> Optional[int]:
"""Return the last used row for playlist, or None if no rows""" """Return the last used row for playlist, or None if no rows"""
return session.execute( return session.execute(
@ -551,7 +443,7 @@ class PlaylistRows(Base):
@staticmethod @staticmethod
def get_track_plr( def get_track_plr(
session: scoped_session, track_id: int, playlist_id: int session: Session, track_id: int, playlist_id: int
) -> Optional["PlaylistRows"]: ) -> Optional["PlaylistRows"]:
"""Return first matching PlaylistRows object or None""" """Return first matching PlaylistRows object or None"""
@ -566,7 +458,7 @@ class PlaylistRows(Base):
@classmethod @classmethod
def get_played_rows( def get_played_rows(
cls, session: scoped_session, playlist_id: int cls, session: Session, playlist_id: int
) -> Sequence["PlaylistRows"]: ) -> Sequence["PlaylistRows"]:
""" """
For passed playlist, return a list of rows that For passed playlist, return a list of rows that
@ -584,10 +476,8 @@ class PlaylistRows(Base):
@classmethod @classmethod
def get_rows_with_tracks( def get_rows_with_tracks(
cls, cls,
session: scoped_session, session: Session,
playlist_id: int, playlist_id: int,
from_row: Optional[int] = None,
to_row: Optional[int] = None,
) -> Sequence["PlaylistRows"]: ) -> Sequence["PlaylistRows"]:
""" """
For passed playlist, return a list of rows that For passed playlist, return a list of rows that
@ -597,18 +487,13 @@ class PlaylistRows(Base):
query = select(cls).where( query = select(cls).where(
cls.playlist_id == playlist_id, cls.track_id.is_not(None) cls.playlist_id == playlist_id, cls.track_id.is_not(None)
) )
if from_row is not None:
query = query.where(cls.plr_rownum >= from_row)
if to_row is not None:
query = query.where(cls.plr_rownum <= to_row)
plrs = session.scalars((query).order_by(cls.plr_rownum)).all() plrs = session.scalars((query).order_by(cls.plr_rownum)).all()
return plrs return plrs
@classmethod @classmethod
def get_unplayed_rows( def get_unplayed_rows(
cls, session: scoped_session, playlist_id: int cls, session: Session, playlist_id: int
) -> Sequence["PlaylistRows"]: ) -> Sequence["PlaylistRows"]:
""" """
For passed playlist, return a list of playlist rows that For passed playlist, return a list of playlist rows that
@ -629,14 +514,25 @@ class PlaylistRows(Base):
@classmethod @classmethod
def insert_row( def insert_row(
cls, session: scoped_session, playlist_id: int, new_row_number: int cls,
session: Session,
playlist_id: int,
new_row_number: int,
note: str = "",
track_id: Optional[int] = None,
) -> "PlaylistRows": ) -> "PlaylistRows":
cls.move_rows_down(session, playlist_id, new_row_number, 1) cls.move_rows_down(session, playlist_id, new_row_number, 1)
return cls(session, playlist_id, new_row_number) return cls(
session,
playlist_id=playlist_id,
row_number=new_row_number,
note=note,
track_id=track_id,
)
@staticmethod @staticmethod
def move_rows_down( def move_rows_down(
session: scoped_session, playlist_id: int, starting_row: int, move_by: int session: Session, playlist_id: int, starting_row: int, move_by: int
) -> None: ) -> None:
""" """
Create space to insert move_by additional rows by incremented row Create space to insert move_by additional rows by incremented row
@ -656,7 +552,7 @@ class PlaylistRows(Base):
@staticmethod @staticmethod
def update_plr_rownumbers( def update_plr_rownumbers(
session: scoped_session, playlist_id: int, sqla_map: List[dict[str, int]] session: Session, playlist_id: int, sqla_map: List[dict[str, int]]
) -> None: ) -> None:
""" """
Take a {plrid: plr_rownum} dictionary and update the row numbers accordingly Take a {plrid: plr_rownum} dictionary and update the row numbers accordingly
@ -675,27 +571,11 @@ class PlaylistRows(Base):
session.connection().execute(stmt, sqla_map) session.connection().execute(stmt, sqla_map)
class Settings(Base): class Settings(dbtables.SettingsTable):
"""Manage settings""" def __init__(self, session: Session, name: str):
__tablename__ = "settings"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(64), unique=True)
f_datetime: Mapped[Optional[datetime]] = mapped_column(default=None)
f_int: Mapped[Optional[int]] = mapped_column(default=None)
f_string: Mapped[Optional[str]] = mapped_column(String(128), default=None)
def __repr__(self) -> str:
return (
f"<Settings(id={self.id}, name={self.name}, "
f"f_datetime={self.f_datetime}, f_int={self.f_int}, f_string={self.f_string}>"
)
def __init__(self, session: scoped_session, name: str):
self.name = name self.name = name
session.add(self) session.add(self)
session.flush() session.commit()
@classmethod @classmethod
def all_as_dict(cls, session): def all_as_dict(cls, session):
@ -712,7 +592,7 @@ class Settings(Base):
return result return result
@classmethod @classmethod
def get_int_settings(cls, session: scoped_session, name: str) -> "Settings": def get_int_settings(cls, session: Session, name: str) -> "Settings":
"""Get setting for an integer or return new setting record""" """Get setting for an integer or return new setting record"""
try: try:
@ -721,36 +601,14 @@ class Settings(Base):
except NoResultFound: except NoResultFound:
return Settings(session, name) return Settings(session, name)
def update(self, session: scoped_session, data: dict) -> None: def update(self, session: Session, data: dict) -> None:
for key, value in data.items(): for key, value in data.items():
assert hasattr(self, key) assert hasattr(self, key)
setattr(self, key, value) setattr(self, key, value)
session.flush() session.commit()
class Tracks(Base): class Tracks(dbtables.TracksTable):
__tablename__ = "tracks"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(256), index=True)
artist: Mapped[str] = mapped_column(String(256), index=True)
bitrate: Mapped[Optional[int]] = mapped_column(default=None)
duration: Mapped[int] = mapped_column(index=True)
fade_at: Mapped[int] = mapped_column(index=False)
mtime: Mapped[float] = mapped_column(index=True)
path: Mapped[str] = mapped_column(String(2048), index=False, unique=True)
silence_at: Mapped[int] = mapped_column(index=False)
start_gap: Mapped[int] = mapped_column(index=False)
playlistrows: Mapped[List[PlaylistRows]] = relationship(
"PlaylistRows", back_populates="track"
)
playlists = association_proxy("playlistrows", "playlist")
playdates: Mapped[List[Playdates]] = relationship(
"Playdates",
back_populates="track",
lazy="joined",
)
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
f"<Track(id={self.id}, title={self.title}, " f"<Track(id={self.id}, title={self.title}, "
@ -759,7 +617,7 @@ class Tracks(Base):
def __init__( def __init__(
self, self,
session: scoped_session, session: Session,
path: str, path: str,
title: str, title: str,
artist: str, artist: str,
@ -795,7 +653,7 @@ class Tracks(Base):
return session.scalars(select(cls)).unique().all() return session.scalars(select(cls)).unique().all()
@classmethod @classmethod
def get_by_path(cls, session: scoped_session, path: str) -> Optional["Tracks"]: def get_by_path(cls, session: Session, path: str) -> Optional["Tracks"]:
""" """
Return track with passed path, or None. Return track with passed path, or None.
""" """
@ -810,7 +668,7 @@ class Tracks(Base):
return None return None
@classmethod @classmethod
def search_artists(cls, session: scoped_session, text: str) -> Sequence["Tracks"]: def search_artists(cls, session: Session, text: str) -> Sequence["Tracks"]:
""" """
Search case-insenstively for artists containing str Search case-insenstively for artists containing str
@ -831,7 +689,7 @@ class Tracks(Base):
) )
@classmethod @classmethod
def search_titles(cls, session: scoped_session, text: str) -> Sequence["Tracks"]: def search_titles(cls, session: Session, text: str) -> Sequence["Tracks"]:
""" """
Search case-insenstively for titles containing str Search case-insenstively for titles containing str

View File

@ -1,22 +1,17 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from datetime import datetime, timedelta # Standard library imports
from time import sleep
from typing import (
cast,
List,
Optional,
)
from os.path import basename from os.path import basename
from time import sleep
from typing import cast, List, Optional
import argparse import argparse
import datetime as dt
import os import os
import subprocess import subprocess
import sys import sys
import threading import threading
import pipeclient # PyQt imports
from pygame import mixer
from PyQt6.QtCore import ( from PyQt6.QtCore import (
pyqtSignal, pyqtSignal,
QDate, QDate,
@ -49,8 +44,14 @@ from PyQt6.QtWidgets import (
QProgressBar, QProgressBar,
QPushButton, QPushButton,
) )
# Third party imports
from pygame import mixer
import pipeclient
from sqlalchemy.orm import scoped_session
import stackprinter # type: ignore import stackprinter # type: ignore
# App imports
from classes import ( from classes import (
track_sequence, track_sequence,
FadeCurve, FadeCurve,
@ -58,23 +59,18 @@ from classes import (
PlaylistTrack, PlaylistTrack,
) )
from config import Config from config import Config
from dbconfig import (
engine,
scoped_session,
Session,
)
from dialogs import TrackSelectDialog from dialogs import TrackSelectDialog
from log import log from log import log
from models import Base, Carts, Playdates, PlaylistRows, Playlists, Settings, Tracks from models import db, Carts, Playdates, PlaylistRows, Playlists, Settings, Tracks
from playlistmodel import PlaylistModel, PlaylistProxyModel from playlistmodel import PlaylistModel, PlaylistProxyModel
from playlists import PlaylistTab from playlists import PlaylistTab
from ui import icons_rc # noqa F401
from ui.dlg_cart_ui import Ui_DialogCartEdit # type: ignore from ui.dlg_cart_ui import Ui_DialogCartEdit # type: ignore
from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore
from ui.downloadcsv_ui import Ui_DateSelect # type: ignore from ui.downloadcsv_ui import Ui_DateSelect # type: ignore
from ui.main_window_ui import Ui_MainWindow # type: ignore from ui.main_window_ui import Ui_MainWindow # type: ignore
from utilities import check_db, update_bitrates from utilities import check_db, update_bitrates
import helpers import helpers
from ui import icons_rc # noqa F401
import music import music
@ -168,7 +164,7 @@ class ImportTrack(QObject):
Create track objects from passed files and add to visible playlist Create track objects from passed files and add to visible playlist
""" """
with Session() as session: with db.Session() as session:
for fname in self.filenames: for fname in self.filenames:
self.signals.status_message_signal.emit( self.signals.status_message_signal.emit(
f"Importing {basename(fname)}", 5000 f"Importing {basename(fname)}", 5000
@ -208,14 +204,12 @@ class Window(QMainWindow, Ui_MainWindow):
self.music: music.Music = music.Music() self.music: music.Music = music.Music()
self.playing: bool = False self.playing: bool = False
self.selected_plrs: Optional[List[PlaylistRows]] = None
self.set_main_window_size() self.set_main_window_size()
self.lblSumPlaytime = QLabel("") self.lblSumPlaytime = QLabel("")
self.statusbar.addPermanentWidget(self.lblSumPlaytime) self.statusbar.addPermanentWidget(self.lblSumPlaytime)
self.txtSearch = QLineEdit() self.txtSearch = QLineEdit()
self.statusbar.addWidget(self.txtSearch)
self.txtSearch.setHidden(True) self.txtSearch.setHidden(True)
self.statusbar.addWidget(self.txtSearch)
self.hide_played_tracks = False self.hide_played_tracks = False
mixer.init() mixer.init()
self.widgetFadeVolume.hideAxis("bottom") self.widgetFadeVolume.hideAxis("bottom")
@ -257,7 +251,7 @@ class Window(QMainWindow, Ui_MainWindow):
except subprocess.CalledProcessError as exc_info: except subprocess.CalledProcessError as exc_info:
git_tag = str(exc_info.output) git_tag = str(exc_info.output)
with Session() as session: with db.Session() as session:
if session.bind: if session.bind:
dbname = session.bind.engine.url.database dbname = session.bind.engine.url.database
@ -319,7 +313,7 @@ class Window(QMainWindow, Ui_MainWindow):
def cart_edit(self, btn: CartButton, event: QEvent): def cart_edit(self, btn: CartButton, event: QEvent):
"""Handle context menu for cart button""" """Handle context menu for cart button"""
with Session() as session: with db.Session() as session:
cart = session.query(Carts).get(btn.cart_id) cart = session.query(Carts).get(btn.cart_id)
if cart is None: if cart is None:
log.error("cart_edit: cart not found") log.error("cart_edit: cart not found")
@ -351,7 +345,7 @@ class Window(QMainWindow, Ui_MainWindow):
def carts_init(self) -> None: def carts_init(self) -> None:
"""Initialse carts data structures""" """Initialse carts data structures"""
with Session() as session: with db.Session() as session:
# Number carts from 1 for humanity # Number carts from 1 for humanity
for cart_number in range(1, Config.CARTS_COUNT + 1): for cart_number in range(1, Config.CARTS_COUNT + 1):
cart = session.query(Carts).get(cart_number) cart = session.query(Carts).get(cart_number)
@ -428,7 +422,7 @@ class Window(QMainWindow, Ui_MainWindow):
self, "Track playing", "Can't close application while track is playing" self, "Track playing", "Can't close application while track is playing"
) )
else: else:
with Session() as session: with db.Session() as session:
settings = Settings.all_as_dict(session) settings = Settings.all_as_dict(session)
record = settings["mainwindow_height"] record = settings["mainwindow_height"]
if record.f_int != self.height(): if record.f_int != self.height():
@ -497,7 +491,7 @@ class Window(QMainWindow, Ui_MainWindow):
return False return False
# Record playlist as closed and update remaining playlist tabs # Record playlist as closed and update remaining playlist tabs
with Session() as session: with db.Session() as session:
playlist = session.get(Playlists, closing_tab_playlist_id) playlist = session.get(Playlists, closing_tab_playlist_id)
if playlist: if playlist:
playlist.close() playlist.close()
@ -590,7 +584,7 @@ class Window(QMainWindow, Ui_MainWindow):
def create_and_show_playlist(self) -> None: def create_and_show_playlist(self) -> None:
"""Create new playlist and display it""" """Create new playlist and display it"""
with Session() as session: with db.Session() as session:
playlist = self.create_playlist(session) playlist = self.create_playlist(session)
if playlist: if playlist:
self.create_playlist_tab(playlist) self.create_playlist_tab(playlist)
@ -638,7 +632,7 @@ class Window(QMainWindow, Ui_MainWindow):
Delete current playlist Delete current playlist
""" """
with Session() as session: with db.Session() as session:
playlist_id = self.active_tab().playlist_id playlist_id = self.active_tab().playlist_id
playlist = session.get(Playlists, playlist_id) playlist = session.get(Playlists, playlist_id)
if playlist: if playlist:
@ -672,7 +666,7 @@ class Window(QMainWindow, Ui_MainWindow):
path += ".csv" path += ".csv"
with open(path, "w") as f: with open(path, "w") as f:
with Session() as session: with db.Session() as session:
for playdate in Playdates.played_after(session, start_dt): for playdate in Playdates.played_after(session, start_dt):
f.write(f"{playdate.track.artist},{playdate.track.title}\n") f.write(f"{playdate.track.artist},{playdate.track.title}\n")
@ -704,7 +698,7 @@ class Window(QMainWindow, Ui_MainWindow):
playlist_id = self.active_tab().playlist_id playlist_id = self.active_tab().playlist_id
with Session() as session: with db.Session() as session:
# Get output filename # Get output filename
playlist = session.get(Playlists, playlist_id) playlist = session.get(Playlists, playlist_id)
if not playlist: if not playlist:
@ -755,7 +749,7 @@ class Window(QMainWindow, Ui_MainWindow):
if track_sequence.now.track_id is None or track_sequence.now.start_time is None: if track_sequence.now.track_id is None or track_sequence.now.start_time is None:
return 0 return 0
now = datetime.now() now = dt.datetime.now()
track_start = track_sequence.now.start_time track_start = track_sequence.now.start_time
elapsed_seconds = (now - track_start).total_seconds() elapsed_seconds = (now - track_start).total_seconds()
return int(elapsed_seconds * 1000) return int(elapsed_seconds * 1000)
@ -786,7 +780,7 @@ class Window(QMainWindow, Ui_MainWindow):
if not dlg.exec(): if not dlg.exec():
return return
with Session() as session: with db.Session() as session:
new_tracks = [] new_tracks = []
for fname in dlg.selectedFiles(): for fname in dlg.selectedFiles():
txt = "" txt = ""
@ -885,7 +879,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.active_tab().source_model_selected_row_number() self.active_tab().source_model_selected_row_number()
or self.active_proxy_model().rowCount() or self.active_proxy_model().rowCount()
) )
with Session() as session: with db.Session() as session:
dlg = TrackSelectDialog( dlg = TrackSelectDialog(
session=session, session=session,
new_row_number=new_row_number, new_row_number=new_row_number,
@ -897,12 +891,12 @@ class Window(QMainWindow, Ui_MainWindow):
"""Load the playlists that were open when the last session closed""" """Load the playlists that were open when the last session closed"""
playlist_ids = [] playlist_ids = []
with Session() as session: with db.Session() as session:
for playlist in Playlists.get_open(session): for playlist in Playlists.get_open(session):
if playlist: if playlist:
_ = self.create_playlist_tab(playlist) _ = self.create_playlist_tab(playlist)
playlist_ids.append(playlist.id) playlist_ids.append(playlist.id)
log.info(f"load_last_playlists() loaded {playlist=}") log.debug(f"load_last_playlists() loaded {playlist=}")
# Set active tab # Set active tab
record = Settings.get_int_settings(session, "active_tab") record = Settings.get_int_settings(session, "active_tab")
if record.f_int is not None and record.f_int >= 0: if record.f_int is not None and record.f_int >= 0:
@ -954,7 +948,7 @@ class Window(QMainWindow, Ui_MainWindow):
visible_tab = self.active_tab() visible_tab = self.active_tab()
source_playlist_id = visible_tab.playlist_id source_playlist_id = visible_tab.playlist_id
with Session() as session: with db.Session() as session:
for playlist in Playlists.get_all(session): for playlist in Playlists.get_all(session):
if playlist.id == source_playlist_id: if playlist.id == source_playlist_id:
continue continue
@ -1007,7 +1001,7 @@ class Window(QMainWindow, Ui_MainWindow):
def new_from_template(self) -> None: def new_from_template(self) -> None:
"""Create new playlist from template""" """Create new playlist from template"""
with Session() as session: with db.Session() as session:
templates = Playlists.get_all_templates(session) templates = Playlists.get_all_templates(session)
dlg = SelectPlaylistDialog(self, playlists=templates, session=session) dlg = SelectPlaylistDialog(self, playlists=templates, session=session)
dlg.exec() dlg.exec()
@ -1033,7 +1027,7 @@ class Window(QMainWindow, Ui_MainWindow):
def open_playlist(self) -> None: def open_playlist(self) -> None:
"""Open existing playlist""" """Open existing playlist"""
with Session() as session: with db.Session() as session:
playlists = Playlists.get_closed(session) playlists = Playlists.get_closed(session)
dlg = SelectPlaylistDialog(self, playlists=playlists, session=session) dlg = SelectPlaylistDialog(self, playlists=playlists, session=session)
dlg.exec() dlg.exec()
@ -1203,7 +1197,7 @@ class Window(QMainWindow, Ui_MainWindow):
Rename current playlist Rename current playlist
""" """
with Session() as session: with db.Session() as session:
playlist_id = self.active_tab().playlist_id playlist_id = self.active_tab().playlist_id
playlist = session.get(Playlists, playlist_id) playlist = session.get(Playlists, playlist_id)
if playlist: if playlist:
@ -1247,12 +1241,12 @@ class Window(QMainWindow, Ui_MainWindow):
and track_sequence.now.resume_marker and track_sequence.now.resume_marker
): ):
elapsed_ms = track_sequence.now.duration * track_sequence.now.resume_marker elapsed_ms = track_sequence.now.duration * track_sequence.now.resume_marker
track_sequence.now.start_time -= timedelta(milliseconds=elapsed_ms) track_sequence.now.start_time -= dt.timedelta(milliseconds=elapsed_ms)
def save_as_template(self) -> None: def save_as_template(self) -> None:
"""Save current playlist as template""" """Save current playlist as template"""
with Session() as session: with db.Session() as session:
template_names = [a.name for a in Playlists.get_all_templates(session)] template_names = [a.name for a in Playlists.get_all_templates(session)]
while True: while True:
@ -1314,7 +1308,7 @@ class Window(QMainWindow, Ui_MainWindow):
def set_main_window_size(self) -> None: def set_main_window_size(self) -> None:
"""Set size of window from database""" """Set size of window from database"""
with Session() as session: with db.Session() as session:
settings = Settings.all_as_dict(session) settings = Settings.all_as_dict(session)
record = settings["mainwindow_x"] record = settings["mainwindow_x"]
x = record.f_int or 1 x = record.f_int or 1
@ -1508,7 +1502,7 @@ class Window(QMainWindow, Ui_MainWindow):
and track_sequence.now.start_time and track_sequence.now.start_time
): ):
play_time = ( play_time = (
datetime.now() - track_sequence.now.start_time dt.datetime.now() - track_sequence.now.start_time
).total_seconds() * 1000 ).total_seconds() * 1000
track_sequence.now.fade_graph.tick(play_time) track_sequence.now.fade_graph.tick(play_time)
@ -1517,7 +1511,7 @@ class Window(QMainWindow, Ui_MainWindow):
Called every 500ms Called every 500ms
""" """
self.lblTOD.setText(datetime.now().strftime(Config.TOD_TIME_FORMAT)) self.lblTOD.setText(dt.datetime.now().strftime(Config.TOD_TIME_FORMAT))
# Update carts # Update carts
# self.cart_tick() # self.cart_tick()
@ -1545,8 +1539,8 @@ class Window(QMainWindow, Ui_MainWindow):
and track_sequence.now.start_time and track_sequence.now.start_time
and ( and (
self.music.player.is_playing() self.music.player.is_playing()
or (datetime.now() - track_sequence.now.start_time) or (dt.datetime.now() - track_sequence.now.start_time)
< timedelta(microseconds=Config.PLAY_SETTLE) < dt.timedelta(microseconds=Config.PLAY_SETTLE)
) )
): ):
playtime = self.get_playtime() playtime = self.get_playtime()
@ -1755,18 +1749,15 @@ if __name__ == "__main__":
# Run as required # Run as required
if args.check_db: if args.check_db:
log.debug("Updating database") log.debug("Checking database")
with Session() as session: with db.Session() as session:
check_db(session) check_db(session)
engine.dispose()
elif args.update_bitrates: elif args.update_bitrates:
log.debug("Update bitrates") log.debug("Update bitrates")
with Session() as session: with db.Session() as session:
update_bitrates(session) update_bitrates(session)
engine.dispose()
else: else:
try: try:
Base.metadata.create_all(engine)
app = QApplication(sys.argv) app = QApplication(sys.argv)
# PyQt6 defaults to a grey for labels # PyQt6 defaults to a grey for labels
palette = app.palette() palette = app.palette()
@ -1784,7 +1775,6 @@ if __name__ == "__main__":
win = Window() win = Window()
win.show() win.show()
status = app.exec() status = app.exec()
engine.dispose()
sys.exit(status) sys.exit(status)
except Exception as exc: except Exception as exc:
if os.environ["MM_ENV"] == "PRODUCTION": if os.environ["MM_ENV"] == "PRODUCTION":

View File

@ -81,22 +81,22 @@ import argparse
if sys.version_info[0] < 3: if sys.version_info[0] < 3:
raise RuntimeError('PipeClient Error: Python 3.x required') raise RuntimeError("PipeClient Error: Python 3.x required")
# Platform specific constants # Platform specific constants
if sys.platform == 'win32': if sys.platform == "win32":
WRITE_NAME: str = '\\\\.\\pipe\\ToSrvPipe' WRITE_NAME: str = "\\\\.\\pipe\\ToSrvPipe"
READ_NAME: str = '\\\\.\\pipe\\FromSrvPipe' READ_NAME: str = "\\\\.\\pipe\\FromSrvPipe"
EOL: str = '\r\n\0' EOL: str = "\r\n\0"
else: else:
# Linux or Mac # Linux or Mac
PIPE_BASE: str = '/tmp/audacity_script_pipe.' PIPE_BASE: str = "/tmp/audacity_script_pipe."
WRITE_NAME: str = PIPE_BASE + 'to.' + str(os.getuid()) WRITE_NAME: str = PIPE_BASE + "to." + str(os.getuid())
READ_NAME: str = PIPE_BASE + 'from.' + str(os.getuid()) READ_NAME: str = PIPE_BASE + "from." + str(os.getuid())
EOL: str = '\n' EOL: str = "\n"
class PipeClient(): class PipeClient:
"""Write / read client access to Audacity via named pipes. """Write / read client access to Audacity via named pipes.
Normally there should be just one instance of this class. If Normally there should be just one instance of this class. If
@ -141,7 +141,7 @@ class PipeClient():
self.timer: bool = False # type: ignore self.timer: bool = False # type: ignore
self._start_time: float = 0 # type: ignore self._start_time: float = 0 # type: ignore
self._write_pipe = None self._write_pipe = None
self.reply: str = '' # type: ignore self.reply: str = "" # type: ignore
if not self._write_pipe: if not self._write_pipe:
self._write_thread_start() self._write_thread_start()
self._read_thread_start() self._read_thread_start()
@ -156,7 +156,7 @@ class PipeClient():
# Allow a little time for connection to be made. # Allow a little time for connection to be made.
time.sleep(0.1) time.sleep(0.1)
if not self._write_pipe: if not self._write_pipe:
raise RuntimeError('PipeClientError: Write pipe cannot be opened.') raise RuntimeError("PipeClientError: Write pipe cannot be opened.")
def _write_pipe_open(self) -> None: def _write_pipe_open(self) -> None:
"""Open _write_pipe.""" """Open _write_pipe."""
@ -187,16 +187,16 @@ class PipeClient():
self._write_pipe.write(command + EOL) self._write_pipe.write(command + EOL)
# Check that read pipe is alive # Check that read pipe is alive
if PipeClient.reader_pipe_broken.is_set(): if PipeClient.reader_pipe_broken.is_set():
raise RuntimeError('PipeClient: Read-pipe error.') raise RuntimeError("PipeClient: Read-pipe error.")
try: try:
self._write_pipe.flush() self._write_pipe.flush()
if self.timer: if self.timer:
self._start_time = time.time() self._start_time = time.time()
self.reply = '' self.reply = ""
PipeClient.reply_ready.clear() PipeClient.reply_ready.clear()
except IOError as err: except IOError as err:
if err.errno == errno.EPIPE: if err.errno == errno.EPIPE:
raise RuntimeError('PipeClient: Write-pipe error.') raise RuntimeError("PipeClient: Write-pipe error.")
else: else:
raise raise
@ -211,20 +211,20 @@ class PipeClient():
line = read_pipe.readline() line = read_pipe.readline()
# Stop timer as soon as we get first line of response. # Stop timer as soon as we get first line of response.
stop_time = time.time() stop_time = time.time()
while pipe_ok and line != '\n': while pipe_ok and line != "\n":
message += line message += line
line = read_pipe.readline() line = read_pipe.readline()
if line == '': if line == "":
# No data in read_pipe indicates that the pipe # No data in read_pipe indicates that the pipe
# is broken (Audacity may have crashed). # is broken (Audacity may have crashed).
PipeClient.reader_pipe_broken.set() PipeClient.reader_pipe_broken.set()
pipe_ok = False pipe_ok = False
if self.timer: if self.timer:
xtime = (stop_time - self._start_time) * 1000 xtime = (stop_time - self._start_time) * 1000
message += f'Execution time: {xtime:.2f}ms' message += f"Execution time: {xtime:.2f}ms"
self.reply = message self.reply = message
PipeClient.reply_ready.set() PipeClient.reply_ready.set()
message = '' message = ""
def read(self) -> str: def read(self) -> str:
"""Read Audacity's reply from pipe. """Read Audacity's reply from pipe.
@ -238,31 +238,45 @@ class PipeClient():
""" """
if not PipeClient.reply_ready.is_set(): if not PipeClient.reply_ready.is_set():
return '' return ""
return self.reply return self.reply
def bool_from_string(strval) -> bool: def bool_from_string(strval) -> bool:
"""Return boolean value from string""" """Return boolean value from string"""
if strval.lower() in ('true', 't', '1', 'yes', 'y'): if strval.lower() in ("true", "t", "1", "yes", "y"):
return True return True
if strval.lower() in ('false', 'f', '0', 'no', 'n'): if strval.lower() in ("false", "f", "0", "no", "n"):
return False return False
raise argparse.ArgumentTypeError('Boolean value expected.') raise argparse.ArgumentTypeError("Boolean value expected.")
def main() -> None: def main() -> None:
"""Interactive command-line for PipeClient""" """Interactive command-line for PipeClient"""
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('-t', '--timeout', type=float, metavar='', default=10, parser.add_argument(
help="timeout for reply in seconds (default: 10") "-t",
parser.add_argument('-s', '--show-time', metavar='True/False', "--timeout",
nargs='?', type=bool_from_string, type=float,
const='t', default='t', dest='show', metavar="",
help='show command execution time (default: True)') default=10,
parser.add_argument('-d', '--docs', action='store_true', help="timeout for reply in seconds (default: 10",
help='show documentation and exit') )
parser.add_argument(
"-s",
"--show-time",
metavar="True/False",
nargs="?",
type=bool_from_string,
const="t",
default="t",
dest="show",
help="show command execution time (default: True)",
)
parser.add_argument(
"-d", "--docs", action="store_true", help="show documentation and exit"
)
args = parser.parse_args() args = parser.parse_args()
if args.docs: if args.docs:
@ -271,23 +285,23 @@ def main() -> None:
client: PipeClient = PipeClient() client: PipeClient = PipeClient()
while True: while True:
reply: str = '' reply: str = ""
message: str = input("\nEnter command or 'Q' to quit: ") message: str = input("\nEnter command or 'Q' to quit: ")
start = time.time() start = time.time()
if message.upper() == 'Q': if message.upper() == "Q":
sys.exit(0) sys.exit(0)
elif message == '': elif message == "":
pass pass
else: else:
client.write(message, timer=args.show) client.write(message, timer=args.show)
while reply == '': while reply == "":
time.sleep(0.1) # allow time for reply time.sleep(0.1) # allow time for reply
if time.time() - start > args.timeout: if time.time() - start > args.timeout:
reply = 'PipeClient: Reply timed-out.' reply = "PipeClient: Reply timed-out."
else: else:
reply = client.read() reply = client.read()
print(reply) print(reply)
if __name__ == '__main__': if __name__ == "__main__":
main() main()

View File

@ -1,14 +1,16 @@
# Standard library imports
# Allow forward reference to PlaylistModel # Allow forward reference to PlaylistModel
from __future__ import annotations from __future__ import annotations
import obsws_python as obs # type: ignore
import re
from datetime import datetime, timedelta
from enum import auto, Enum from enum import auto, Enum
from operator import attrgetter from operator import attrgetter
from time import sleep from time import sleep
from random import shuffle from random import shuffle
from typing import List, Optional from typing import List, Optional
import datetime as dt
import re
# PyQt imports
from PyQt6.QtCore import ( from PyQt6.QtCore import (
QAbstractTableModel, QAbstractTableModel,
QModelIndex, QModelIndex,
@ -24,9 +26,14 @@ from PyQt6.QtGui import (
QFont, QFont,
) )
# Third party imports
import obsws_python as obs # type: ignore
# import snoop # type: ignore
# App imports
from classes import track_sequence, MusicMusterSignals, PlaylistTrack from classes import track_sequence, MusicMusterSignals, PlaylistTrack
from config import Config from config import Config
from dbconfig import scoped_session, Session
from helpers import ( from helpers import (
file_is_unreadable, file_is_unreadable,
get_embedded_time, get_embedded_time,
@ -35,7 +42,7 @@ from helpers import (
set_track_metadata, set_track_metadata,
) )
from log import log from log import log
from models import NoteColours, Playdates, PlaylistRows, Tracks from models import db, NoteColours, Playdates, PlaylistRows, Tracks
HEADER_NOTES_COLUMN = 1 HEADER_NOTES_COLUMN = 1
@ -63,13 +70,13 @@ class PlaylistRowData:
self.artist: str = "" self.artist: str = ""
self.bitrate = 0 self.bitrate = 0
self.duration: int = 0 self.duration: int = 0
self.lastplayed: datetime = Config.EPOCH self.lastplayed: dt.datetime = Config.EPOCH
self.path = "" self.path = ""
self.played = False self.played = False
self.start_gap: Optional[int] = None self.start_gap: Optional[int] = None
self.title: str = "" self.title: str = ""
self.start_time: Optional[datetime] = None self.start_time: Optional[dt.datetime] = None
self.end_time: Optional[datetime] = None self.end_time: Optional[dt.datetime] = None
self.plrid: int = plr.id self.plrid: int = plr.id
self.plr_rownum: int = plr.plr_rownum self.plr_rownum: int = plr.plr_rownum
@ -117,7 +124,7 @@ class PlaylistModel(QAbstractTableModel):
*args, *args,
**kwargs, **kwargs,
): ):
log.info(f"PlaylistModel.__init__({playlist_id=})") log.debug(f"PlaylistModel.__init__({playlist_id=})")
self.playlist_id = playlist_id self.playlist_id = playlist_id
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -130,7 +137,7 @@ class PlaylistModel(QAbstractTableModel):
self.signals.end_reset_model_signal.connect(self.end_reset_model) self.signals.end_reset_model_signal.connect(self.end_reset_model)
self.signals.row_order_changed_signal.connect(self.row_order_changed) self.signals.row_order_changed_signal.connect(self.row_order_changed)
with Session() as session: with db.Session() as session:
# Ensure row numbers in playlist are contiguous # Ensure row numbers in playlist are contiguous
PlaylistRows.fixup_rownumbers(session, playlist_id) PlaylistRows.fixup_rownumbers(session, playlist_id)
# Populate self.playlist_rows # Populate self.playlist_rows
@ -149,7 +156,7 @@ class PlaylistModel(QAbstractTableModel):
Add track to existing header row Add track to existing header row
""" """
log.info(f"add_track_to_header({row_number=}, {track_id=}, {note=}") log.debug(f"add_track_to_header({row_number=}, {track_id=}, {note=}")
# Get existing row # Get existing row
try: try:
@ -166,7 +173,7 @@ class PlaylistModel(QAbstractTableModel):
"Header row already has track associated" "Header row already has track associated"
) )
return return
with Session() as session: with db.Session() as session:
plr = session.get(PlaylistRows, prd.plrid) plr = session.get(PlaylistRows, prd.plrid)
if plr: if plr:
# Add track to PlaylistRows # Add track to PlaylistRows
@ -188,7 +195,7 @@ class PlaylistModel(QAbstractTableModel):
# Header row # Header row
if self.is_header_row(row): if self.is_header_row(row):
# Check for specific header colouring # Check for specific header colouring
with Session() as session: with db.Session() as session:
note_colour = NoteColours.get_colour(session, prd.note) note_colour = NoteColours.get_colour(session, prd.note)
if note_colour: if note_colour:
return QBrush(QColor(note_colour)) return QBrush(QColor(note_colour))
@ -217,7 +224,7 @@ class PlaylistModel(QAbstractTableModel):
return QBrush(QColor(Config.COLOUR_BITRATE_OK)) return QBrush(QColor(Config.COLOUR_BITRATE_OK))
if column == Col.NOTE.value: if column == Col.NOTE.value:
if prd.note: if prd.note:
with Session() as session: with db.Session() as session:
note_colour = NoteColours.get_colour(session, prd.note) note_colour = NoteColours.get_colour(session, prd.note)
if note_colour: if note_colour:
return QBrush(QColor(note_colour)) return QBrush(QColor(note_colour))
@ -278,7 +285,7 @@ class PlaylistModel(QAbstractTableModel):
log.error("Call OBS scene change") log.error("Call OBS scene change")
self.obs_scene_change(row_number) self.obs_scene_change(row_number)
with Session() as session: with db.Session() as session:
# Update Playdates in database # Update Playdates in database
sleep(1) sleep(1)
log.error("update playdates") log.error("update playdates")
@ -384,7 +391,7 @@ class PlaylistModel(QAbstractTableModel):
Delete from highest row back so that not yet deleted row numbers don't change. Delete from highest row back so that not yet deleted row numbers don't change.
""" """
with Session() as session: with db.Session() as session:
for row_number in sorted(row_numbers, reverse=True): for row_number in sorted(row_numbers, reverse=True):
log.info(f"delete_rows(), {row_number=}") log.info(f"delete_rows(), {row_number=}")
super().beginRemoveRows(QModelIndex(), row_number, row_number) super().beginRemoveRows(QModelIndex(), row_number, row_number)
@ -461,7 +468,7 @@ class PlaylistModel(QAbstractTableModel):
if playlist_id != self.playlist_id: if playlist_id != self.playlist_id:
log.debug(f"end_reset_model: not us ({self.playlist_id=})") log.debug(f"end_reset_model: not us ({self.playlist_id=})")
return return
with Session() as session: with db.Session() as session:
self.refresh_data(session) self.refresh_data(session)
super().endResetModel() super().endResetModel()
self.reset_track_sequence_row_numbers() self.reset_track_sequence_row_numbers()
@ -548,7 +555,7 @@ class PlaylistModel(QAbstractTableModel):
If not given, return row number to add to end of model. If not given, return row number to add to end of model.
""" """
log.info(f"_get_new_row_number({proposed_row_number=})") log.debug(f"_get_new_row_number({proposed_row_number=})")
if proposed_row_number is None or proposed_row_number > len(self.playlist_rows): if proposed_row_number is None or proposed_row_number > len(self.playlist_rows):
# We are adding to the end of the list # We are adding to the end of the list
@ -559,7 +566,7 @@ class PlaylistModel(QAbstractTableModel):
else: else:
new_row_number = proposed_row_number new_row_number = proposed_row_number
log.info(f"get_new_row_number() return: {new_row_number=}") log.debug(f"get_new_row_number() return: {new_row_number=}")
return new_row_number return new_row_number
def get_row_info(self, row_number: int) -> PlaylistRowData: def get_row_info(self, row_number: int) -> PlaylistRowData:
@ -695,8 +702,9 @@ class PlaylistModel(QAbstractTableModel):
< prd.plr_rownum < prd.plr_rownum
) )
): ):
section_end_time = track_sequence.now.end_time + timedelta( section_end_time = (
milliseconds=duration track_sequence.now.end_time
+ dt.timedelta(milliseconds=duration)
) )
end_time_str = ( end_time_str = (
", section end time " ", section end time "
@ -751,23 +759,25 @@ class PlaylistModel(QAbstractTableModel):
self, self,
proposed_row_number: Optional[int], proposed_row_number: Optional[int],
track_id: Optional[int] = None, track_id: Optional[int] = None,
note: Optional[str] = None, note: str = "",
) -> None: ) -> None:
""" """
Insert a row. Insert a row.
""" """
log.info(f"insert_row({proposed_row_number=}, {track_id=}, {note=})") log.debug(f"insert_row({proposed_row_number=}, {track_id=}, {note=})")
new_row_number = self._get_new_row_number(proposed_row_number) new_row_number = self._get_new_row_number(proposed_row_number)
with Session() as session: with db.Session() as session:
super().beginInsertRows(QModelIndex(), new_row_number, new_row_number) super().beginInsertRows(QModelIndex(), new_row_number, new_row_number)
plr = PlaylistRows.insert_row(session, self.playlist_id, new_row_number) _ = PlaylistRows.insert_row(
session=session,
plr.track_id = track_id playlist_id=self.playlist_id,
if note: new_row_number=new_row_number,
plr.note = note note=note,
track_id=track_id,
)
self.refresh_data(session) self.refresh_data(session)
super().endInsertRows() super().endInsertRows()
@ -827,7 +837,7 @@ class PlaylistModel(QAbstractTableModel):
Mark row as unplayed Mark row as unplayed
""" """
with Session() as session: with db.Session() as session:
for row_number in row_numbers: for row_number in row_numbers:
plr = session.get(PlaylistRows, self.playlist_rows[row_number].plrid) plr = session.get(PlaylistRows, self.playlist_rows[row_number].plrid)
if not plr: if not plr:
@ -842,7 +852,7 @@ class PlaylistModel(QAbstractTableModel):
Move the playlist rows given to to_row and below. Move the playlist rows given to to_row and below.
""" """
log.info(f"move_rows({from_rows=}, {to_row_number=}") log.debug(f"move_rows({from_rows=}, {to_row_number=}")
# Build a {current_row_number: new_row_number} dictionary # Build a {current_row_number: new_row_number} dictionary
row_map: dict[int, int] = {} row_map: dict[int, int] = {}
@ -890,7 +900,7 @@ class PlaylistModel(QAbstractTableModel):
plrid = self.playlist_rows[oldrow].plrid plrid = self.playlist_rows[oldrow].plrid
sqla_map.append({"plrid": plrid, "plr_rownum": newrow}) sqla_map.append({"plrid": plrid, "plr_rownum": newrow})
with Session() as session: with db.Session() as session:
PlaylistRows.update_plr_rownumbers(session, self.playlist_id, sqla_map) PlaylistRows.update_plr_rownumbers(session, self.playlist_id, sqla_map)
# Update playlist_rows # Update playlist_rows
self.refresh_data(session) self.refresh_data(session)
@ -906,7 +916,7 @@ class PlaylistModel(QAbstractTableModel):
Move the playlist rows given to to_row and below of to_playlist. Move the playlist rows given to to_row and below of to_playlist.
""" """
log.info( log.debug(
f"move_rows_between_playlists({from_rows=}, {to_row_number=}, {to_playlist_id=}" f"move_rows_between_playlists({from_rows=}, {to_row_number=}, {to_playlist_id=}"
) )
@ -919,7 +929,7 @@ class PlaylistModel(QAbstractTableModel):
# Prepare destination playlist for a reset # Prepare destination playlist for a reset
self.signals.begin_reset_model_signal.emit(to_playlist_id) self.signals.begin_reset_model_signal.emit(to_playlist_id)
with Session() as session: with db.Session() as session:
# Make room in destination playlist # Make room in destination playlist
max_destination_row_number = PlaylistRows.get_last_used_row( max_destination_row_number = PlaylistRows.get_last_used_row(
session, to_playlist_id session, to_playlist_id
@ -969,7 +979,7 @@ class PlaylistModel(QAbstractTableModel):
log.info(f"move_track_add_note({new_row_number=}, {existing_prd=}, {note=}") log.info(f"move_track_add_note({new_row_number=}, {existing_prd=}, {note=}")
if note: if note:
with Session() as session: with db.Session() as session:
plr = session.get(PlaylistRows, existing_prd.plrid) plr = session.get(PlaylistRows, existing_prd.plrid)
if plr: if plr:
if plr.note: if plr.note:
@ -1057,7 +1067,7 @@ class PlaylistModel(QAbstractTableModel):
# Update display # Update display
self.invalidate_row(track_sequence.previous.plr_rownum) self.invalidate_row(track_sequence.previous.plr_rownum)
def refresh_data(self, session: scoped_session): def refresh_data(self, session: db.session):
"""Populate dicts for data calls""" """Populate dicts for data calls"""
# Populate self.playlist_rows with playlist data # Populate self.playlist_rows with playlist data
@ -1078,7 +1088,7 @@ class PlaylistModel(QAbstractTableModel):
log.info(f"remove_track({row_number=})") log.info(f"remove_track({row_number=})")
with Session() as session: with db.Session() as session:
plr = session.get(PlaylistRows, self.playlist_rows[row_number].plrid) plr = session.get(PlaylistRows, self.playlist_rows[row_number].plrid)
if plr: if plr:
plr.track_id = None plr.track_id = None
@ -1092,7 +1102,7 @@ class PlaylistModel(QAbstractTableModel):
track_id = self.playlist_rows[row_number].track_id track_id = self.playlist_rows[row_number].track_id
if track_id: if track_id:
with Session() as session: with db.Session() as session:
track = session.get(Tracks, track_id) track = session.get(Tracks, track_id)
set_track_metadata(track) set_track_metadata(track)
self.refresh_row(session, row_number) self.refresh_row(session, row_number)
@ -1104,11 +1114,11 @@ class PlaylistModel(QAbstractTableModel):
Signal handler for when row ordering has changed Signal handler for when row ordering has changed
""" """
log.info("reset_track_sequence_row_numbers()") log.debug("reset_track_sequence_row_numbers()")
# Check the track_sequence next, now and previous plrs and # Check the track_sequence next, now and previous plrs and
# update the row number # update the row number
with Session() as session: with db.Session() as session:
if track_sequence.next.plr_rownum: if track_sequence.next.plr_rownum:
next_plr = session.get(PlaylistRows, track_sequence.next.plr_id) next_plr = session.get(PlaylistRows, track_sequence.next.plr_id)
if next_plr: if next_plr:
@ -1136,7 +1146,7 @@ class PlaylistModel(QAbstractTableModel):
return: [[20, 21], [17], [13], [9, 10], [7], [2, 3, 4, 5]] return: [[20, 21], [17], [13], [9, 10], [7], [2, 3, 4, 5]]
""" """
log.info(f"_reversed_contiguous_row_groups({row_numbers=} called") log.debug(f"_reversed_contiguous_row_groups({row_numbers=} called")
result: List[List[int]] = [] result: List[List[int]] = []
temp: List[int] = [] temp: List[int] = []
@ -1152,7 +1162,7 @@ class PlaylistModel(QAbstractTableModel):
result.append(temp) result.append(temp)
result.reverse() result.reverse()
log.info(f"_reversed_contiguous_row_groups() returned: {result=}") log.debug(f"_reversed_contiguous_row_groups() returned: {result=}")
return result return result
def rowCount(self, index: QModelIndex = QModelIndex()) -> int: def rowCount(self, index: QModelIndex = QModelIndex()) -> int:
@ -1165,7 +1175,7 @@ class PlaylistModel(QAbstractTableModel):
Signal handler for when row ordering has changed Signal handler for when row ordering has changed
""" """
log.info(f"row_order_changed({playlist_id=}) {self.playlist_id=}") log.debug(f"row_order_changed({playlist_id=}) {self.playlist_id=}")
# Only action if this is for us # Only action if this is for us
if playlist_id != self.playlist_id: if playlist_id != self.playlist_id:
@ -1213,7 +1223,7 @@ class PlaylistModel(QAbstractTableModel):
return return
# Update playing_track # Update playing_track
with Session() as session: with db.Session() as session:
track_sequence.next = PlaylistTrack() track_sequence.next = PlaylistTrack()
try: try:
plrid = self.playlist_rows[row_number].plrid plrid = self.playlist_rows[row_number].plrid
@ -1253,7 +1263,7 @@ class PlaylistModel(QAbstractTableModel):
row_number = index.row() row_number = index.row()
column = index.column() column = index.column()
with Session() as session: with db.Session() as session:
plr = session.get(PlaylistRows, self.playlist_rows[row_number].plrid) plr = session.get(PlaylistRows, self.playlist_rows[row_number].plrid)
if not plr: if not plr:
print( print(
@ -1349,12 +1359,15 @@ class PlaylistModel(QAbstractTableModel):
Update track start/end times in self.playlist_rows Update track start/end times in self.playlist_rows
""" """
log.info("update_track_times()") log.debug("update_track_times()")
next_start_time: Optional[datetime] = None next_start_time: Optional[dt.datetime] = None
update_rows: List[int] = [] update_rows: List[int] = []
playlist_length = len(self.playlist_rows)
if not playlist_length:
return
for row_number in range(len(self.playlist_rows)): for row_number in range(playlist_length):
prd = self.playlist_rows[row_number] prd = self.playlist_rows[row_number]
# Reset start_time if this is the current row # Reset start_time if this is the current row
@ -1372,7 +1385,7 @@ class PlaylistModel(QAbstractTableModel):
and track_sequence.now.end_time and track_sequence.now.end_time
): ):
prd.start_time = track_sequence.now.end_time prd.start_time = track_sequence.now.end_time
prd.end_time = prd.start_time + timedelta(milliseconds=prd.duration) prd.end_time = prd.start_time + dt.timedelta(milliseconds=prd.duration)
next_start_time = prd.end_time next_start_time = prd.end_time
update_rows.append(row_number) update_rows.append(row_number)
continue continue
@ -1417,7 +1430,7 @@ class PlaylistModel(QAbstractTableModel):
update_rows.append(row_number) update_rows.append(row_number)
# Calculate next start time # Calculate next start time
next_start_time += timedelta(milliseconds=prd.duration) next_start_time += dt.timedelta(milliseconds=prd.duration)
# Update end time of this row if it's incorrect # Update end time of this row if it's incorrect
if prd.end_time != next_start_time: if prd.end_time != next_start_time:
@ -1462,7 +1475,7 @@ class PlaylistProxyModel(QSortFilterProxyModel):
if self.source_model.played_tracks_hidden: if self.source_model.played_tracks_hidden:
if self.source_model.is_played_row(source_row): if self.source_model.is_played_row(source_row):
# Don't hide current or next track # Don't hide current or next track
with Session() as session: with db.Session() as session:
if track_sequence.next.plr_id: if track_sequence.next.plr_id:
next_plr = session.get(PlaylistRows, track_sequence.next.plr_id) next_plr = session.get(PlaylistRows, track_sequence.next.plr_id)
if ( if (
@ -1493,9 +1506,9 @@ class PlaylistProxyModel(QSortFilterProxyModel):
== self.source_model.playlist_id == self.source_model.playlist_id
): ):
if track_sequence.now.start_time: if track_sequence.now.start_time:
if datetime.now() > ( if dt.datetime.now() > (
track_sequence.now.start_time track_sequence.now.start_time
+ timedelta( + dt.timedelta(
milliseconds=Config.HIDE_AFTER_PLAYING_OFFSET milliseconds=Config.HIDE_AFTER_PLAYING_OFFSET
) )
): ):

View File

@ -1,8 +1,9 @@
# Standard library imports
from typing import Callable, cast, List, Optional, TYPE_CHECKING
import psutil import psutil
import time import time
from pprint import pprint
from typing import Callable, cast, List, Optional, TYPE_CHECKING
# PyQt imports
from PyQt6.QtCore import ( from PyQt6.QtCore import (
QEvent, QEvent,
QModelIndex, QModelIndex,
@ -30,10 +31,12 @@ from PyQt6.QtWidgets import (
QStyleOption, QStyleOption,
) )
from dbconfig import Session # Third party imports
from dialogs import TrackSelectDialog
# App imports
from classes import MusicMusterSignals, track_sequence from classes import MusicMusterSignals, track_sequence
from config import Config from config import Config
from dialogs import TrackSelectDialog
from helpers import ( from helpers import (
ask_yes_no, ask_yes_no,
ms_to_mmss, ms_to_mmss,
@ -41,11 +44,11 @@ from helpers import (
show_warning, show_warning,
) )
from log import log from log import log
from models import Settings from models import db, Settings
from playlistmodel import PlaylistModel, PlaylistProxyModel
if TYPE_CHECKING: if TYPE_CHECKING:
from musicmuster import Window from musicmuster import Window
from playlistmodel import PlaylistModel, PlaylistProxyModel
class EscapeDelegate(QStyledItemDelegate): class EscapeDelegate(QStyledItemDelegate):
@ -335,7 +338,7 @@ class PlaylistTab(QTableView):
if model_row_number is None: if model_row_number is None:
return return
with Session() as session: with db.Session() as session:
dlg = TrackSelectDialog( dlg = TrackSelectDialog(
session=session, session=session,
new_row_number=model_row_number, new_row_number=model_row_number,
@ -536,7 +539,7 @@ class PlaylistTab(QTableView):
# Resize rows if necessary # Resize rows if necessary
self.resizeRowsToContents() self.resizeRowsToContents()
with Session() as session: with db.Session() as session:
attr_name = f"playlist_col_{column_number}_width" attr_name = f"playlist_col_{column_number}_width"
record = Settings.get_int_settings(session, attr_name) record = Settings.get_int_settings(session, attr_name)
record.f_int = self.columnWidth(column_number) record.f_int = self.columnWidth(column_number)
@ -830,7 +833,7 @@ class PlaylistTab(QTableView):
return return
# Last column is set to stretch so ignore it here # Last column is set to stretch so ignore it here
with Session() as session: with db.Session() as session:
for column_number in range(header.count() - 1): for column_number in range(header.count() - 1):
attr_name = f"playlist_col_{column_number}_width" attr_name = f"playlist_col_{column_number}_width"
record = Settings.get_int_settings(session, attr_name) record = Settings.get_int_settings(session, attr_name)

View File

@ -15,7 +15,11 @@ class Ui_MainWindow(object):
MainWindow.resize(1280, 857) MainWindow.resize(1280, 857)
MainWindow.setMinimumSize(QtCore.QSize(1280, 0)) MainWindow.setMinimumSize(QtCore.QSize(1280, 0))
icon = QtGui.QIcon() icon = QtGui.QIcon()
icon.addPixmap(QtGui.QPixmap(":/icons/musicmuster"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off) icon.addPixmap(
QtGui.QPixmap(":/icons/musicmuster"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
MainWindow.setWindowIcon(icon) MainWindow.setWindowIcon(icon)
MainWindow.setStyleSheet("") MainWindow.setStyleSheet("")
self.centralwidget = QtWidgets.QWidget(parent=MainWindow) self.centralwidget = QtWidgets.QWidget(parent=MainWindow)
@ -27,39 +31,62 @@ class Ui_MainWindow(object):
self.verticalLayout_3 = QtWidgets.QVBoxLayout() self.verticalLayout_3 = QtWidgets.QVBoxLayout()
self.verticalLayout_3.setObjectName("verticalLayout_3") self.verticalLayout_3.setObjectName("verticalLayout_3")
self.previous_track_2 = QtWidgets.QLabel(parent=self.centralwidget) self.previous_track_2 = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred) sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0) sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.previous_track_2.sizePolicy().hasHeightForWidth()) sizePolicy.setHeightForWidth(
self.previous_track_2.sizePolicy().hasHeightForWidth()
)
self.previous_track_2.setSizePolicy(sizePolicy) self.previous_track_2.setSizePolicy(sizePolicy)
self.previous_track_2.setMaximumSize(QtCore.QSize(230, 16777215)) self.previous_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont() font = QtGui.QFont()
font.setFamily("Sans") font.setFamily("Sans")
font.setPointSize(20) font.setPointSize(20)
self.previous_track_2.setFont(font) self.previous_track_2.setFont(font)
self.previous_track_2.setStyleSheet("background-color: #f8d7da;\n" self.previous_track_2.setStyleSheet(
"border: 1px solid rgb(85, 87, 83);") "background-color: #f8d7da;\n" "border: 1px solid rgb(85, 87, 83);"
self.previous_track_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) )
self.previous_track_2.setAlignment(
QtCore.Qt.AlignmentFlag.AlignRight
| QtCore.Qt.AlignmentFlag.AlignTrailing
| QtCore.Qt.AlignmentFlag.AlignVCenter
)
self.previous_track_2.setObjectName("previous_track_2") self.previous_track_2.setObjectName("previous_track_2")
self.verticalLayout_3.addWidget(self.previous_track_2) self.verticalLayout_3.addWidget(self.previous_track_2)
self.current_track_2 = QtWidgets.QLabel(parent=self.centralwidget) self.current_track_2 = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred) sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0) sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.current_track_2.sizePolicy().hasHeightForWidth()) sizePolicy.setHeightForWidth(
self.current_track_2.sizePolicy().hasHeightForWidth()
)
self.current_track_2.setSizePolicy(sizePolicy) self.current_track_2.setSizePolicy(sizePolicy)
self.current_track_2.setMaximumSize(QtCore.QSize(230, 16777215)) self.current_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont() font = QtGui.QFont()
font.setFamily("Sans") font.setFamily("Sans")
font.setPointSize(20) font.setPointSize(20)
self.current_track_2.setFont(font) self.current_track_2.setFont(font)
self.current_track_2.setStyleSheet("background-color: #d4edda;\n" self.current_track_2.setStyleSheet(
"border: 1px solid rgb(85, 87, 83);") "background-color: #d4edda;\n" "border: 1px solid rgb(85, 87, 83);"
self.current_track_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) )
self.current_track_2.setAlignment(
QtCore.Qt.AlignmentFlag.AlignRight
| QtCore.Qt.AlignmentFlag.AlignTrailing
| QtCore.Qt.AlignmentFlag.AlignVCenter
)
self.current_track_2.setObjectName("current_track_2") self.current_track_2.setObjectName("current_track_2")
self.verticalLayout_3.addWidget(self.current_track_2) self.verticalLayout_3.addWidget(self.current_track_2)
self.next_track_2 = QtWidgets.QLabel(parent=self.centralwidget) self.next_track_2 = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred) sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0) sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.next_track_2.sizePolicy().hasHeightForWidth()) sizePolicy.setHeightForWidth(self.next_track_2.sizePolicy().hasHeightForWidth())
@ -69,19 +96,29 @@ class Ui_MainWindow(object):
font.setFamily("Sans") font.setFamily("Sans")
font.setPointSize(20) font.setPointSize(20)
self.next_track_2.setFont(font) self.next_track_2.setFont(font)
self.next_track_2.setStyleSheet("background-color: #fff3cd;\n" self.next_track_2.setStyleSheet(
"border: 1px solid rgb(85, 87, 83);") "background-color: #fff3cd;\n" "border: 1px solid rgb(85, 87, 83);"
self.next_track_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) )
self.next_track_2.setAlignment(
QtCore.Qt.AlignmentFlag.AlignRight
| QtCore.Qt.AlignmentFlag.AlignTrailing
| QtCore.Qt.AlignmentFlag.AlignVCenter
)
self.next_track_2.setObjectName("next_track_2") self.next_track_2.setObjectName("next_track_2")
self.verticalLayout_3.addWidget(self.next_track_2) self.verticalLayout_3.addWidget(self.next_track_2)
self.horizontalLayout_3.addLayout(self.verticalLayout_3) self.horizontalLayout_3.addLayout(self.verticalLayout_3)
self.verticalLayout = QtWidgets.QVBoxLayout() self.verticalLayout = QtWidgets.QVBoxLayout()
self.verticalLayout.setObjectName("verticalLayout") self.verticalLayout.setObjectName("verticalLayout")
self.hdrPreviousTrack = QtWidgets.QLabel(parent=self.centralwidget) self.hdrPreviousTrack = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred) sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0) sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.hdrPreviousTrack.sizePolicy().hasHeightForWidth()) sizePolicy.setHeightForWidth(
self.hdrPreviousTrack.sizePolicy().hasHeightForWidth()
)
self.hdrPreviousTrack.setSizePolicy(sizePolicy) self.hdrPreviousTrack.setSizePolicy(sizePolicy)
self.hdrPreviousTrack.setMinimumSize(QtCore.QSize(0, 0)) self.hdrPreviousTrack.setMinimumSize(QtCore.QSize(0, 0))
self.hdrPreviousTrack.setMaximumSize(QtCore.QSize(16777215, 16777215)) self.hdrPreviousTrack.setMaximumSize(QtCore.QSize(16777215, 16777215))
@ -89,32 +126,43 @@ class Ui_MainWindow(object):
font.setFamily("Sans") font.setFamily("Sans")
font.setPointSize(20) font.setPointSize(20)
self.hdrPreviousTrack.setFont(font) self.hdrPreviousTrack.setFont(font)
self.hdrPreviousTrack.setStyleSheet("background-color: #f8d7da;\n" self.hdrPreviousTrack.setStyleSheet(
"border: 1px solid rgb(85, 87, 83);") "background-color: #f8d7da;\n" "border: 1px solid rgb(85, 87, 83);"
)
self.hdrPreviousTrack.setText("") self.hdrPreviousTrack.setText("")
self.hdrPreviousTrack.setWordWrap(False) self.hdrPreviousTrack.setWordWrap(False)
self.hdrPreviousTrack.setObjectName("hdrPreviousTrack") self.hdrPreviousTrack.setObjectName("hdrPreviousTrack")
self.verticalLayout.addWidget(self.hdrPreviousTrack) self.verticalLayout.addWidget(self.hdrPreviousTrack)
self.hdrCurrentTrack = QtWidgets.QPushButton(parent=self.centralwidget) self.hdrCurrentTrack = QtWidgets.QPushButton(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred) sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0) sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.hdrCurrentTrack.sizePolicy().hasHeightForWidth()) sizePolicy.setHeightForWidth(
self.hdrCurrentTrack.sizePolicy().hasHeightForWidth()
)
self.hdrCurrentTrack.setSizePolicy(sizePolicy) self.hdrCurrentTrack.setSizePolicy(sizePolicy)
font = QtGui.QFont() font = QtGui.QFont()
font.setPointSize(20) font.setPointSize(20)
self.hdrCurrentTrack.setFont(font) self.hdrCurrentTrack.setFont(font)
self.hdrCurrentTrack.setStyleSheet("background-color: #d4edda;\n" self.hdrCurrentTrack.setStyleSheet(
"background-color: #d4edda;\n"
"border: 1px solid rgb(85, 87, 83);\n" "border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;\n" "text-align: left;\n"
"padding-left: 8px;\n" "padding-left: 8px;\n"
"") ""
)
self.hdrCurrentTrack.setText("") self.hdrCurrentTrack.setText("")
self.hdrCurrentTrack.setFlat(True) self.hdrCurrentTrack.setFlat(True)
self.hdrCurrentTrack.setObjectName("hdrCurrentTrack") self.hdrCurrentTrack.setObjectName("hdrCurrentTrack")
self.verticalLayout.addWidget(self.hdrCurrentTrack) self.verticalLayout.addWidget(self.hdrCurrentTrack)
self.hdrNextTrack = QtWidgets.QPushButton(parent=self.centralwidget) self.hdrNextTrack = QtWidgets.QPushButton(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred) sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0) sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.hdrNextTrack.sizePolicy().hasHeightForWidth()) sizePolicy.setHeightForWidth(self.hdrNextTrack.sizePolicy().hasHeightForWidth())
@ -122,10 +170,12 @@ class Ui_MainWindow(object):
font = QtGui.QFont() font = QtGui.QFont()
font.setPointSize(20) font.setPointSize(20)
self.hdrNextTrack.setFont(font) self.hdrNextTrack.setFont(font)
self.hdrNextTrack.setStyleSheet("background-color: #fff3cd;\n" self.hdrNextTrack.setStyleSheet(
"background-color: #fff3cd;\n"
"border: 1px solid rgb(85, 87, 83);\n" "border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;\n" "text-align: left;\n"
"padding-left: 8px;") "padding-left: 8px;"
)
self.hdrNextTrack.setText("") self.hdrNextTrack.setText("")
self.hdrNextTrack.setFlat(True) self.hdrNextTrack.setFlat(True)
self.hdrNextTrack.setObjectName("hdrNextTrack") self.hdrNextTrack.setObjectName("hdrNextTrack")
@ -160,7 +210,12 @@ class Ui_MainWindow(object):
self.cartsWidget.setObjectName("cartsWidget") self.cartsWidget.setObjectName("cartsWidget")
self.horizontalLayout_Carts = QtWidgets.QHBoxLayout(self.cartsWidget) self.horizontalLayout_Carts = QtWidgets.QHBoxLayout(self.cartsWidget)
self.horizontalLayout_Carts.setObjectName("horizontalLayout_Carts") self.horizontalLayout_Carts.setObjectName("horizontalLayout_Carts")
spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) spacerItem = QtWidgets.QSpacerItem(
40,
20,
QtWidgets.QSizePolicy.Policy.Expanding,
QtWidgets.QSizePolicy.Policy.Minimum,
)
self.horizontalLayout_Carts.addItem(spacerItem) self.horizontalLayout_Carts.addItem(spacerItem)
self.gridLayout_4.addWidget(self.cartsWidget, 2, 0, 1, 1) self.gridLayout_4.addWidget(self.cartsWidget, 2, 0, 1, 1)
self.frame_6 = QtWidgets.QFrame(parent=self.centralwidget) self.frame_6 = QtWidgets.QFrame(parent=self.centralwidget)
@ -205,7 +260,11 @@ class Ui_MainWindow(object):
self.btnPreview = QtWidgets.QPushButton(parent=self.FadeStopInfoFrame) self.btnPreview = QtWidgets.QPushButton(parent=self.FadeStopInfoFrame)
self.btnPreview.setMinimumSize(QtCore.QSize(132, 41)) self.btnPreview.setMinimumSize(QtCore.QSize(132, 41))
icon1 = QtGui.QIcon() icon1 = QtGui.QIcon()
icon1.addPixmap(QtGui.QPixmap(":/icons/headphones"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off) icon1.addPixmap(
QtGui.QPixmap(":/icons/headphones"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnPreview.setIcon(icon1) self.btnPreview.setIcon(icon1)
self.btnPreview.setIconSize(QtCore.QSize(30, 30)) self.btnPreview.setIconSize(QtCore.QSize(30, 30))
self.btnPreview.setCheckable(True) self.btnPreview.setCheckable(True)
@ -289,10 +348,15 @@ class Ui_MainWindow(object):
self.label_silent_timer.setObjectName("label_silent_timer") self.label_silent_timer.setObjectName("label_silent_timer")
self.horizontalLayout.addWidget(self.frame_silent) self.horizontalLayout.addWidget(self.frame_silent)
self.widgetFadeVolume = PlotWidget(parent=self.InfoFooterFrame) self.widgetFadeVolume = PlotWidget(parent=self.InfoFooterFrame)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred) sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(1) sizePolicy.setHorizontalStretch(1)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.widgetFadeVolume.sizePolicy().hasHeightForWidth()) sizePolicy.setHeightForWidth(
self.widgetFadeVolume.sizePolicy().hasHeightForWidth()
)
self.widgetFadeVolume.setSizePolicy(sizePolicy) self.widgetFadeVolume.setSizePolicy(sizePolicy)
self.widgetFadeVolume.setMinimumSize(QtCore.QSize(0, 0)) self.widgetFadeVolume.setMinimumSize(QtCore.QSize(0, 0))
self.widgetFadeVolume.setObjectName("widgetFadeVolume") self.widgetFadeVolume.setObjectName("widgetFadeVolume")
@ -309,7 +373,11 @@ class Ui_MainWindow(object):
self.btnFade.setMinimumSize(QtCore.QSize(132, 32)) self.btnFade.setMinimumSize(QtCore.QSize(132, 32))
self.btnFade.setMaximumSize(QtCore.QSize(164, 16777215)) self.btnFade.setMaximumSize(QtCore.QSize(164, 16777215))
icon2 = QtGui.QIcon() icon2 = QtGui.QIcon()
icon2.addPixmap(QtGui.QPixmap(":/icons/fade"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off) icon2.addPixmap(
QtGui.QPixmap(":/icons/fade"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnFade.setIcon(icon2) self.btnFade.setIcon(icon2)
self.btnFade.setIconSize(QtCore.QSize(30, 30)) self.btnFade.setIconSize(QtCore.QSize(30, 30))
self.btnFade.setObjectName("btnFade") self.btnFade.setObjectName("btnFade")
@ -317,7 +385,11 @@ class Ui_MainWindow(object):
self.btnStop = QtWidgets.QPushButton(parent=self.frame) self.btnStop = QtWidgets.QPushButton(parent=self.frame)
self.btnStop.setMinimumSize(QtCore.QSize(0, 36)) self.btnStop.setMinimumSize(QtCore.QSize(0, 36))
icon3 = QtGui.QIcon() icon3 = QtGui.QIcon()
icon3.addPixmap(QtGui.QPixmap(":/icons/stopsign"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off) icon3.addPixmap(
QtGui.QPixmap(":/icons/stopsign"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnStop.setIcon(icon3) self.btnStop.setIcon(icon3)
self.btnStop.setObjectName("btnStop") self.btnStop.setObjectName("btnStop")
self.verticalLayout_5.addWidget(self.btnStop) self.verticalLayout_5.addWidget(self.btnStop)
@ -343,39 +415,69 @@ class Ui_MainWindow(object):
MainWindow.setStatusBar(self.statusbar) MainWindow.setStatusBar(self.statusbar)
self.actionPlay_next = QtGui.QAction(parent=MainWindow) self.actionPlay_next = QtGui.QAction(parent=MainWindow)
icon4 = QtGui.QIcon() icon4 = QtGui.QIcon()
icon4.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon-play.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off) icon4.addPixmap(
QtGui.QPixmap("app/ui/../../../../.designer/backup/icon-play.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionPlay_next.setIcon(icon4) self.actionPlay_next.setIcon(icon4)
self.actionPlay_next.setObjectName("actionPlay_next") self.actionPlay_next.setObjectName("actionPlay_next")
self.actionSkipToNext = QtGui.QAction(parent=MainWindow) self.actionSkipToNext = QtGui.QAction(parent=MainWindow)
icon5 = QtGui.QIcon() icon5 = QtGui.QIcon()
icon5.addPixmap(QtGui.QPixmap(":/icons/next"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off) icon5.addPixmap(
QtGui.QPixmap(":/icons/next"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionSkipToNext.setIcon(icon5) self.actionSkipToNext.setIcon(icon5)
self.actionSkipToNext.setObjectName("actionSkipToNext") self.actionSkipToNext.setObjectName("actionSkipToNext")
self.actionInsertTrack = QtGui.QAction(parent=MainWindow) self.actionInsertTrack = QtGui.QAction(parent=MainWindow)
icon6 = QtGui.QIcon() icon6 = QtGui.QIcon()
icon6.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon_search_database.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off) icon6.addPixmap(
QtGui.QPixmap(
"app/ui/../../../../.designer/backup/icon_search_database.png"
),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionInsertTrack.setIcon(icon6) self.actionInsertTrack.setIcon(icon6)
self.actionInsertTrack.setObjectName("actionInsertTrack") self.actionInsertTrack.setObjectName("actionInsertTrack")
self.actionAdd_file = QtGui.QAction(parent=MainWindow) self.actionAdd_file = QtGui.QAction(parent=MainWindow)
icon7 = QtGui.QIcon() icon7 = QtGui.QIcon()
icon7.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon_open_file.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off) icon7.addPixmap(
QtGui.QPixmap("app/ui/../../../../.designer/backup/icon_open_file.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionAdd_file.setIcon(icon7) self.actionAdd_file.setIcon(icon7)
self.actionAdd_file.setObjectName("actionAdd_file") self.actionAdd_file.setObjectName("actionAdd_file")
self.actionFade = QtGui.QAction(parent=MainWindow) self.actionFade = QtGui.QAction(parent=MainWindow)
icon8 = QtGui.QIcon() icon8 = QtGui.QIcon()
icon8.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon-fade.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off) icon8.addPixmap(
QtGui.QPixmap("app/ui/../../../../.designer/backup/icon-fade.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionFade.setIcon(icon8) self.actionFade.setIcon(icon8)
self.actionFade.setObjectName("actionFade") self.actionFade.setObjectName("actionFade")
self.actionStop = QtGui.QAction(parent=MainWindow) self.actionStop = QtGui.QAction(parent=MainWindow)
icon9 = QtGui.QIcon() icon9 = QtGui.QIcon()
icon9.addPixmap(QtGui.QPixmap(":/icons/stop"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off) icon9.addPixmap(
QtGui.QPixmap(":/icons/stop"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionStop.setIcon(icon9) self.actionStop.setIcon(icon9)
self.actionStop.setObjectName("actionStop") self.actionStop.setObjectName("actionStop")
self.action_Clear_selection = QtGui.QAction(parent=MainWindow) self.action_Clear_selection = QtGui.QAction(parent=MainWindow)
self.action_Clear_selection.setObjectName("action_Clear_selection") self.action_Clear_selection.setObjectName("action_Clear_selection")
self.action_Resume_previous = QtGui.QAction(parent=MainWindow) self.action_Resume_previous = QtGui.QAction(parent=MainWindow)
icon10 = QtGui.QIcon() icon10 = QtGui.QIcon()
icon10.addPixmap(QtGui.QPixmap(":/icons/previous"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off) icon10.addPixmap(
QtGui.QPixmap(":/icons/previous"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.action_Resume_previous.setIcon(icon10) self.action_Resume_previous.setIcon(icon10)
self.action_Resume_previous.setObjectName("action_Resume_previous") self.action_Resume_previous.setObjectName("action_Resume_previous")
self.actionE_xit = QtGui.QAction(parent=MainWindow) self.actionE_xit = QtGui.QAction(parent=MainWindow)
@ -422,7 +524,9 @@ class Ui_MainWindow(object):
self.actionImport = QtGui.QAction(parent=MainWindow) self.actionImport = QtGui.QAction(parent=MainWindow)
self.actionImport.setObjectName("actionImport") self.actionImport.setObjectName("actionImport")
self.actionDownload_CSV_of_played_tracks = QtGui.QAction(parent=MainWindow) self.actionDownload_CSV_of_played_tracks = QtGui.QAction(parent=MainWindow)
self.actionDownload_CSV_of_played_tracks.setObjectName("actionDownload_CSV_of_played_tracks") self.actionDownload_CSV_of_played_tracks.setObjectName(
"actionDownload_CSV_of_played_tracks"
)
self.actionSearch = QtGui.QAction(parent=MainWindow) self.actionSearch = QtGui.QAction(parent=MainWindow)
self.actionSearch.setObjectName("actionSearch") self.actionSearch.setObjectName("actionSearch")
self.actionInsertSectionHeader = QtGui.QAction(parent=MainWindow) self.actionInsertSectionHeader = QtGui.QAction(parent=MainWindow)
@ -450,9 +554,13 @@ class Ui_MainWindow(object):
self.actionResume = QtGui.QAction(parent=MainWindow) self.actionResume = QtGui.QAction(parent=MainWindow)
self.actionResume.setObjectName("actionResume") self.actionResume.setObjectName("actionResume")
self.actionSearch_title_in_Wikipedia = QtGui.QAction(parent=MainWindow) self.actionSearch_title_in_Wikipedia = QtGui.QAction(parent=MainWindow)
self.actionSearch_title_in_Wikipedia.setObjectName("actionSearch_title_in_Wikipedia") self.actionSearch_title_in_Wikipedia.setObjectName(
"actionSearch_title_in_Wikipedia"
)
self.actionSearch_title_in_Songfacts = QtGui.QAction(parent=MainWindow) self.actionSearch_title_in_Songfacts = QtGui.QAction(parent=MainWindow)
self.actionSearch_title_in_Songfacts.setObjectName("actionSearch_title_in_Songfacts") self.actionSearch_title_in_Songfacts.setObjectName(
"actionSearch_title_in_Songfacts"
)
self.actionSelect_duplicate_rows = QtGui.QAction(parent=MainWindow) self.actionSelect_duplicate_rows = QtGui.QAction(parent=MainWindow)
self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows") self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows")
self.menuFile.addAction(self.actionNewPlaylist) self.menuFile.addAction(self.actionNewPlaylist)
@ -539,38 +647,58 @@ class Ui_MainWindow(object):
self.actionFade.setShortcut(_translate("MainWindow", "Ctrl+Z")) self.actionFade.setShortcut(_translate("MainWindow", "Ctrl+Z"))
self.actionStop.setText(_translate("MainWindow", "S&top")) self.actionStop.setText(_translate("MainWindow", "S&top"))
self.actionStop.setShortcut(_translate("MainWindow", "Ctrl+Alt+S")) self.actionStop.setShortcut(_translate("MainWindow", "Ctrl+Alt+S"))
self.action_Clear_selection.setText(_translate("MainWindow", "Clear &selection")) self.action_Clear_selection.setText(
_translate("MainWindow", "Clear &selection")
)
self.action_Clear_selection.setShortcut(_translate("MainWindow", "Esc")) self.action_Clear_selection.setShortcut(_translate("MainWindow", "Esc"))
self.action_Resume_previous.setText(_translate("MainWindow", "&Resume previous")) self.action_Resume_previous.setText(
_translate("MainWindow", "&Resume previous")
)
self.actionE_xit.setText(_translate("MainWindow", "E&xit")) self.actionE_xit.setText(_translate("MainWindow", "E&xit"))
self.actionTest.setText(_translate("MainWindow", "&Test")) self.actionTest.setText(_translate("MainWindow", "&Test"))
self.actionOpenPlaylist.setText(_translate("MainWindow", "O&pen...")) self.actionOpenPlaylist.setText(_translate("MainWindow", "O&pen..."))
self.actionNewPlaylist.setText(_translate("MainWindow", "&New...")) self.actionNewPlaylist.setText(_translate("MainWindow", "&New..."))
self.actionTestFunction.setText(_translate("MainWindow", "&Test function")) self.actionTestFunction.setText(_translate("MainWindow", "&Test function"))
self.actionSkipToFade.setText(_translate("MainWindow", "&Skip to start of fade")) self.actionSkipToFade.setText(
_translate("MainWindow", "&Skip to start of fade")
)
self.actionSkipToEnd.setText(_translate("MainWindow", "Skip to &end of track")) self.actionSkipToEnd.setText(_translate("MainWindow", "Skip to &end of track"))
self.actionClosePlaylist.setText(_translate("MainWindow", "&Close")) self.actionClosePlaylist.setText(_translate("MainWindow", "&Close"))
self.actionRenamePlaylist.setText(_translate("MainWindow", "&Rename...")) self.actionRenamePlaylist.setText(_translate("MainWindow", "&Rename..."))
self.actionDeletePlaylist.setText(_translate("MainWindow", "Dele&te...")) self.actionDeletePlaylist.setText(_translate("MainWindow", "Dele&te..."))
self.actionMoveSelected.setText(_translate("MainWindow", "Mo&ve selected tracks to...")) self.actionMoveSelected.setText(
_translate("MainWindow", "Mo&ve selected tracks to...")
)
self.actionExport_playlist.setText(_translate("MainWindow", "E&xport...")) self.actionExport_playlist.setText(_translate("MainWindow", "E&xport..."))
self.actionSetNext.setText(_translate("MainWindow", "Set &next")) self.actionSetNext.setText(_translate("MainWindow", "Set &next"))
self.actionSetNext.setShortcut(_translate("MainWindow", "Ctrl+N")) self.actionSetNext.setShortcut(_translate("MainWindow", "Ctrl+N"))
self.actionSelect_next_track.setText(_translate("MainWindow", "Select next track")) self.actionSelect_next_track.setText(
_translate("MainWindow", "Select next track")
)
self.actionSelect_next_track.setShortcut(_translate("MainWindow", "J")) self.actionSelect_next_track.setShortcut(_translate("MainWindow", "J"))
self.actionSelect_previous_track.setText(_translate("MainWindow", "Select previous track")) self.actionSelect_previous_track.setText(
_translate("MainWindow", "Select previous track")
)
self.actionSelect_previous_track.setShortcut(_translate("MainWindow", "K")) self.actionSelect_previous_track.setShortcut(_translate("MainWindow", "K"))
self.actionSelect_played_tracks.setText(_translate("MainWindow", "Select played tracks")) self.actionSelect_played_tracks.setText(
self.actionMoveUnplayed.setText(_translate("MainWindow", "Move &unplayed tracks to...")) _translate("MainWindow", "Select played tracks")
)
self.actionMoveUnplayed.setText(
_translate("MainWindow", "Move &unplayed tracks to...")
)
self.actionAdd_note.setText(_translate("MainWindow", "Add note...")) self.actionAdd_note.setText(_translate("MainWindow", "Add note..."))
self.actionAdd_note.setShortcut(_translate("MainWindow", "Ctrl+T")) self.actionAdd_note.setShortcut(_translate("MainWindow", "Ctrl+T"))
self.actionEnable_controls.setText(_translate("MainWindow", "Enable controls")) self.actionEnable_controls.setText(_translate("MainWindow", "Enable controls"))
self.actionImport.setText(_translate("MainWindow", "Import track...")) self.actionImport.setText(_translate("MainWindow", "Import track..."))
self.actionImport.setShortcut(_translate("MainWindow", "Ctrl+Shift+I")) self.actionImport.setShortcut(_translate("MainWindow", "Ctrl+Shift+I"))
self.actionDownload_CSV_of_played_tracks.setText(_translate("MainWindow", "Download CSV of played tracks...")) self.actionDownload_CSV_of_played_tracks.setText(
_translate("MainWindow", "Download CSV of played tracks...")
)
self.actionSearch.setText(_translate("MainWindow", "Search...")) self.actionSearch.setText(_translate("MainWindow", "Search..."))
self.actionSearch.setShortcut(_translate("MainWindow", "/")) self.actionSearch.setShortcut(_translate("MainWindow", "/"))
self.actionInsertSectionHeader.setText(_translate("MainWindow", "Insert &section header...")) self.actionInsertSectionHeader.setText(
_translate("MainWindow", "Insert &section header...")
)
self.actionInsertSectionHeader.setShortcut(_translate("MainWindow", "Ctrl+H")) self.actionInsertSectionHeader.setShortcut(_translate("MainWindow", "Ctrl+H"))
self.actionRemove.setText(_translate("MainWindow", "&Remove track")) self.actionRemove.setText(_translate("MainWindow", "&Remove track"))
self.actionFind_next.setText(_translate("MainWindow", "Find next")) self.actionFind_next.setText(_translate("MainWindow", "Find next"))
@ -578,8 +706,12 @@ class Ui_MainWindow(object):
self.actionFind_previous.setText(_translate("MainWindow", "Find previous")) self.actionFind_previous.setText(_translate("MainWindow", "Find previous"))
self.actionFind_previous.setShortcut(_translate("MainWindow", "P")) self.actionFind_previous.setShortcut(_translate("MainWindow", "P"))
self.action_About.setText(_translate("MainWindow", "&About")) self.action_About.setText(_translate("MainWindow", "&About"))
self.actionSave_as_template.setText(_translate("MainWindow", "Save as template...")) self.actionSave_as_template.setText(
self.actionNew_from_template.setText(_translate("MainWindow", "New from template...")) _translate("MainWindow", "Save as template...")
)
self.actionNew_from_template.setText(
_translate("MainWindow", "New from template...")
)
self.actionDebug.setText(_translate("MainWindow", "Debug")) self.actionDebug.setText(_translate("MainWindow", "Debug"))
self.actionAdd_cart.setText(_translate("MainWindow", "Edit cart &1...")) self.actionAdd_cart.setText(_translate("MainWindow", "Edit cart &1..."))
self.actionMark_for_moving.setText(_translate("MainWindow", "Mark for moving")) self.actionMark_for_moving.setText(_translate("MainWindow", "Mark for moving"))
@ -588,10 +720,22 @@ class Ui_MainWindow(object):
self.actionPaste.setShortcut(_translate("MainWindow", "Ctrl+V")) self.actionPaste.setShortcut(_translate("MainWindow", "Ctrl+V"))
self.actionResume.setText(_translate("MainWindow", "Resume")) self.actionResume.setText(_translate("MainWindow", "Resume"))
self.actionResume.setShortcut(_translate("MainWindow", "Ctrl+R")) self.actionResume.setShortcut(_translate("MainWindow", "Ctrl+R"))
self.actionSearch_title_in_Wikipedia.setText(_translate("MainWindow", "Search title in Wikipedia")) self.actionSearch_title_in_Wikipedia.setText(
self.actionSearch_title_in_Wikipedia.setShortcut(_translate("MainWindow", "Ctrl+W")) _translate("MainWindow", "Search title in Wikipedia")
self.actionSearch_title_in_Songfacts.setText(_translate("MainWindow", "Search title in Songfacts")) )
self.actionSearch_title_in_Songfacts.setShortcut(_translate("MainWindow", "Ctrl+S")) self.actionSearch_title_in_Wikipedia.setShortcut(
self.actionSelect_duplicate_rows.setText(_translate("MainWindow", "Select duplicate rows...")) _translate("MainWindow", "Ctrl+W")
)
self.actionSearch_title_in_Songfacts.setText(
_translate("MainWindow", "Search title in Songfacts")
)
self.actionSearch_title_in_Songfacts.setShortcut(
_translate("MainWindow", "Ctrl+S")
)
self.actionSelect_duplicate_rows.setText(
_translate("MainWindow", "Select duplicate rows...")
)
from infotabs import InfoTabs from infotabs import InfoTabs
from pyqtgraph import PlotWidget from pyqtgraph import PlotWidget

View File

@ -1,11 +1,11 @@
#!/usr/bin/python3 #!/usr/bin/python3
from datetime import datetime, timedelta import datetime as dt
from threading import Timer from threading import Timer
from pydub import AudioSegment from pydub import AudioSegment
from time import sleep from time import sleep
from timeloop import Timeloop from timeloop import Timeloop # type: ignore
import vlc import vlc # type: ignore
class RepeatedTimer(object): class RepeatedTimer(object):
@ -49,9 +49,9 @@ def leading_silence(audio_segment, silence_threshold=-50.0, chunk_size=10):
trim_ms = 0 # ms trim_ms = 0 # ms
assert chunk_size > 0 # to avoid infinite loop assert chunk_size > 0 # to avoid infinite loop
while ( while audio_segment[
audio_segment[trim_ms:trim_ms + chunk_size].dBFS < silence_threshold trim_ms : trim_ms + chunk_size
and trim_ms < len(audio_segment)): ].dBFS < silence_threshold and trim_ms < len(audio_segment):
trim_ms += chunk_size trim_ms += chunk_size
# if there is no end it should return the length of the segment # if there is no end it should return the length of the segment
@ -73,7 +73,8 @@ def significant_fade(audio_segment, fade_threshold=-20.0, chunk_size=10):
trim_ms = segment_length - chunk_size trim_ms = segment_length - chunk_size
while ( while (
audio_segment[trim_ms : trim_ms + chunk_size].dBFS < fade_threshold audio_segment[trim_ms : trim_ms + chunk_size].dBFS < fade_threshold
and trim_ms > 0): and trim_ms > 0
):
trim_ms -= chunk_size trim_ms -= chunk_size
# if there is no trailing silence, return lenght of track (it's less # if there is no trailing silence, return lenght of track (it's less
@ -95,7 +96,8 @@ def trailing_silence(audio_segment, silence_threshold=-50.0, chunk_size=10):
trim_ms = segment_length - chunk_size trim_ms = segment_length - chunk_size
while ( while (
audio_segment[trim_ms : trim_ms + chunk_size].dBFS < silence_threshold audio_segment[trim_ms : trim_ms + chunk_size].dBFS < silence_threshold
and trim_ms > 0): and trim_ms > 0
):
trim_ms -= chunk_size trim_ms -= chunk_size
# if there is no trailing silence, return lenght of track (it's less # if there is no trailing silence, return lenght of track (it's less
@ -124,15 +126,17 @@ def update_progress(player, talk_at, silent_at):
remaining_time = total_time - elapsed_time remaining_time = total_time - elapsed_time
talk_time = remaining_time - (total_time - talk_at) talk_time = remaining_time - (total_time - talk_at)
silent_time = remaining_time - (total_time - silent_at) silent_time = remaining_time - (total_time - silent_at)
end_time = (datetime.now() + timedelta( end_time = (dt.datetime.now() + timedelta(milliseconds=remaining_time)).strftime(
milliseconds=remaining_time)).strftime("%H:%M:%S") "%H:%M:%S"
)
print( print(
f"\t{ms_to_mmss(elapsed_time)}/" f"\t{ms_to_mmss(elapsed_time)}/"
f"{ms_to_mmss(total_time)}\t\t" f"{ms_to_mmss(total_time)}\t\t"
f"Talk in: {ms_to_mmss(talk_time)} " f"Talk in: {ms_to_mmss(talk_time)} "
f"Silent in: {ms_to_mmss(silent_time)} " f"Silent in: {ms_to_mmss(silent_time)} "
f"Ends at: {end_time} [{ms_to_mmss(remaining_time)}]" f"Ends at: {end_time} [{ms_to_mmss(remaining_time)}]",
, end="\r") end="\r",
)
# Print name of current song, print name of next song. Play current when # Print name of current song, print name of next song. Play current when

View File

@ -1,5 +1,3 @@
# tl = Timeloop() # tl = Timeloop()
# #
# #

View File

@ -7,7 +7,7 @@ from PyQt5.QtCore import Qt
qt_creator_file = "mainwindow.ui" qt_creator_file = "mainwindow.ui"
Ui_MainWindow, QtBaseClass = uic.loadUiType(qt_creator_file) Ui_MainWindow, QtBaseClass = uic.loadUiType(qt_creator_file)
tick = QtGui.QImage('tick.png') tick = QtGui.QImage("tick.png")
class TodoModel(QtCore.QAbstractListModel): class TodoModel(QtCore.QAbstractListModel):
@ -56,7 +56,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
self.model.todos.append((False, text)) self.model.todos.append((False, text))
# Trigger refresh. # Trigger refresh.
self.model.layoutChanged.emit() self.model.layoutChanged.emit()
# Empty the input # Empty the input
self.todoEdit.setText("") self.todoEdit.setText("")
self.save() self.save()
@ -88,13 +88,13 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
def load(self): def load(self):
try: try:
with open('data.db', 'r') as f: with open("data.db", "r") as f:
self.model.todos = json.load(f) self.model.todos = json.load(f)
except Exception: except Exception:
pass pass
def save(self): def save(self):
with open('data.db', 'w') as f: with open("data.db", "w") as f:
data = json.dump(self.model.todos, f) data = json.dump(self.model.todos, f)
@ -102,5 +102,3 @@ app = QtWidgets.QApplication(sys.argv)
window = MainWindow() window = MainWindow()
window.show() window.show()
app.exec_() app.exec_()

View File

@ -1,49 +0,0 @@
# https://itnext.io/setting-up-transactional-tests-with-pytest-and-sqlalchemy-b2d726347629
import pytest
import helpers
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
from app.models import Base, Tracks
DB_CONNECTION = "mysql+mysqldb://musicmuster_testing:musicmuster_testing@localhost/dev_musicmuster_testing"
@pytest.fixture(scope="session")
def db_engine():
engine = create_engine(DB_CONNECTION, isolation_level="READ COMMITTED")
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture(scope="function")
def session(db_engine):
connection = db_engine.connect()
transaction = connection.begin()
sm = sessionmaker(bind=connection)
session = scoped_session(sm)
# print(f"PyTest SqlA: session acquired [{hex(id(session))}]")
yield session
# print(f" PyTest SqlA: session released and cleaned up [{hex(id(session))}]")
session.remove()
transaction.rollback()
connection.close()
@pytest.fixture(scope="function")
def track1(session):
track_path = "testdata/isa.mp3"
metadata = helpers.get_file_metadata(track_path)
track = Tracks(session, **metadata)
return track
@pytest.fixture(scope="function")
def track2(session):
track_path = "testdata/mom.mp3"
metadata = helpers.get_file_metadata(track_path)
track = Tracks(session, **metadata)
return track

View File

@ -10,23 +10,23 @@ import sqlalchemy as sa
from sqlalchemy.dialects import mysql from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '0c604bf490f8' revision = "0c604bf490f8"
down_revision = '29c0d7ffc741' down_revision = "29c0d7ffc741"
branch_labels = None branch_labels = None
depends_on = None depends_on = None
def upgrade(): def upgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlist_rows', sa.Column('played', sa.Boolean(), nullable=False)) op.add_column("playlist_rows", sa.Column("played", sa.Boolean(), nullable=False))
op.drop_index('ix_tracks_lastplayed', table_name='tracks') op.drop_index("ix_tracks_lastplayed", table_name="tracks")
op.drop_column('tracks', 'lastplayed') op.drop_column("tracks", "lastplayed")
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade(): def downgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.add_column('tracks', sa.Column('lastplayed', mysql.DATETIME(), nullable=True)) op.add_column("tracks", sa.Column("lastplayed", mysql.DATETIME(), nullable=True))
op.create_index('ix_tracks_lastplayed', 'tracks', ['lastplayed'], unique=False) op.create_index("ix_tracks_lastplayed", "tracks", ["lastplayed"], unique=False)
op.drop_column('playlist_rows', 'played') op.drop_column("playlist_rows", "played")
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@ -10,21 +10,21 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '2cc37d3cf07f' revision = "2cc37d3cf07f"
down_revision = 'e3b04db5506f' down_revision = "e3b04db5506f"
branch_labels = None branch_labels = None
depends_on = None depends_on = None
def upgrade(): def upgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlists', sa.Column('last_used', sa.DateTime(), nullable=True)) op.add_column("playlists", sa.Column("last_used", sa.DateTime(), nullable=True))
op.add_column('playlists', sa.Column('loaded', sa.Boolean(), nullable=True)) op.add_column("playlists", sa.Column("loaded", sa.Boolean(), nullable=True))
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade(): def downgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_column('playlists', 'loaded') op.drop_column("playlists", "loaded")
op.drop_column('playlists', 'last_used') op.drop_column("playlists", "last_used")
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@ -10,27 +10,28 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = 'b0983648595e' revision = "b0983648595e"
down_revision = '1bc727e5e87f' down_revision = "1bc727e5e87f"
branch_labels = None branch_labels = None
depends_on = None depends_on = None
def upgrade(): def upgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('settings', op.create_table(
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), "settings",
sa.Column('name', sa.String(length=32), nullable=False), sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column('f_datetime', sa.DateTime(), nullable=True), sa.Column("name", sa.String(length=32), nullable=False),
sa.Column('f_int', sa.Integer(), nullable=True), sa.Column("f_datetime", sa.DateTime(), nullable=True),
sa.Column('f_string', sa.String(length=128), nullable=True), sa.Column("f_int", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint('id'), sa.Column("f_string", sa.String(length=128), nullable=True),
sa.UniqueConstraint('name') sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
) )
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade(): def downgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_table('settings') op.drop_table("settings")
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@ -10,43 +10,54 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = 'f07b96a5e60f' revision = "f07b96a5e60f"
down_revision = 'b0983648595e' down_revision = "b0983648595e"
branch_labels = None branch_labels = None
depends_on = None depends_on = None
def upgrade(): def upgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('playdates', op.create_table(
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), "playdates",
sa.Column('lastplayed', sa.DateTime(), nullable=True), sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.PrimaryKeyConstraint('id') sa.Column("lastplayed", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
) )
op.create_index(op.f('ix_playdates_lastplayed'), 'playdates', ['lastplayed'], unique=False) op.create_index(
op.create_table('playlists', op.f("ix_playdates_lastplayed"), "playdates", ["lastplayed"], unique=False
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=32), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
) )
op.create_table('playlistracks', op.create_table(
sa.Column('playlist_id', sa.Integer(), nullable=True), "playlists",
sa.Column('track_id', sa.Integer(), nullable=True), sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.ForeignKeyConstraint(['playlist_id'], ['playlists.id'], ), sa.Column("name", sa.String(length=32), nullable=False),
sa.ForeignKeyConstraint(['track_id'], ['tracks.id'], ) sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
) )
op.add_column('tracks', sa.Column('playdates_id', sa.Integer(), nullable=True)) op.create_table(
op.create_foreign_key(None, 'tracks', 'playdates', ['playdates_id'], ['id']) "playlistracks",
sa.Column("playlist_id", sa.Integer(), nullable=True),
sa.Column("track_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["playlist_id"],
["playlists.id"],
),
sa.ForeignKeyConstraint(
["track_id"],
["tracks.id"],
),
)
op.add_column("tracks", sa.Column("playdates_id", sa.Integer(), nullable=True))
op.create_foreign_key(None, "tracks", "playdates", ["playdates_id"], ["id"])
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade(): def downgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'tracks', type_='foreignkey') op.drop_constraint(None, "tracks", type_="foreignkey")
op.drop_column('tracks', 'playdates_id') op.drop_column("tracks", "playdates_id")
op.drop_table('playlistracks') op.drop_table("playlistracks")
op.drop_table('playlists') op.drop_table("playlists")
op.drop_index(op.f('ix_playdates_lastplayed'), table_name='playdates') op.drop_index(op.f("ix_playdates_lastplayed"), table_name="playdates")
op.drop_table('playdates') op.drop_table("playdates")
# ### end Alembic commands ### # ### end Alembic commands ###

450
poetry.lock generated
View File

@ -12,6 +12,24 @@ files = [
{file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"},
] ]
[[package]]
name = "alchemical"
version = "1.0.1"
description = "Modern SQLAlchemy simplified"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "alchemical-1.0.1-py3-none-any.whl", hash = "sha256:79a98d459ed4f8afa8ed44b21d4d2ccf3586c76d73f53f647a9338aaba2bb33c"},
{file = "alchemical-1.0.1.tar.gz", hash = "sha256:cff882cfef9533a56c53aa6bb38d5fa19939ea283226017c7c9369b73422200a"},
]
[package.dependencies]
sqlalchemy = ">=1.4.24"
[package.extras]
docs = ["sphinx"]
[[package]] [[package]]
name = "alembic" name = "alembic"
version = "1.13.1" version = "1.13.1"
@ -90,34 +108,34 @@ lxml = ["lxml"]
[[package]] [[package]]
name = "black" name = "black"
version = "24.2.0" version = "24.3.0"
description = "The uncompromising code formatter." description = "The uncompromising code formatter."
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "black-24.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6981eae48b3b33399c8757036c7f5d48a535b962a7c2310d19361edeef64ce29"}, {file = "black-24.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7d5e026f8da0322b5662fa7a8e752b3fa2dac1c1cbc213c3d7ff9bdd0ab12395"},
{file = "black-24.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d533d5e3259720fdbc1b37444491b024003e012c5173f7d06825a77508085430"}, {file = "black-24.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9f50ea1132e2189d8dff0115ab75b65590a3e97de1e143795adb4ce317934995"},
{file = "black-24.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61a0391772490ddfb8a693c067df1ef5227257e72b0e4108482b8d41b5aee13f"}, {file = "black-24.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2af80566f43c85f5797365077fb64a393861a3730bd110971ab7a0c94e873e7"},
{file = "black-24.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:992e451b04667116680cb88f63449267c13e1ad134f30087dec8527242e9862a"}, {file = "black-24.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:4be5bb28e090456adfc1255e03967fb67ca846a03be7aadf6249096100ee32d0"},
{file = "black-24.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:163baf4ef40e6897a2a9b83890e59141cc8c2a98f2dda5080dc15c00ee1e62cd"}, {file = "black-24.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4f1373a7808a8f135b774039f61d59e4be7eb56b2513d3d2f02a8b9365b8a8a9"},
{file = "black-24.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e37c99f89929af50ffaf912454b3e3b47fd64109659026b678c091a4cd450fb2"}, {file = "black-24.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aadf7a02d947936ee418777e0247ea114f78aff0d0959461057cae8a04f20597"},
{file = "black-24.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9de21bafcba9683853f6c96c2d515e364aee631b178eaa5145fc1c61a3cc92"}, {file = "black-24.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c02e4ea2ae09d16314d30912a58ada9a5c4fdfedf9512d23326128ac08ac3d"},
{file = "black-24.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:9db528bccb9e8e20c08e716b3b09c6bdd64da0dd129b11e160bf082d4642ac23"}, {file = "black-24.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf21b7b230718a5f08bd32d5e4f1db7fc8788345c8aea1d155fc17852b3410f5"},
{file = "black-24.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d84f29eb3ee44859052073b7636533ec995bd0f64e2fb43aeceefc70090e752b"}, {file = "black-24.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:2818cf72dfd5d289e48f37ccfa08b460bf469e67fb7c4abb07edc2e9f16fb63f"},
{file = "black-24.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e08fb9a15c914b81dd734ddd7fb10513016e5ce7e6704bdd5e1251ceee51ac9"}, {file = "black-24.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4acf672def7eb1725f41f38bf6bf425c8237248bb0804faa3965c036f7672d11"},
{file = "black-24.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:810d445ae6069ce64030c78ff6127cd9cd178a9ac3361435708b907d8a04c693"}, {file = "black-24.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7ed6668cbbfcd231fa0dc1b137d3e40c04c7f786e626b405c62bcd5db5857e4"},
{file = "black-24.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ba15742a13de85e9b8f3239c8f807723991fbfae24bad92d34a2b12e81904982"}, {file = "black-24.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:56f52cfbd3dabe2798d76dbdd299faa046a901041faf2cf33288bc4e6dae57b5"},
{file = "black-24.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7e53a8c630f71db01b28cd9602a1ada68c937cbf2c333e6ed041390d6968faf4"}, {file = "black-24.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:79dcf34b33e38ed1b17434693763301d7ccbd1c5860674a8f871bd15139e7837"},
{file = "black-24.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:93601c2deb321b4bad8f95df408e3fb3943d85012dddb6121336b8e24a0d1218"}, {file = "black-24.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e19cb1c6365fd6dc38a6eae2dcb691d7d83935c10215aef8e6c38edee3f77abd"},
{file = "black-24.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0057f800de6acc4407fe75bb147b0c2b5cbb7c3ed110d3e5999cd01184d53b0"}, {file = "black-24.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65b76c275e4c1c5ce6e9870911384bff5ca31ab63d19c76811cb1fb162678213"},
{file = "black-24.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:faf2ee02e6612577ba0181f4347bcbcf591eb122f7841ae5ba233d12c39dcb4d"}, {file = "black-24.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:b5991d523eee14756f3c8d5df5231550ae8993e2286b8014e2fdea7156ed0959"},
{file = "black-24.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:057c3dc602eaa6fdc451069bd027a1b2635028b575a6c3acfd63193ced20d9c8"}, {file = "black-24.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c45f8dff244b3c431b36e3224b6be4a127c6aca780853574c00faf99258041eb"},
{file = "black-24.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08654d0797e65f2423f850fc8e16a0ce50925f9337fb4a4a176a7aa4026e63f8"}, {file = "black-24.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6905238a754ceb7788a73f02b45637d820b2f5478b20fec82ea865e4f5d4d9f7"},
{file = "black-24.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca610d29415ee1a30a3f30fab7a8f4144e9d34c89a235d81292a1edb2b55f540"}, {file = "black-24.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7de8d330763c66663661a1ffd432274a2f92f07feeddd89ffd085b5744f85e7"},
{file = "black-24.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:4dd76e9468d5536abd40ffbc7a247f83b2324f0c050556d9c371c2b9a9a95e31"}, {file = "black-24.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:7bb041dca0d784697af4646d3b62ba4a6b028276ae878e53f6b4f74ddd6db99f"},
{file = "black-24.2.0-py3-none-any.whl", hash = "sha256:e8a6ae970537e67830776488bca52000eaa37fa63b9988e8c487458d9cd5ace6"}, {file = "black-24.3.0-py3-none-any.whl", hash = "sha256:41622020d7120e01d377f74249e677039d20e6344ff5851de8a10f11f513bf93"},
{file = "black-24.2.0.tar.gz", hash = "sha256:bce4f25c27c3435e4dace4815bcb2008b87e167e3bf4ee47ccdc5ce906eb4894"}, {file = "black-24.3.0.tar.gz", hash = "sha256:a0c9c4a0771afc6919578cec71ce82a3e31e054904e7197deacbc9382671c41f"},
] ]
[package.dependencies] [package.dependencies]
@ -247,6 +265,21 @@ files = [
{file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"},
] ]
[[package]]
name = "cheap-repr"
version = "0.5.1"
description = "Better version of repr/reprlib for short, cheap string representations."
category = "dev"
optional = false
python-versions = "*"
files = [
{file = "cheap_repr-0.5.1-py2.py3-none-any.whl", hash = "sha256:30096998aeb49367a4a153988d7a99dce9dc59bbdd4b19740da6b4f3f97cf2ff"},
{file = "cheap_repr-0.5.1.tar.gz", hash = "sha256:31ec63b9d8394aa23d746c8376c8307f75f9fca0b983566b8bcf13cc661fe6dd"},
]
[package.extras]
tests = ["Django", "Django (<2)", "Django (<3)", "chainmap", "numpy (>=1.16.3)", "numpy (>=1.16.3,<1.17)", "numpy (>=1.16.3,<1.19)", "pandas (>=0.24.2)", "pandas (>=0.24.2,<0.25)", "pandas (>=0.24.2,<0.26)", "pytest"]
[[package]] [[package]]
name = "click" name = "click"
version = "8.1.7" version = "8.1.7"
@ -292,6 +325,74 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""}
[package.extras] [package.extras]
development = ["black", "flake8", "mypy", "pytest", "types-colorama"] development = ["black", "flake8", "mypy", "pytest", "types-colorama"]
[[package]]
name = "coverage"
version = "7.4.4"
description = "Code coverage measurement for Python"
category = "dev"
optional = false
python-versions = ">=3.8"
files = [
{file = "coverage-7.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0be5efd5127542ef31f165de269f77560d6cdef525fffa446de6f7e9186cfb2"},
{file = "coverage-7.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ccd341521be3d1b3daeb41960ae94a5e87abe2f46f17224ba5d6f2b8398016cf"},
{file = "coverage-7.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fa497a8ab37784fbb20ab699c246053ac294d13fc7eb40ec007a5043ec91f8"},
{file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1a93009cb80730c9bca5d6d4665494b725b6e8e157c1cb7f2db5b4b122ea562"},
{file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:690db6517f09336559dc0b5f55342df62370a48f5469fabf502db2c6d1cffcd2"},
{file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:09c3255458533cb76ef55da8cc49ffab9e33f083739c8bd4f58e79fecfe288f7"},
{file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8ce1415194b4a6bd0cdcc3a1dfbf58b63f910dcb7330fe15bdff542c56949f87"},
{file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b91cbc4b195444e7e258ba27ac33769c41b94967919f10037e6355e998af255c"},
{file = "coverage-7.4.4-cp310-cp310-win32.whl", hash = "sha256:598825b51b81c808cb6f078dcb972f96af96b078faa47af7dfcdf282835baa8d"},
{file = "coverage-7.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:09ef9199ed6653989ebbcaacc9b62b514bb63ea2f90256e71fea3ed74bd8ff6f"},
{file = "coverage-7.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f9f50e7ef2a71e2fae92774c99170eb8304e3fdf9c8c3c7ae9bab3e7229c5cf"},
{file = "coverage-7.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:623512f8ba53c422fcfb2ce68362c97945095b864cda94a92edbaf5994201083"},
{file = "coverage-7.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0513b9508b93da4e1716744ef6ebc507aff016ba115ffe8ecff744d1322a7b63"},
{file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40209e141059b9370a2657c9b15607815359ab3ef9918f0196b6fccce8d3230f"},
{file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a2b2b78c78293782fd3767d53e6474582f62443d0504b1554370bde86cc8227"},
{file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:73bfb9c09951125d06ee473bed216e2c3742f530fc5acc1383883125de76d9cd"},
{file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1f384c3cc76aeedce208643697fb3e8437604b512255de6d18dae3f27655a384"},
{file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:54eb8d1bf7cacfbf2a3186019bcf01d11c666bd495ed18717162f7eb1e9dd00b"},
{file = "coverage-7.4.4-cp311-cp311-win32.whl", hash = "sha256:cac99918c7bba15302a2d81f0312c08054a3359eaa1929c7e4b26ebe41e9b286"},
{file = "coverage-7.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:b14706df8b2de49869ae03a5ccbc211f4041750cd4a66f698df89d44f4bd30ec"},
{file = "coverage-7.4.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:201bef2eea65e0e9c56343115ba3814e896afe6d36ffd37bab783261db430f76"},
{file = "coverage-7.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41c9c5f3de16b903b610d09650e5e27adbfa7f500302718c9ffd1c12cf9d6818"},
{file = "coverage-7.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d898fe162d26929b5960e4e138651f7427048e72c853607f2b200909794ed978"},
{file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ea79bb50e805cd6ac058dfa3b5c8f6c040cb87fe83de10845857f5535d1db70"},
{file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce4b94265ca988c3f8e479e741693d143026632672e3ff924f25fab50518dd51"},
{file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:00838a35b882694afda09f85e469c96367daa3f3f2b097d846a7216993d37f4c"},
{file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48"},
{file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:69eb372f7e2ece89f14751fbcbe470295d73ed41ecd37ca36ed2eb47512a6ab9"},
{file = "coverage-7.4.4-cp312-cp312-win32.whl", hash = "sha256:137eb07173141545e07403cca94ab625cc1cc6bc4c1e97b6e3846270e7e1fea0"},
{file = "coverage-7.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:d71eec7d83298f1af3326ce0ff1d0ea83c7cb98f72b577097f9083b20bdaf05e"},
{file = "coverage-7.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d5ae728ff3b5401cc320d792866987e7e7e880e6ebd24433b70a33b643bb0384"},
{file = "coverage-7.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc4f1358cb0c78edef3ed237ef2c86056206bb8d9140e73b6b89fbcfcbdd40e1"},
{file = "coverage-7.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8130a2aa2acb8788e0b56938786c33c7c98562697bf9f4c7d6e8e5e3a0501e4a"},
{file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf271892d13e43bc2b51e6908ec9a6a5094a4df1d8af0bfc360088ee6c684409"},
{file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4cdc86d54b5da0df6d3d3a2f0b710949286094c3a6700c21e9015932b81447e"},
{file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ae71e7ddb7a413dd60052e90528f2f65270aad4b509563af6d03d53e979feafd"},
{file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:38dd60d7bf242c4ed5b38e094baf6401faa114fc09e9e6632374388a404f98e7"},
{file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa5b1c1bfc28384f1f53b69a023d789f72b2e0ab1b3787aae16992a7ca21056c"},
{file = "coverage-7.4.4-cp38-cp38-win32.whl", hash = "sha256:dfa8fe35a0bb90382837b238fff375de15f0dcdb9ae68ff85f7a63649c98527e"},
{file = "coverage-7.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:b2991665420a803495e0b90a79233c1433d6ed77ef282e8e152a324bbbc5e0c8"},
{file = "coverage-7.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b799445b9f7ee8bf299cfaed6f5b226c0037b74886a4e11515e569b36fe310d"},
{file = "coverage-7.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b4d33f418f46362995f1e9d4f3a35a1b6322cb959c31d88ae56b0298e1c22357"},
{file = "coverage-7.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadacf9a2f407a4688d700e4ebab33a7e2e408f2ca04dbf4aef17585389eff3e"},
{file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c95949560050d04d46b919301826525597f07b33beba6187d04fa64d47ac82e"},
{file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4"},
{file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5fc1de20b2d4a061b3df27ab9b7c7111e9a710f10dc2b84d33a4ab25065994ec"},
{file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c74880fc64d4958159fbd537a091d2a585448a8f8508bf248d72112723974cbd"},
{file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:742a76a12aa45b44d236815d282b03cfb1de3b4323f3e4ec933acfae08e54ade"},
{file = "coverage-7.4.4-cp39-cp39-win32.whl", hash = "sha256:d89d7b2974cae412400e88f35d86af72208e1ede1a541954af5d944a8ba46c57"},
{file = "coverage-7.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:9ca28a302acb19b6af89e90f33ee3e1906961f94b54ea37de6737b7ca9d8827c"},
{file = "coverage-7.4.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:b2c5edc4ac10a7ef6605a966c58929ec6c1bd0917fb8c15cb3363f65aa40e677"},
{file = "coverage-7.4.4.tar.gz", hash = "sha256:c901df83d097649e257e803be22592aedfd5182f07b3cc87d640bbb9afd50f49"},
]
[package.dependencies]
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
[package.extras]
toml = ["tomli"]
[[package]] [[package]]
name = "decorator" name = "decorator"
version = "5.1.1" version = "5.1.1"
@ -515,23 +616,23 @@ files = [
[[package]] [[package]]
name = "importlib-metadata" name = "importlib-metadata"
version = "7.0.1" version = "7.1.0"
description = "Read metadata from Python packages" description = "Read metadata from Python packages"
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "importlib_metadata-7.0.1-py3-none-any.whl", hash = "sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e"}, {file = "importlib_metadata-7.1.0-py3-none-any.whl", hash = "sha256:30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570"},
{file = "importlib_metadata-7.0.1.tar.gz", hash = "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc"}, {file = "importlib_metadata-7.1.0.tar.gz", hash = "sha256:b78938b926ee8d5f020fc4772d487045805a55ddbad2ecf21c6d60938dc7fcd2"},
] ]
[package.dependencies] [package.dependencies]
zipp = ">=0.5" zipp = ">=0.5"
[package.extras] [package.extras]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
perf = ["ipython"] perf = ["ipython"]
testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] testing = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
@ -879,39 +980,39 @@ files = [
[[package]] [[package]]
name = "mypy" name = "mypy"
version = "1.8.0" version = "1.9.0"
description = "Optional static typing for Python" description = "Optional static typing for Python"
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, {file = "mypy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f"},
{file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, {file = "mypy-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed"},
{file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, {file = "mypy-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150"},
{file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, {file = "mypy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374"},
{file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, {file = "mypy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03"},
{file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, {file = "mypy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3"},
{file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, {file = "mypy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc"},
{file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, {file = "mypy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129"},
{file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, {file = "mypy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612"},
{file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, {file = "mypy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3"},
{file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, {file = "mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd"},
{file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, {file = "mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6"},
{file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, {file = "mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185"},
{file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, {file = "mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913"},
{file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, {file = "mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"},
{file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, {file = "mypy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b"},
{file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, {file = "mypy-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2"},
{file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, {file = "mypy-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e"},
{file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, {file = "mypy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04"},
{file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, {file = "mypy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89"},
{file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, {file = "mypy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02"},
{file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, {file = "mypy-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4"},
{file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, {file = "mypy-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d"},
{file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, {file = "mypy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf"},
{file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, {file = "mypy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9"},
{file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, {file = "mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e"},
{file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, {file = "mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974"},
] ]
[package.dependencies] [package.dependencies]
@ -1023,14 +1124,14 @@ dev = ["black", "isort", "pytest", "pytest-randomly"]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "23.2" version = "24.0"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"},
{file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"},
] ]
[[package]] [[package]]
@ -1398,16 +1499,16 @@ PyQt6-sip = ">=13.6,<14"
[[package]] [[package]]
name = "pyqt6-qt6" name = "pyqt6-qt6"
version = "6.6.2" version = "6.6.3"
description = "The subset of a Qt installation needed by PyQt6." description = "The subset of a Qt installation needed by PyQt6."
category = "main" category = "main"
optional = false optional = false
python-versions = "*" python-versions = "*"
files = [ files = [
{file = "PyQt6_Qt6-6.6.2-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:7ef446d3ffc678a8586ff6dc9f0d27caf4dff05dea02c353540d2f614386faf9"}, {file = "PyQt6_Qt6-6.6.3-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:1674d161ea49a36e9146fd652e789d413a246cc2455ac8bf9c76902b4bd3b986"},
{file = "PyQt6_Qt6-6.6.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b8363d88623342a72ac17da9127dc12f259bb3148796ea029762aa2d499778d9"}, {file = "PyQt6_Qt6-6.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:18fe1fbbc709dcff5c513e3cac7b1d7b630fb189e6d32a1601f193d73d326f42"},
{file = "PyQt6_Qt6-6.6.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:8d7f674a4ec43ca00191e14945ca4129acbe37a2172ed9d08214ad58b170bc11"}, {file = "PyQt6_Qt6-6.6.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6ae465dfcbb819dae5e18e8c96abba735b5bb2f16c066497dda4b7ca17c066ce"},
{file = "PyQt6_Qt6-6.6.2-py3-none-win_amd64.whl", hash = "sha256:5a41fe9d53b9e29e9ec5c23f3c5949dba160f90ca313ee8b96b8ffe6a5059387"}, {file = "PyQt6_Qt6-6.6.3-py3-none-win_amd64.whl", hash = "sha256:dbe509eccc579f8818b2b2e8ba93e27986facdd1d4d83ef1c7d9bd47cdf32651"},
] ]
[[package]] [[package]]
@ -1462,32 +1563,32 @@ PyQt6-WebEngine-Qt6 = ">=6.6.0"
[[package]] [[package]]
name = "pyqt6-webengine-qt6" name = "pyqt6-webengine-qt6"
version = "6.6.2" version = "6.6.3"
description = "The subset of a Qt installation needed by PyQt6-WebEngine." description = "The subset of a Qt installation needed by PyQt6-WebEngine."
category = "main" category = "main"
optional = false optional = false
python-versions = "*" python-versions = "*"
files = [ files = [
{file = "PyQt6_WebEngine_Qt6-6.6.2-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:27b1b6a6f4ea115b3dd300d2df906d542009d9eb0e62b05e6b7cb85dfe68e9c3"}, {file = "PyQt6_WebEngine_Qt6-6.6.3-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:4ce545accc5a58d62bde7ce18253a70b3970c28a24c94642ec89537352c23974"},
{file = "PyQt6_WebEngine_Qt6-6.6.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2364dfa3a6e751ead71b7ba759081be677fcf1c6bbd8a2a2a250eb5f06432e8"}, {file = "PyQt6_WebEngine_Qt6-6.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a82308115193a6f220d6310453d1edbe30f1a8ac32c01fc813865319a2199959"},
{file = "PyQt6_WebEngine_Qt6-6.6.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3da4db9ddd984b647d0b79fa10fc6cf65364dfe283cd702b12cb7164be2307cd"}, {file = "PyQt6_WebEngine_Qt6-6.6.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:87f636e23e9c1a1326bf91d273da6bdfed2f42fcc243e527e7b0dbc4f39e70dd"},
{file = "PyQt6_WebEngine_Qt6-6.6.2-py3-none-win_amd64.whl", hash = "sha256:5d6f3ae521115cee77fea22b0248e7b219995390b951b51e4d519aef9c304ca8"}, {file = "PyQt6_WebEngine_Qt6-6.6.3-py3-none-win_amd64.whl", hash = "sha256:3d3e81db62f166f5fbc24b28660fe81c1be4390282bfb9bb48111f32a6bd0f51"},
] ]
[[package]] [[package]]
name = "pyqtgraph" name = "pyqtgraph"
version = "0.13.3" version = "0.13.4"
description = "Scientific Graphics and GUI Library for Python" description = "Scientific Graphics and GUI Library for Python"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.9"
files = [ files = [
{file = "pyqtgraph-0.13.3-py3-none-any.whl", hash = "sha256:fdcc04ac4b32a7bedf1bf3cf74cbb93ab3ba5687791712bbfa8d0712377d2f2b"}, {file = "pyqtgraph-0.13.4-py3-none-any.whl", hash = "sha256:1dc9a786aa43cd787114366058dc3b4b8cb96a0e318f334720c7e6cc6c285940"},
{file = "pyqtgraph-0.13.3.tar.gz", hash = "sha256:58108d8411c7054e0841d8b791ee85e101fc296b9b359c0e01dde38a98ff2ace"}, {file = "pyqtgraph-0.13.4.tar.gz", hash = "sha256:67b0d371405c4fd5f35afecfeb37d4b73bc118f187c52a965ed68d62f59b67b3"},
] ]
[package.dependencies] [package.dependencies]
numpy = ">=1.20.0" numpy = ">=1.22.0"
[[package]] [[package]]
name = "pyreadline3" name = "pyreadline3"
@ -1503,14 +1604,14 @@ files = [
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "7.4.4" version = "8.1.1"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.8"
files = [ files = [
{file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"},
{file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"},
] ]
[package.dependencies] [package.dependencies]
@ -1518,11 +1619,30 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
iniconfig = "*" iniconfig = "*"
packaging = "*" packaging = "*"
pluggy = ">=0.12,<2.0" pluggy = ">=1.4,<2.0"
tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} tomli = {version = ">=1", markers = "python_version < \"3.11\""}
[package.extras] [package.extras]
testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-cov"
version = "5.0.0"
description = "Pytest plugin for measuring coverage."
category = "dev"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"},
{file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"},
]
[package.dependencies]
coverage = {version = ">=5.2.1", extras = ["toml"]}
pytest = ">=4.6"
[package.extras]
testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"]
[[package]] [[package]]
name = "pytest-qt" name = "pytest-qt"
@ -1631,19 +1751,19 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]] [[package]]
name = "setuptools" name = "setuptools"
version = "69.1.1" version = "69.2.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages" description = "Easily download, build, install, upgrade, and uninstall Python packages"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "setuptools-69.1.1-py3-none-any.whl", hash = "sha256:02fa291a0471b3a18b2b2481ed902af520c69e8ae0919c13da936542754b4c56"}, {file = "setuptools-69.2.0-py3-none-any.whl", hash = "sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c"},
{file = "setuptools-69.1.1.tar.gz", hash = "sha256:5c0806c7d9af348e6dd3777b4f4dbb42c7ad85b190104837488eab9a7c945cf8"}, {file = "setuptools-69.2.0.tar.gz", hash = "sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e"},
] ]
[package.extras] [package.extras]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
[[package]] [[package]]
@ -1658,6 +1778,28 @@ files = [
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
] ]
[[package]]
name = "snoop"
version = "0.4.3"
description = "Powerful debugging tools for Python"
category = "dev"
optional = false
python-versions = "*"
files = [
{file = "snoop-0.4.3-py2.py3-none-any.whl", hash = "sha256:b7418581889ff78b29d9dc5ad4625c4c475c74755fb5cba82c693c6e32afadc0"},
{file = "snoop-0.4.3.tar.gz", hash = "sha256:2e0930bb19ff0dbdaa6f5933f88e89ed5984210ea9f9de0e1d8231fa5c1c1f25"},
]
[package.dependencies]
asttokens = "*"
cheap-repr = ">=0.4.0"
executing = "*"
pygments = "*"
six = "*"
[package.extras]
tests = ["Django", "birdseye", "littleutils", "numpy (>=1.16.5)", "pandas (>=0.24.2)", "pprintpp", "prettyprinter", "pytest", "pytest-order", "pytest-order (<=0.11.0)"]
[[package]] [[package]]
name = "snowballstemmer" name = "snowballstemmer"
version = "2.2.0" version = "2.2.0"
@ -1838,61 +1980,61 @@ test = ["pytest"]
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.27" version = "2.0.29"
description = "Database Abstraction Library" description = "Database Abstraction Library"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "SQLAlchemy-2.0.27-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d04e579e911562f1055d26dab1868d3e0bb905db3bccf664ee8ad109f035618a"}, {file = "SQLAlchemy-2.0.29-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4c142852ae192e9fe5aad5c350ea6befe9db14370b34047e1f0f7cf99e63c63b"},
{file = "SQLAlchemy-2.0.27-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fa67d821c1fd268a5a87922ef4940442513b4e6c377553506b9db3b83beebbd8"}, {file = "SQLAlchemy-2.0.29-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:99a1e69d4e26f71e750e9ad6fdc8614fbddb67cfe2173a3628a2566034e223c7"},
{file = "SQLAlchemy-2.0.27-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c7a596d0be71b7baa037f4ac10d5e057d276f65a9a611c46970f012752ebf2d"}, {file = "SQLAlchemy-2.0.29-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ef3fbccb4058355053c51b82fd3501a6e13dd808c8d8cd2561e610c5456013c"},
{file = "SQLAlchemy-2.0.27-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:954d9735ee9c3fa74874c830d089a815b7b48df6f6b6e357a74130e478dbd951"}, {file = "SQLAlchemy-2.0.29-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d6753305936eddc8ed190e006b7bb33a8f50b9854823485eed3a886857ab8d1"},
{file = "SQLAlchemy-2.0.27-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5cd20f58c29bbf2680039ff9f569fa6d21453fbd2fa84dbdb4092f006424c2e6"}, {file = "SQLAlchemy-2.0.29-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0f3ca96af060a5250a8ad5a63699180bc780c2edf8abf96c58af175921df847a"},
{file = "SQLAlchemy-2.0.27-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:03f448ffb731b48323bda68bcc93152f751436ad6037f18a42b7e16af9e91c07"}, {file = "SQLAlchemy-2.0.29-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c4520047006b1d3f0d89e0532978c0688219857eb2fee7c48052560ae76aca1e"},
{file = "SQLAlchemy-2.0.27-cp310-cp310-win32.whl", hash = "sha256:d997c5938a08b5e172c30583ba6b8aad657ed9901fc24caf3a7152eeccb2f1b4"}, {file = "SQLAlchemy-2.0.29-cp310-cp310-win32.whl", hash = "sha256:b2a0e3cf0caac2085ff172c3faacd1e00c376e6884b5bc4dd5b6b84623e29e4f"},
{file = "SQLAlchemy-2.0.27-cp310-cp310-win_amd64.whl", hash = "sha256:eb15ef40b833f5b2f19eeae65d65e191f039e71790dd565c2af2a3783f72262f"}, {file = "SQLAlchemy-2.0.29-cp310-cp310-win_amd64.whl", hash = "sha256:01d10638a37460616708062a40c7b55f73e4d35eaa146781c683e0fa7f6c43fb"},
{file = "SQLAlchemy-2.0.27-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6c5bad7c60a392850d2f0fee8f355953abaec878c483dd7c3836e0089f046bf6"}, {file = "SQLAlchemy-2.0.29-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:308ef9cb41d099099fffc9d35781638986870b29f744382904bf9c7dadd08513"},
{file = "SQLAlchemy-2.0.27-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3012ab65ea42de1be81fff5fb28d6db893ef978950afc8130ba707179b4284a"}, {file = "SQLAlchemy-2.0.29-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:296195df68326a48385e7a96e877bc19aa210e485fa381c5246bc0234c36c78e"},
{file = "SQLAlchemy-2.0.27-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dbcd77c4d94b23e0753c5ed8deba8c69f331d4fd83f68bfc9db58bc8983f49cd"}, {file = "SQLAlchemy-2.0.29-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a13b917b4ffe5a0a31b83d051d60477819ddf18276852ea68037a144a506efb9"},
{file = "SQLAlchemy-2.0.27-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d177b7e82f6dd5e1aebd24d9c3297c70ce09cd1d5d37b43e53f39514379c029c"}, {file = "SQLAlchemy-2.0.29-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f6d971255d9ddbd3189e2e79d743ff4845c07f0633adfd1de3f63d930dbe673"},
{file = "SQLAlchemy-2.0.27-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:680b9a36029b30cf063698755d277885d4a0eab70a2c7c6e71aab601323cba45"}, {file = "SQLAlchemy-2.0.29-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:61405ea2d563407d316c63a7b5271ae5d274a2a9fbcd01b0aa5503635699fa1e"},
{file = "SQLAlchemy-2.0.27-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1306102f6d9e625cebaca3d4c9c8f10588735ef877f0360b5cdb4fdfd3fd7131"}, {file = "SQLAlchemy-2.0.29-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de7202ffe4d4a8c1e3cde1c03e01c1a3772c92858837e8f3879b497158e4cb44"},
{file = "SQLAlchemy-2.0.27-cp311-cp311-win32.whl", hash = "sha256:5b78aa9f4f68212248aaf8943d84c0ff0f74efc65a661c2fc68b82d498311fd5"}, {file = "SQLAlchemy-2.0.29-cp311-cp311-win32.whl", hash = "sha256:b5d7ed79df55a731749ce65ec20d666d82b185fa4898430b17cb90c892741520"},
{file = "SQLAlchemy-2.0.27-cp311-cp311-win_amd64.whl", hash = "sha256:15e19a84b84528f52a68143439d0c7a3a69befcd4f50b8ef9b7b69d2628ae7c4"}, {file = "SQLAlchemy-2.0.29-cp311-cp311-win_amd64.whl", hash = "sha256:205f5a2b39d7c380cbc3b5dcc8f2762fb5bcb716838e2d26ccbc54330775b003"},
{file = "SQLAlchemy-2.0.27-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0de1263aac858f288a80b2071990f02082c51d88335a1db0d589237a3435fe71"}, {file = "SQLAlchemy-2.0.29-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d96710d834a6fb31e21381c6d7b76ec729bd08c75a25a5184b1089141356171f"},
{file = "SQLAlchemy-2.0.27-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce850db091bf7d2a1f2fdb615220b968aeff3849007b1204bf6e3e50a57b3d32"}, {file = "SQLAlchemy-2.0.29-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:52de4736404e53c5c6a91ef2698c01e52333988ebdc218f14c833237a0804f1b"},
{file = "SQLAlchemy-2.0.27-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dfc936870507da96aebb43e664ae3a71a7b96278382bcfe84d277b88e379b18"}, {file = "SQLAlchemy-2.0.29-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c7b02525ede2a164c5fa5014915ba3591730f2cc831f5be9ff3b7fd3e30958e"},
{file = "SQLAlchemy-2.0.27-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4fbe6a766301f2e8a4519f4500fe74ef0a8509a59e07a4085458f26228cd7cc"}, {file = "SQLAlchemy-2.0.29-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dfefdb3e54cd15f5d56fd5ae32f1da2d95d78319c1f6dfb9bcd0eb15d603d5d"},
{file = "SQLAlchemy-2.0.27-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4535c49d961fe9a77392e3a630a626af5baa967172d42732b7a43496c8b28876"}, {file = "SQLAlchemy-2.0.29-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a88913000da9205b13f6f195f0813b6ffd8a0c0c2bd58d499e00a30eb508870c"},
{file = "SQLAlchemy-2.0.27-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0fb3bffc0ced37e5aa4ac2416f56d6d858f46d4da70c09bb731a246e70bff4d5"}, {file = "SQLAlchemy-2.0.29-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fecd5089c4be1bcc37c35e9aa678938d2888845a134dd016de457b942cf5a758"},
{file = "SQLAlchemy-2.0.27-cp312-cp312-win32.whl", hash = "sha256:7f470327d06400a0aa7926b375b8e8c3c31d335e0884f509fe272b3c700a7254"}, {file = "SQLAlchemy-2.0.29-cp312-cp312-win32.whl", hash = "sha256:8197d6f7a3d2b468861ebb4c9f998b9df9e358d6e1cf9c2a01061cb9b6cf4e41"},
{file = "SQLAlchemy-2.0.27-cp312-cp312-win_amd64.whl", hash = "sha256:f9374e270e2553653d710ece397df67db9d19c60d2647bcd35bfc616f1622dcd"}, {file = "SQLAlchemy-2.0.29-cp312-cp312-win_amd64.whl", hash = "sha256:9b19836ccca0d321e237560e475fd99c3d8655d03da80c845c4da20dda31b6e1"},
{file = "SQLAlchemy-2.0.27-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e97cf143d74a7a5a0f143aa34039b4fecf11343eed66538610debc438685db4a"}, {file = "SQLAlchemy-2.0.29-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:87a1d53a5382cdbbf4b7619f107cc862c1b0a4feb29000922db72e5a66a5ffc0"},
{file = "SQLAlchemy-2.0.27-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7b5a3e2120982b8b6bd1d5d99e3025339f7fb8b8267551c679afb39e9c7c7f1"}, {file = "SQLAlchemy-2.0.29-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a0732dffe32333211801b28339d2a0babc1971bc90a983e3035e7b0d6f06b93"},
{file = "SQLAlchemy-2.0.27-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e36aa62b765cf9f43a003233a8c2d7ffdeb55bc62eaa0a0380475b228663a38f"}, {file = "SQLAlchemy-2.0.29-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90453597a753322d6aa770c5935887ab1fc49cc4c4fdd436901308383d698b4b"},
{file = "SQLAlchemy-2.0.27-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:5ada0438f5b74c3952d916c199367c29ee4d6858edff18eab783b3978d0db16d"}, {file = "SQLAlchemy-2.0.29-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ea311d4ee9a8fa67f139c088ae9f905fcf0277d6cd75c310a21a88bf85e130f5"},
{file = "SQLAlchemy-2.0.27-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b1d9d1bfd96eef3c3faedb73f486c89e44e64e40e5bfec304ee163de01cf996f"}, {file = "SQLAlchemy-2.0.29-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5f20cb0a63a3e0ec4e169aa8890e32b949c8145983afa13a708bc4b0a1f30e03"},
{file = "SQLAlchemy-2.0.27-cp37-cp37m-win32.whl", hash = "sha256:ca891af9f3289d24a490a5fde664ea04fe2f4984cd97e26de7442a4251bd4b7c"}, {file = "SQLAlchemy-2.0.29-cp37-cp37m-win32.whl", hash = "sha256:e5bbe55e8552019c6463709b39634a5fc55e080d0827e2a3a11e18eb73f5cdbd"},
{file = "SQLAlchemy-2.0.27-cp37-cp37m-win_amd64.whl", hash = "sha256:fd8aafda7cdff03b905d4426b714601c0978725a19efc39f5f207b86d188ba01"}, {file = "SQLAlchemy-2.0.29-cp37-cp37m-win_amd64.whl", hash = "sha256:c2f9c762a2735600654c654bf48dad388b888f8ce387b095806480e6e4ff6907"},
{file = "SQLAlchemy-2.0.27-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ec1f5a328464daf7a1e4e385e4f5652dd9b1d12405075ccba1df842f7774b4fc"}, {file = "SQLAlchemy-2.0.29-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7e614d7a25a43a9f54fcce4675c12761b248547f3d41b195e8010ca7297c369c"},
{file = "SQLAlchemy-2.0.27-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ad862295ad3f644e3c2c0d8b10a988e1600d3123ecb48702d2c0f26771f1c396"}, {file = "SQLAlchemy-2.0.29-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:471fcb39c6adf37f820350c28aac4a7df9d3940c6548b624a642852e727ea586"},
{file = "SQLAlchemy-2.0.27-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48217be1de7d29a5600b5c513f3f7664b21d32e596d69582be0a94e36b8309cb"}, {file = "SQLAlchemy-2.0.29-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:988569c8732f54ad3234cf9c561364221a9e943b78dc7a4aaf35ccc2265f1930"},
{file = "SQLAlchemy-2.0.27-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e56afce6431450442f3ab5973156289bd5ec33dd618941283847c9fd5ff06bf"}, {file = "SQLAlchemy-2.0.29-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dddaae9b81c88083e6437de95c41e86823d150f4ee94bf24e158a4526cbead01"},
{file = "SQLAlchemy-2.0.27-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:611068511b5531304137bcd7fe8117c985d1b828eb86043bd944cebb7fae3910"}, {file = "SQLAlchemy-2.0.29-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:334184d1ab8f4c87f9652b048af3f7abea1c809dfe526fb0435348a6fef3d380"},
{file = "SQLAlchemy-2.0.27-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b86abba762ecfeea359112b2bb4490802b340850bbee1948f785141a5e020de8"}, {file = "SQLAlchemy-2.0.29-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:38b624e5cf02a69b113c8047cf7f66b5dfe4a2ca07ff8b8716da4f1b3ae81567"},
{file = "SQLAlchemy-2.0.27-cp38-cp38-win32.whl", hash = "sha256:30d81cc1192dc693d49d5671cd40cdec596b885b0ce3b72f323888ab1c3863d5"}, {file = "SQLAlchemy-2.0.29-cp38-cp38-win32.whl", hash = "sha256:bab41acf151cd68bc2b466deae5deeb9e8ae9c50ad113444151ad965d5bf685b"},
{file = "SQLAlchemy-2.0.27-cp38-cp38-win_amd64.whl", hash = "sha256:120af1e49d614d2525ac247f6123841589b029c318b9afbfc9e2b70e22e1827d"}, {file = "SQLAlchemy-2.0.29-cp38-cp38-win_amd64.whl", hash = "sha256:52c8011088305476691b8750c60e03b87910a123cfd9ad48576d6414b6ec2a1d"},
{file = "SQLAlchemy-2.0.27-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d07ee7793f2aeb9b80ec8ceb96bc8cc08a2aec8a1b152da1955d64e4825fcbac"}, {file = "SQLAlchemy-2.0.29-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3071ad498896907a5ef756206b9dc750f8e57352113c19272bdfdc429c7bd7de"},
{file = "SQLAlchemy-2.0.27-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cb0845e934647232b6ff5150df37ceffd0b67b754b9fdbb095233deebcddbd4a"}, {file = "SQLAlchemy-2.0.29-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dba622396a3170974f81bad49aacebd243455ec3cc70615aeaef9e9613b5bca5"},
{file = "SQLAlchemy-2.0.27-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fc19ae2e07a067663dd24fca55f8ed06a288384f0e6e3910420bf4b1270cc51"}, {file = "SQLAlchemy-2.0.29-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b184e3de58009cc0bf32e20f137f1ec75a32470f5fede06c58f6c355ed42a72"},
{file = "SQLAlchemy-2.0.27-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b90053be91973a6fb6020a6e44382c97739736a5a9d74e08cc29b196639eb979"}, {file = "SQLAlchemy-2.0.29-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c37f1050feb91f3d6c32f864d8e114ff5545a4a7afe56778d76a9aec62638ba"},
{file = "SQLAlchemy-2.0.27-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2f5c9dfb0b9ab5e3a8a00249534bdd838d943ec4cfb9abe176a6c33408430230"}, {file = "SQLAlchemy-2.0.29-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bda7ce59b06d0f09afe22c56714c65c957b1068dee3d5e74d743edec7daba552"},
{file = "SQLAlchemy-2.0.27-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:33e8bde8fff203de50399b9039c4e14e42d4d227759155c21f8da4a47fc8053c"}, {file = "SQLAlchemy-2.0.29-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:25664e18bef6dc45015b08f99c63952a53a0a61f61f2e48a9e70cec27e55f699"},
{file = "SQLAlchemy-2.0.27-cp39-cp39-win32.whl", hash = "sha256:d873c21b356bfaf1589b89090a4011e6532582b3a8ea568a00e0c3aab09399dd"}, {file = "SQLAlchemy-2.0.29-cp39-cp39-win32.whl", hash = "sha256:77d29cb6c34b14af8a484e831ab530c0f7188f8efed1c6a833a2c674bf3c26ec"},
{file = "SQLAlchemy-2.0.27-cp39-cp39-win_amd64.whl", hash = "sha256:ff2f1b7c963961d41403b650842dc2039175b906ab2093635d8319bef0b7d620"}, {file = "SQLAlchemy-2.0.29-cp39-cp39-win_amd64.whl", hash = "sha256:04c487305ab035a9548f573763915189fc0fe0824d9ba28433196f8436f1449c"},
{file = "SQLAlchemy-2.0.27-py3-none-any.whl", hash = "sha256:1ab4e0448018d01b142c916cc7119ca573803a4745cfe341b8f95657812700ac"}, {file = "SQLAlchemy-2.0.29-py3-none-any.whl", hash = "sha256:dc4ee2d4ee43251905f88637d5281a8d52e916a021384ec10758826f5cbae305"},
{file = "SQLAlchemy-2.0.27.tar.gz", hash = "sha256:86a6ed69a71fe6b88bf9331594fa390a2adda4a49b5c06f98e47bf0d392534f8"}, {file = "SQLAlchemy-2.0.29.tar.gz", hash = "sha256:bd9566b8e58cabd700bc367b60e90d9349cd16f0984973f98a9a09f9c64e86f0"},
] ]
[package.dependencies] [package.dependencies]
@ -1946,14 +2088,14 @@ tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"]
[[package]] [[package]]
name = "stackprinter" name = "stackprinter"
version = "0.2.11" version = "0.2.12"
description = "Debug-friendly stack traces, with variable values and semantic highlighting" description = "Debug-friendly stack traces, with variable values and semantic highlighting"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.4" python-versions = ">=3.4"
files = [ files = [
{file = "stackprinter-0.2.11-py3-none-any.whl", hash = "sha256:101da55db7dfd54af516e3e209db9c84645285e5ea00d0b0709418dde2f157a1"}, {file = "stackprinter-0.2.12-py3-none-any.whl", hash = "sha256:0a0623d46a5babd7a8a9787f605f4dd4a42d6ff7aee140541d5e9291a506e8d9"},
{file = "stackprinter-0.2.11.tar.gz", hash = "sha256:abbd8f4f892f24a5bd370119af49c3e3408b0bf04cd4d28e99f81c4e781a767b"}, {file = "stackprinter-0.2.12.tar.gz", hash = "sha256:271efc75ebdcc1554e58168ea7779f98066d54a325f57c7dc19f10fa998ef01e"},
] ]
[[package]] [[package]]
@ -2039,30 +2181,30 @@ files = [
[[package]] [[package]]
name = "traitlets" name = "traitlets"
version = "5.14.1" version = "5.14.2"
description = "Traitlets Python configuration system" description = "Traitlets Python configuration system"
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "traitlets-5.14.1-py3-none-any.whl", hash = "sha256:2e5a030e6eff91737c643231bfcf04a65b0132078dad75e4936700b213652e74"}, {file = "traitlets-5.14.2-py3-none-any.whl", hash = "sha256:fcdf85684a772ddeba87db2f398ce00b40ff550d1528c03c14dbf6a02003cd80"},
{file = "traitlets-5.14.1.tar.gz", hash = "sha256:8585105b371a04b8316a43d5ce29c098575c2e477850b62b848b964f1444527e"}, {file = "traitlets-5.14.2.tar.gz", hash = "sha256:8cdd83c040dab7d1dee822678e5f5d100b514f7b72b01615b26fc5718916fdf9"},
] ]
[package.extras] [package.extras]
docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"]
test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<7.5)", "pytest-mock", "pytest-mypy-testing"] test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.1)", "pytest-mock", "pytest-mypy-testing"]
[[package]] [[package]]
name = "types-psutil" name = "types-psutil"
version = "5.9.5.20240205" version = "5.9.5.20240316"
description = "Typing stubs for psutil" description = "Typing stubs for psutil"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "types-psutil-5.9.5.20240205.tar.gz", hash = "sha256:51df36a361aa597bf483dcc5b58f2ab7aa87452a36d2da97c90994d6a81ef743"}, {file = "types-psutil-5.9.5.20240316.tar.gz", hash = "sha256:5636f5714bb930c64bb34c4d47a59dc92f9d610b778b5364a31daa5584944848"},
{file = "types_psutil-5.9.5.20240205-py3-none-any.whl", hash = "sha256:3ec9bd8b95a64fe1269241d3ffb74b94a45df2d0391da1402423cd33f29745ca"}, {file = "types_psutil-5.9.5.20240316-py3-none-any.whl", hash = "sha256:2fdd64ea6e97befa546938f486732624f9255fde198b55e6f00fda236f059f64"},
] ]
[[package]] [[package]]
@ -2097,14 +2239,14 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]] [[package]]
name = "urwid" name = "urwid"
version = "2.6.7" version = "2.6.10"
description = "A full-featured console (xterm et al.) user interface library" description = "A full-featured console (xterm et al.) user interface library"
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">3.7" python-versions = ">3.7"
files = [ files = [
{file = "urwid-2.6.7-py3-none-any.whl", hash = "sha256:80b922d2051db6abe598b7e1b0b31d8d04fcc56d35bb1ec40b3c128fa0bd23ab"}, {file = "urwid-2.6.10-py3-none-any.whl", hash = "sha256:f5d290ab01a9cf69a062d5d04ff69111903d41fc14ed03f3ed92cb36f5ef4735"},
{file = "urwid-2.6.7.tar.gz", hash = "sha256:597fa2d19ac788e4607d2a48aca32f257342201cb55e5f6a00a8fcd24e62a5ab"}, {file = "urwid-2.6.10.tar.gz", hash = "sha256:ae33355c414c13214e541d3634f3c8a0bfb373914e62ffbcf2fa863527706321"},
] ]
[package.dependencies] [package.dependencies]
@ -2169,21 +2311,21 @@ test = ["websockets"]
[[package]] [[package]]
name = "zipp" name = "zipp"
version = "3.17.0" version = "3.18.1"
description = "Backport of pathlib-compatible object wrapper for zip files" description = "Backport of pathlib-compatible object wrapper for zip files"
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"}, {file = "zipp-3.18.1-py3-none-any.whl", hash = "sha256:206f5a15f2af3dbaee80769fb7dc6f249695e940acca08dfb2a4769fe61e538b"},
{file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"}, {file = "zipp-3.18.1.tar.gz", hash = "sha256:2884ed22e7d8961de1c9a05142eb69a247f120291bc0206a00a7642f09b5b715"},
] ]
[package.extras] [package.extras]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.9" python-versions = "^3.9"
content-hash = "9fc13f4695a3be773cbbdcc175495650e638f31d786f6e35724b57f70bcb5f78" content-hash = "e8a4a3f4b5dd70bd5fb2ab420b4de6e3304a15be383233bb01b966e047700cd1"

View File

@ -27,10 +27,10 @@ pyqt6-webengine = "^6.5.0"
pygame = "^2.4.0" pygame = "^2.4.0"
pyqtgraph = "^0.13.3" pyqtgraph = "^0.13.3"
colorlog = "^6.8.0" colorlog = "^6.8.0"
alchemical = "^1.0.1"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
ipdb = "^0.13.9" ipdb = "^0.13.9"
pytest = "^7.0.1"
pytest-qt = "^4.0.2" pytest-qt = "^4.0.2"
pydub-stubs = "^0.25.1" pydub-stubs = "^0.25.1"
line-profiler = "^4.0.2" line-profiler = "^4.0.2"
@ -44,6 +44,9 @@ black = "^24.2.0"
flakehell = "^0.9.0" flakehell = "^0.9.0"
mypy = "^1.7.0" mypy = "^1.7.0"
pdbp = "^1.5.0" pdbp = "^1.5.0"
pytest-cov = "^5.0.0"
pytest = "^8.1.1"
snoop = "^0.4.3"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]

View File

@ -1,76 +0,0 @@
from datetime import datetime, timedelta
from helpers import (
fade_point,
get_audio_segment,
get_tags,
get_relative_date,
leading_silence,
ms_to_mmss,
)
def test_fade_point():
test_track_path = "testdata/isa.mp3"
test_track_data = "testdata/isa.py"
audio_segment = get_audio_segment(test_track_path)
assert audio_segment
fade_at = fade_point(audio_segment)
# Get test data
with open(test_track_data) as f:
testdata = eval(f.read())
# Volume detection can vary, so ± 1 second is OK
assert fade_at < testdata["fade_at"] + 1000
assert fade_at > testdata["fade_at"] - 1000
def test_get_tags():
test_track_path = "testdata/mom.mp3"
test_track_data = "testdata/mom.py"
tags = get_tags(test_track_path)
# Get test data
with open(test_track_data) as f:
testdata = eval(f.read())
assert tags["artist"] == testdata["artist"]
assert tags["title"] == testdata["title"]
def test_get_relative_date():
assert get_relative_date(None) == "Never"
today_at_10 = datetime.now().replace(hour=10, minute=0)
today_at_11 = datetime.now().replace(hour=11, minute=0)
assert get_relative_date(today_at_10, today_at_11) == "Today 10:00"
eight_days_ago = today_at_10 - timedelta(days=8)
assert get_relative_date(eight_days_ago, today_at_11) == "1 week, 1 day ago"
sixteen_days_ago = today_at_10 - timedelta(days=16)
assert get_relative_date(sixteen_days_ago, today_at_11) == "2 weeks, 2 days ago"
def test_leading_silence():
test_track_path = "testdata/isa.mp3"
test_track_data = "testdata/isa.py"
audio_segment = get_audio_segment(test_track_path)
assert audio_segment
silence_at = leading_silence(audio_segment)
# Get test data
with open(test_track_data) as f:
testdata = eval(f.read())
# Volume detection can vary, so ± 1 second is OK
assert silence_at < testdata["leading_silence"] + 1000
assert silence_at > testdata["leading_silence"] - 1000
def test_ms_to_mmss():
assert ms_to_mmss(None) == "-"
assert ms_to_mmss(59600) == "0:59"
assert ms_to_mmss((5 * 60 * 1000) + 23000) == "5:23"

View File

@ -1,186 +0,0 @@
import os.path
import helpers
from app.models import (
NoteColours,
Playdates,
Playlists,
Tracks,
)
def test_notecolours_get_colour(session):
"""Create a colour record and retrieve all colours"""
print(">>>text_notcolours_get_colour")
note_colour = "#0bcdef"
NoteColours(session, substring="substring", colour=note_colour)
records = NoteColours.get_all(session)
assert len(records) == 1
record = records[0]
assert record.colour == note_colour
def test_notecolours_get_all(session):
"""Create two colour records and retrieve them all"""
print(">>>text_notcolours_get_all")
note1_colour = "#1bcdef"
note2_colour = "#20ff00"
NoteColours(session, substring="note1", colour=note1_colour)
NoteColours(session, substring="note2", colour=note2_colour)
records = NoteColours.get_all(session)
assert len(records) == 2
assert note1_colour in [n.colour for n in records]
assert note2_colour in [n.colour for n in records]
def test_notecolours_get_colour_none(session):
note_colour = "#3bcdef"
NoteColours(session, substring="substring", colour=note_colour)
result = NoteColours.get_colour(session, "xyz")
assert result is None
def test_notecolours_get_colour_match(session):
note_colour = "#4bcdef"
nc = NoteColours(session, substring="sub", colour=note_colour)
assert nc
result = NoteColours.get_colour(session, "The substring")
assert result == note_colour
def test_playdates_add_playdate(session, track1):
"""Test playdate and last_played retrieval"""
playdate = Playdates(session, track1.id)
assert playdate
last_played = Playdates.last_played(session, track1.id)
assert abs((playdate.lastplayed - last_played).total_seconds()) < 2
def test_playlist_create(session):
playlist = Playlists(session, "my playlist")
assert playlist
# def test_playlist_add_track(session, track):
# # We need a playlist
# playlist = Playlists(session, "my playlist")
# row = 17
# playlist.add_track(session, track.id, row)
# assert len(playlist.tracks) == 1
# playlist_track = playlist.tracks[row]
# assert playlist_track.path == track_path
# def test_playlist_tracks(session):
# # We need a playlist
# playlist = Playlists(session, "my playlist")
# # We need two tracks
# track1_path = "/a/b/c"
# track1_row = 17
# track1 = Tracks(session, track1_path)
# track2_path = "/x/y/z"
# track2_row = 29
# track2 = Tracks(session, track2_path)
# playlist.add_track(session, track1.id, track1_row)
# playlist.add_track(session, track2.id, track2_row)
# tracks = playlist.tracks
# assert tracks[track1_row] == track1
# assert tracks[track2_row] == track2
# def test_playlist_notes(session):
# # We need a playlist
# playlist = Playlists(session, "my playlist")
# # We need two notes
# note1_text = "note1 text"
# note1_row = 11
# _ = Notes(session, playlist.id, note1_row, note1_text)
# note2_text = "note2 text"
# note2_row = 19
# _ = Notes(session, playlist.id, note2_row, note2_text)
# notes = playlist.notes
# assert note1_text in [n.note for n in notes]
# assert note1_row in [n.row for n in notes]
# assert note2_text in [n.note for n in notes]
# assert note2_row in [n.row for n in notes]
def test_playlist_open_and_close(session):
# We need a playlist
playlist = Playlists(session, "my playlist")
assert len(Playlists.get_open(session)) == 0
assert len(Playlists.get_closed(session)) == 1
playlist.mark_open()
assert len(Playlists.get_open(session)) == 1
assert len(Playlists.get_closed(session)) == 0
playlist.close()
assert len(Playlists.get_open(session)) == 0
assert len(Playlists.get_closed(session)) == 1
def test_playlist_get_all_and_by_id(session):
# We need two playlists
p1_name = "playlist one"
p2_name = "playlist two"
playlist1 = Playlists(session, p1_name)
_ = Playlists(session, p2_name)
all_playlists = Playlists.get_all(session)
assert len(all_playlists) == 2
assert p1_name in [p.name for p in all_playlists]
assert p2_name in [p.name for p in all_playlists]
assert session.get(Playlists, playlist1.id).name == p1_name
def test_tracks_get_all_tracks(session, track1, track2):
# Need two tracks
result = [a.path for a in Tracks.get_all(session)]
assert track1.path in result
assert track2.path in result
def test_tracks_by_path(session, track1):
assert Tracks.get_by_path(session, track1.path) is track1
def test_tracks_by_id(session, track1):
assert session.get(Tracks, track1.id) is track1
def test_tracks_search_artists(session, track1):
track1_artist = "Fleetwood Mac"
assert len(Tracks.search_artists(session, track1_artist)) == 1
def test_tracks_search_titles(session, track1):
track1_title = "I'm So Afraid"
assert len(Tracks.search_titles(session, track1_title)) == 1

View File

@ -1,380 +0,0 @@
from pprint import pprint
from typing import Optional
from app.models import (
Playlists,
Tracks,
)
from PyQt6.QtCore import Qt, QModelIndex
from app.helpers import get_file_metadata
from app import playlistmodel
from dbconfig import scoped_session
test_tracks = [
"testdata/isa.mp3",
"testdata/isa_with_gap.mp3",
"testdata/loser.mp3",
"testdata/lovecats-10seconds.mp3",
"testdata/lovecats.mp3",
"testdata/mom.mp3",
"testdata/sitting.mp3",
]
def create_model_with_tracks(session: scoped_session, name: Optional[str] = None) -> "playlistmodel.PlaylistModel":
playlist = Playlists(session, name or "test playlist")
model = playlistmodel.PlaylistModel(playlist.id)
for row in range(len(test_tracks)):
track_path = test_tracks[row % len(test_tracks)]
metadata = get_file_metadata(track_path)
track = Tracks(session, **metadata)
model.insert_row(proposed_row_number=row, track_id=track.id, note=f"{row=}")
session.commit()
return model
def create_model_with_playlist_rows(
session: scoped_session, rows: int, name: Optional[str] = None
) -> "playlistmodel.PlaylistModel":
playlist = Playlists(session, name or "test playlist")
# Create a model
model = playlistmodel.PlaylistModel(playlist.id)
for row in range(rows):
model.insert_row(proposed_row_number=row, note=str(row))
session.commit()
return model
def test_11_row_playlist(monkeypatch, session):
# Create multirow playlist
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_playlist_rows(session, 11)
assert model.rowCount() == 11
assert max(model.playlist_rows.keys()) == 10
for row in range(model.rowCount()):
assert row in model.playlist_rows
assert model.playlist_rows[row].plr_rownum == row
def test_move_rows_test2(monkeypatch, session):
# move row 3 to row 5
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_playlist_rows(session, 11)
model.move_rows([3], 5)
# Check we have all rows and plr_rownums are correct
for row in range(model.rowCount()):
assert row in model.playlist_rows
assert model.playlist_rows[row].plr_rownum == row
if row not in [3, 4, 5]:
assert model.playlist_rows[row].note == str(row)
elif row == 3:
assert model.playlist_rows[row].note == str(4)
elif row == 4:
assert model.playlist_rows[row].note == str(5)
elif row == 5:
assert model.playlist_rows[row].note == str(3)
def test_move_rows_test3(monkeypatch, session):
# move row 4 to row 3
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_playlist_rows(session, 11)
model.move_rows([4], 3)
# Check we have all rows and plr_rownums are correct
for row in range(model.rowCount()):
assert row in model.playlist_rows
assert model.playlist_rows[row].plr_rownum == row
if row not in [3, 4]:
assert model.playlist_rows[row].note == str(row)
elif row == 3:
assert model.playlist_rows[row].note == str(4)
elif row == 4:
assert model.playlist_rows[row].note == str(3)
def test_move_rows_test4(monkeypatch, session):
# move row 4 to row 2
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_playlist_rows(session, 11)
model.move_rows([4], 2)
# Check we have all rows and plr_rownums are correct
for row in range(model.rowCount()):
assert row in model.playlist_rows
assert model.playlist_rows[row].plr_rownum == row
if row not in [2, 3, 4]:
assert model.playlist_rows[row].note == str(row)
elif row == 2:
assert model.playlist_rows[row].note == str(4)
elif row == 3:
assert model.playlist_rows[row].note == str(2)
elif row == 4:
assert model.playlist_rows[row].note == str(3)
def test_move_rows_test5(monkeypatch, session):
# move rows [1, 4, 5, 10] → 8
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_playlist_rows(session, 11)
model.move_rows([1, 4, 5, 10], 8)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in range(model.rowCount()):
assert row in model.playlist_rows
assert model.playlist_rows[row].plr_rownum == row
new_order.append(int(model.playlist_rows[row].note))
assert new_order == [0, 2, 3, 6, 7, 8, 9, 1, 4, 5, 10]
def test_move_rows_test6(monkeypatch, session):
# move rows [3, 6] → 5
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_playlist_rows(session, 11)
model.move_rows([3, 6], 5)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in range(model.rowCount()):
assert row in model.playlist_rows
assert model.playlist_rows[row].plr_rownum == row
new_order.append(int(model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 4, 5, 3, 6, 7, 8, 9, 10]
def test_move_rows_test7(monkeypatch, session):
# move rows [3, 5, 6] → 8
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_playlist_rows(session, 11)
model.move_rows([3, 5, 6], 8)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in range(model.rowCount()):
assert row in model.playlist_rows
assert model.playlist_rows[row].plr_rownum == row
new_order.append(int(model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 4, 7, 8, 9, 10, 3, 5, 6]
def test_move_rows_test8(monkeypatch, session):
# move rows [7, 8, 10] → 5
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_playlist_rows(session, 11)
model.move_rows([7, 8, 10], 5)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in range(model.rowCount()):
assert row in model.playlist_rows
assert model.playlist_rows[row].plr_rownum == row
new_order.append(int(model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9]
def test_insert_header_row_end(monkeypatch, session):
# insert header row at end of playlist
monkeypatch.setattr(playlistmodel, "Session", session)
note_text = "test text"
initial_row_count = 11
model = create_model_with_playlist_rows(session, initial_row_count)
model.insert_row(proposed_row_number=None, note=note_text)
assert model.rowCount() == initial_row_count + 1
prd = model.playlist_rows[model.rowCount() - 1]
# Test against edit_role because display_role for headers is
# handled differently (sets up row span)
assert (
model.edit_role(model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd)
== note_text
)
def test_insert_header_row_middle(monkeypatch, session):
# insert header row in middle of playlist
monkeypatch.setattr(playlistmodel, "Session", session)
note_text = "test text"
initial_row_count = 11
insert_row = 6
model = create_model_with_playlist_rows(session, initial_row_count)
model.insert_row(proposed_row_number=insert_row, note=note_text)
assert model.rowCount() == initial_row_count + 1
prd = model.playlist_rows[insert_row]
# Test against edit_role because display_role for headers is
# handled differently (sets up row span)
assert (
model.edit_role(model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd)
== note_text
)
def test_create_model_with_tracks(monkeypatch, session):
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_tracks(session)
assert len(model.playlist_rows) == len(test_tracks)
def test_timing_one_track(monkeypatch, session):
START_ROW = 0
END_ROW = 2
monkeypatch.setattr(playlistmodel, "Session", session)
model = create_model_with_tracks(session)
model.insert_row(proposed_row_number=START_ROW, note="start+")
model.insert_row(proposed_row_number=END_ROW, note="-")
prd = model.playlist_rows[START_ROW]
qv_value = model.display_role(START_ROW, playlistmodel.HEADER_NOTES_COLUMN, prd)
assert qv_value.value() == "start [1 tracks, 4:23 unplayed]"
def test_insert_track_new_playlist(monkeypatch, session):
# insert a track into a new playlist
monkeypatch.setattr(playlistmodel, "Session", session)
playlist = Playlists(session, "test playlist")
# Create a model
model = playlistmodel.PlaylistModel(playlist.id)
track_path = test_tracks[0]
metadata = get_file_metadata(track_path)
track = Tracks(session, **metadata)
model.insert_row(proposed_row_number=0, track_id=track.id)
prd = model.playlist_rows[model.rowCount() - 1]
assert (
model.edit_role(model.rowCount() - 1, playlistmodel.Col.TITLE.value, prd)
== metadata["title"]
)
def test_reverse_row_groups_one_row(monkeypatch, session):
monkeypatch.setattr(playlistmodel, "Session", session)
rows_to_move = [3]
model_src = create_model_with_playlist_rows(session, 5, name="source")
result = model_src._reversed_contiguous_row_groups(rows_to_move)
assert len(result) == 1
assert result[0] == [3]
def test_reverse_row_groups_multiple_row(monkeypatch, session):
monkeypatch.setattr(playlistmodel, "Session", session)
rows_to_move = [2, 3, 4, 5, 7, 9, 10, 13, 17, 20, 21]
model_src = create_model_with_playlist_rows(session, 5, name="source")
result = model_src._reversed_contiguous_row_groups(rows_to_move)
assert result == [[20, 21], [17], [13], [9, 10], [7], [2, 3, 4, 5]]
def test_move_one_row_between_playlists_to_end(monkeypatch, session):
monkeypatch.setattr(playlistmodel, "Session", session)
create_rowcount = 5
from_rows = [3]
to_row = create_rowcount
model_src = create_model_with_playlist_rows(session, create_rowcount, name="source")
model_dst = create_model_with_playlist_rows(session, create_rowcount, name="destination")
model_src.move_rows_between_playlists(from_rows, to_row, model_dst)
model_dst.refresh_data(session)
assert len(model_src.playlist_rows) == create_rowcount - len(from_rows)
assert len(model_dst.playlist_rows) == create_rowcount + len(from_rows)
assert sorted([a.plr_rownum for a in model_src.playlist_rows.values()]) == list(
range(len(model_src.playlist_rows))
)
def test_move_one_row_between_playlists_to_middle(monkeypatch, session):
monkeypatch.setattr(playlistmodel, "Session", session)
create_rowcount = 5
from_rows = [3]
to_row = 2
model_src = create_model_with_playlist_rows(session, create_rowcount, name="source")
model_dst = create_model_with_playlist_rows(session, create_rowcount, name="destination")
model_src.move_rows_between_playlists(from_rows, to_row, model_dst)
model_dst.refresh_data(session)
# Check the rows of the destination model
row_notes = []
for row_number in range(model_dst.rowCount()):
index = model_dst.index(row_number, playlistmodel.Col.TITLE.value, QModelIndex())
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole).value())
assert len(model_src.playlist_rows) == create_rowcount - len(from_rows)
assert len(model_dst.playlist_rows) == create_rowcount + len(from_rows)
assert [int(a) for a in row_notes] == [0, 1, 3, 2, 3, 4]
def test_move_multiple_rows_between_playlists_to_end(monkeypatch, session):
monkeypatch.setattr(playlistmodel, "Session", session)
create_rowcount = 5
from_rows = [1, 3, 4]
to_row = 2
model_src = create_model_with_playlist_rows(session, create_rowcount, name="source")
model_dst = create_model_with_playlist_rows(session, create_rowcount, name="destination")
model_src.move_rows_between_playlists(from_rows, to_row, model_dst)
model_dst.refresh_data(session)
# Check the rows of the destination model
row_notes = []
for row_number in range(model_dst.rowCount()):
index = model_dst.index(row_number, playlistmodel.Col.TITLE.value, QModelIndex())
row_notes.append(model_dst.data(index, Qt.ItemDataRole.EditRole).value())
assert len(model_src.playlist_rows) == create_rowcount - len(from_rows)
assert len(model_dst.playlist_rows) == create_rowcount + len(from_rows)
assert [int(a) for a in row_notes] == [0, 1, 3, 4, 1, 2, 3, 4]
# def test_edit_header(monkeypatch, session): # edit header row in middle of playlist
# monkeypatch.setattr(playlistmodel, "Session", session)
# note_text = "test text"
# initial_row_count = 11
# insert_row = 6
# model = create_model_with_playlist_rows(session, initial_row_count)
# model.insert_header_row(insert_row, note_text)
# assert model.rowCount() == initial_row_count + 1
# prd = model.playlist_rows[insert_row]
# # Test against edit_role because display_role for headers is
# # handled differently (sets up row span)
# assert (
# model.edit_role(model.rowCount(), playlistmodel.Col.NOTE.value, prd)
# == note_text
# )

101
tests/test_helpers.py Normal file
View File

@ -0,0 +1,101 @@
# Standard library imports
import datetime as dt
import shutil
import tempfile
import unittest
# PyQt imports
# Third party imports
# App imports
from helpers import (
fade_point,
get_audio_segment,
get_tags,
get_relative_date,
leading_silence,
ms_to_mmss,
normalise_track,
)
class TestMMHelpers(unittest.TestCase):
def setUp(self):
pass
def tearDown(self):
pass
def test_fade_point(self):
test_track_path = "testdata/isa.mp3"
test_track_data = "testdata/isa.py"
audio_segment = get_audio_segment(test_track_path)
assert audio_segment
fade_at = fade_point(audio_segment)
# Get test data
with open(test_track_data) as f:
testdata = eval(f.read())
# Volume detection can vary, so ± 1 second is OK
assert fade_at < testdata["fade_at"] + 1000
assert fade_at > testdata["fade_at"] - 1000
def test_get_tags(self):
test_track_path = "testdata/mom.mp3"
test_track_data = "testdata/mom.py"
tags = get_tags(test_track_path)
# Get test data
with open(test_track_data) as f:
testdata = eval(f.read())
assert tags["artist"] == testdata["artist"]
assert tags["title"] == testdata["title"]
def test_get_relative_date(self):
assert get_relative_date(None) == "Never"
today_at_10 = dt.datetime.now().replace(hour=10, minute=0)
today_at_11 = dt.datetime.now().replace(hour=11, minute=0)
assert get_relative_date(today_at_10, today_at_11) == "Today 10:00"
eight_days_ago = today_at_10 - dt.timedelta(days=8)
assert get_relative_date(eight_days_ago, today_at_11) == "1 week, 1 day ago"
sixteen_days_ago = today_at_10 - dt.timedelta(days=16)
assert get_relative_date(sixteen_days_ago, today_at_11) == "2 weeks, 2 days ago"
def test_leading_silence(self):
test_track_path = "testdata/isa.mp3"
test_track_data = "testdata/isa.py"
audio_segment = get_audio_segment(test_track_path)
assert audio_segment
silence_at = leading_silence(audio_segment)
# Get test data
with open(test_track_data) as f:
testdata = eval(f.read())
# Volume detection can vary, so ± 1 second is OK
assert silence_at < testdata["leading_silence"] + 1000
assert silence_at > testdata["leading_silence"] - 1000
def test_ms_to_mmss(self):
assert ms_to_mmss(None) == "-"
assert ms_to_mmss(59600) == "0:59"
assert ms_to_mmss((5 * 60 * 1000) + 23000) == "5:23"
def test_normalise(self):
"""Make copies to normalise to avoid corrupting source"""
_, mp3_temp_path = tempfile.mkstemp(suffix=".mp3")
shutil.copyfile("testdata/isa.mp3", mp3_temp_path)
normalise_track(mp3_temp_path)
_, flac_temp_path = tempfile.mkstemp(suffix=".flac")
shutil.copyfile("testdata/isa.flac", flac_temp_path)
normalise_track(flac_temp_path)

50
tests/test_misc.py Normal file
View File

@ -0,0 +1,50 @@
# Standard library imports
import os
import unittest
# PyQt imports
# Third party imports
import pytest
# App imports
# Set up test database before importing db
# Mark subsequent lines to ignore E402, imports not at top of file
# Set up test database before importing db
# Mark subsequent lines to ignore E402, imports not at top of file
DB_FILE = "/tmp/mm.db"
if os.path.exists(DB_FILE):
os.unlink(DB_FILE)
os.environ["ALCHEMICAL_DATABASE_URI"] = "sqlite:///" + DB_FILE
from models import db, Settings # noqa: E402
class TestMMMisc(unittest.TestCase):
def setUp(self):
db.create_all()
def tearDown(self):
db.drop_all()
def test_log_exception(self):
"""Test deliberate exception"""
with pytest.raises(Exception):
1 / 0
def test_create_settings(self):
SETTING_NAME = "wombat"
NO_SUCH_SETTING = "abc"
VALUE = 3
with db.Session() as session:
setting = Settings(session, SETTING_NAME)
# test repr
_ = str(setting)
setting.update(session, dict(f_int=VALUE))
_ = Settings.all_as_dict(session)
test = Settings.get_int_settings(session, SETTING_NAME)
assert test.name == SETTING_NAME
assert test.f_int == VALUE
test_new = Settings.get_int_settings(session, NO_SUCH_SETTING)
assert test_new.name == NO_SUCH_SETTING

304
tests/test_models.py Normal file
View File

@ -0,0 +1,304 @@
# Standard library imports
import datetime as dt
import os
import unittest
# PyQt imports
# Third party imports
# App imports
from app import helpers
# Set up test database before importing db
# Mark subsequent lines to ignore E402, imports not at top of file
DB_FILE = "/tmp/mm.db"
if os.path.exists(DB_FILE):
os.unlink(DB_FILE)
os.environ["ALCHEMICAL_DATABASE_URI"] = "sqlite:///" + DB_FILE
from app.models import ( # noqa: E402
db,
Carts,
NoteColours,
Playdates,
Playlists,
PlaylistRows,
Tracks,
)
class TestMMModels(unittest.TestCase):
def setUp(self):
db.create_all()
with db.Session() as session:
track1_path = "testdata/isa.mp3"
metadata1 = helpers.get_file_metadata(track1_path)
self.track1 = Tracks(session, **metadata1)
track2_path = "testdata/mom.mp3"
metadata2 = helpers.get_file_metadata(track2_path)
self.track2 = Tracks(session, **metadata2)
def tearDown(self):
db.drop_all()
def test_track_repr(self):
with db.Session() as session:
session.add(self.track1)
_ =str(self.track1)
def test_notecolours_get_colour(self):
"""Create a colour record and retrieve all colours"""
note_colour = "#0bcdef"
with db.Session() as session:
NoteColours(session, substring="substring", colour=note_colour)
records = NoteColours.get_all(session)
assert len(records) == 1
record = records[0]
assert record.colour == note_colour
def test_notecolours_get_all(self):
"""Create two colour records and retrieve them all"""
note1_colour = "#1bcdef"
note2_colour = "#20ff00"
with db.Session() as session:
NoteColours(session, substring="note1", colour=note1_colour)
NoteColours(session, substring="note2", colour=note2_colour)
records = NoteColours.get_all(session)
assert len(records) == 2
assert note1_colour in [n.colour for n in records]
assert note2_colour in [n.colour for n in records]
def test_notecolours_get_colour_none(self):
note_colour = "#3bcdef"
with db.Session() as session:
NoteColours(session, substring="substring", colour=note_colour)
result = NoteColours.get_colour(session, "xyz")
assert result is None
def test_notecolours_get_colour_match(self):
note_colour = "#4bcdef"
with db.Session() as session:
nc = NoteColours(session, substring="sub", colour=note_colour)
assert nc
result = NoteColours.get_colour(session, "The substring")
assert result == note_colour
def test_playdates_add_playdate(self):
"""Test playdate and last_played retrieval"""
with db.Session() as session:
session.add(self.track1)
playdate = Playdates(session, self.track1.id)
assert playdate
# test repr
_ = str(playdate)
last_played = Playdates.last_played(session, self.track1.id)
assert abs((playdate.lastplayed - last_played).total_seconds()) < 2
def test_playdates_played_after(self):
with db.Session() as session:
session.add(self.track1)
playdate = Playdates(session, self.track1.id)
yesterday = dt.datetime.now() - dt.timedelta(days=1)
played = Playdates.played_after(session, yesterday)
assert len(played) == 1
assert played[0] == playdate
def test_playlist_create(self):
TEMPLATE_NAME = "my template"
with db.Session() as session:
playlist = Playlists(session, "my playlist")
assert playlist
# test repr
_ = str(playlist)
# test clear tabs
Playlists.clear_tabs(session, [playlist.id])
# create template
Playlists.save_as_template(session, playlist.id, TEMPLATE_NAME)
# test create template
_ = Playlists.create_playlist_from_template(
session, playlist, "my new name"
)
# get all templates
all_templates = Playlists.get_all_templates(session)
assert len(all_templates) == 1
# Save as template creates new playlist
assert all_templates[0] != playlist
# test delete playlist
playlist.delete(session)
def test_playlist_open_and_close(self):
# We need a playlist
with db.Session() as session:
playlist = Playlists(session, "my playlist")
assert len(Playlists.get_open(session)) == 0
assert len(Playlists.get_closed(session)) == 1
playlist.mark_open()
assert len(Playlists.get_open(session)) == 1
assert len(Playlists.get_closed(session)) == 0
playlist.close()
assert len(Playlists.get_open(session)) == 0
assert len(Playlists.get_closed(session)) == 1
def test_playlist_get_all_and_by_id(self):
# We need two playlists
p1_name = "playlist one"
p2_name = "playlist two"
with db.Session() as session:
playlist1 = Playlists(session, p1_name)
_ = Playlists(session, p2_name)
all_playlists = Playlists.get_all(session)
assert len(all_playlists) == 2
assert p1_name in [p.name for p in all_playlists]
assert p2_name in [p.name for p in all_playlists]
assert session.get(Playlists, playlist1.id).name == p1_name
def test_tracks_get_all_tracks(self):
# Need two tracks
with db.Session() as session:
session.add(self.track1)
session.add(self.track2)
result = [a.path for a in Tracks.get_all(session)]
assert self.track1.path in result
assert self.track2.path in result
def test_tracks_by_path(self):
with db.Session() as session:
session.add(self.track1)
assert Tracks.get_by_path(session, self.track1.path) is self.track1
def test_tracks_by_id(self):
with db.Session() as session:
session.add(self.track1)
assert session.get(Tracks, self.track1.id) is self.track1
def test_tracks_search_artists(self):
track1_artist = "Fleetwood Mac"
with db.Session() as session:
session.add(self.track1)
assert len(Tracks.search_artists(session, track1_artist)) == 1
def test_tracks_search_titles(self):
track1_title = "I'm So Afraid"
with db.Session() as session:
session.add(self.track1)
assert len(Tracks.search_titles(session, track1_title)) == 1
def test_repr(self):
"""Just check for error retrieving reprs"""
with db.Session() as session:
nc = NoteColours(session, substring="x", colour="x")
_ = str(nc)
def test_get_colour(self):
"""Test for errors in execution"""
GOOD_STRING = "cantelope"
BAD_STRING = "ericTheBee"
SUBSTR = "ant"
COLOUR = "blue"
with db.Session() as session:
nc1 = NoteColours(
session, substring=SUBSTR, colour=COLOUR, is_casesensitive=True
)
_ = nc1.get_colour(session, "")
colour = nc1.get_colour(session, GOOD_STRING)
assert colour == COLOUR
colour = nc1.get_colour(session, BAD_STRING)
assert colour is None
nc2 = NoteColours(
session, substring=".*" + SUBSTR, colour=COLOUR, is_regex=True
)
colour = nc2.get_colour(session, GOOD_STRING)
assert colour == COLOUR
colour = nc2.get_colour(session, BAD_STRING)
assert colour is None
nc3 = NoteColours(
session,
substring=".*" + SUBSTR,
colour=COLOUR,
is_regex=True,
is_casesensitive=True,
)
colour = nc3.get_colour(session, GOOD_STRING)
assert colour == COLOUR
colour = nc3.get_colour(session, BAD_STRING)
assert colour is None
def test_create_cart(self):
with db.Session() as session:
cart = Carts(session, 1, "name")
assert cart
_ = str(cart)
def test_name_available(self):
PLAYLIST_NAME = "a name"
RENAME = "new name"
with db.Session() as session:
if Playlists.name_is_available(session, PLAYLIST_NAME):
playlist = Playlists(session, PLAYLIST_NAME)
assert playlist
assert Playlists.name_is_available(session, PLAYLIST_NAME) is False
playlist.rename(session, RENAME)
def test_create_playlist_row(self):
PLAYLIST_NAME = "a name"
with db.Session() as session:
if Playlists.name_is_available(session, PLAYLIST_NAME):
playlist = Playlists(session, PLAYLIST_NAME)
plr = PlaylistRows(session, playlist.id, 1)
assert plr
_ = str(plr)
plr.append_note("a note")
plr.append_note("another note")
def test_delete_plr(self):
PLAYLIST_NAME = "a name"
with db.Session() as session:
if Playlists.name_is_available(session, PLAYLIST_NAME):
playlist = Playlists(session, PLAYLIST_NAME)
plr = PlaylistRows(session, playlist.id, 1)
assert plr
PlaylistRows.delete_higher_rows(session, plr.playlist_id, 10)
assert PlaylistRows.get_track_plr(session, 12, plr.playlist_id) is None

404
tests/test_playlistmodel.py Normal file
View File

@ -0,0 +1,404 @@
# Standard library imports
import os
import unittest
from typing import Optional
# PyQt imports
from PyQt6.QtCore import Qt, QModelIndex
# Third party imports
from sqlalchemy.orm.session import Session
# App imports
from app.log import log
from app.helpers import get_file_metadata
# Set up test database before importing db
# Mark subsequent lines to ignore E402, imports not at top of file
DB_FILE = "/tmp/mm.db"
if os.path.exists(DB_FILE):
os.unlink(DB_FILE)
os.environ["ALCHEMICAL_DATABASE_URI"] = "sqlite:///" + DB_FILE
from app import playlistmodel # noqa: E402
from app.models import ( # noqa: E402
db,
Playlists,
Settings,
Tracks,
)
class TestMMMiscTracks(unittest.TestCase):
def setUp(self):
PLAYLIST_NAME = "tracks playlist"
self.test_tracks = [
"testdata/isa.mp3",
"testdata/isa_with_gap.mp3",
"testdata/loser.mp3",
"testdata/lovecats-10seconds.mp3",
"testdata/lovecats.mp3",
"testdata/mom.mp3",
"testdata/sitting.mp3",
"testdata/wrb.flac",
]
db.create_all()
# Create a playlist and model
with db.Session() as session:
self.playlist = Playlists(session, PLAYLIST_NAME)
self.model = playlistmodel.PlaylistModel(self.playlist.id)
for row in range(len(self.test_tracks)):
track_path = self.test_tracks[row % len(self.test_tracks)]
metadata = get_file_metadata(track_path)
track = Tracks(session, **metadata)
self.model.insert_row(
proposed_row_number=row, track_id=track.id, note=f"{row=}"
)
session.commit()
def tearDown(self):
db.drop_all()
def test_8_row_playlist(self):
# Test auto-created playlist
assert self.model.rowCount() == 8
assert max(self.model.playlist_rows.keys()) == 7
for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row
def test_timing_one_track(self):
START_ROW = 0
END_ROW = 2
self.model.insert_row(proposed_row_number=START_ROW, note="start+")
self.model.insert_row(proposed_row_number=END_ROW, note="-")
prd = self.model.playlist_rows[START_ROW]
qv_value = self.model.display_role(START_ROW, playlistmodel.HEADER_NOTES_COLUMN, prd)
assert qv_value.value() == "start [1 tracks, 4:23 unplayed]"
class TestMMMiscNoPlaylist(unittest.TestCase):
PLAYLIST_NAME = "tracks playlist"
test_tracks = [
"testdata/isa.mp3",
"testdata/isa_with_gap.mp3",
"testdata/loser.mp3",
"testdata/lovecats-10seconds.mp3",
"testdata/lovecats.mp3",
"testdata/mom.mp3",
"testdata/sitting.mp3",
]
def setUp(self):
db.create_all()
def tearDown(self):
db.drop_all()
def test_insert_track_new_playlist(self):
# insert a track into a new playlist
with db.Session() as session:
playlist = Playlists(session, self.PLAYLIST_NAME)
# Create a model
model = playlistmodel.PlaylistModel(playlist.id)
# test repr
_ = str(model)
track_path = self.test_tracks[0]
metadata = get_file_metadata(track_path)
track = Tracks(session, **metadata)
model.insert_row(proposed_row_number=0, track_id=track.id)
prd = model.playlist_rows[model.rowCount() - 1]
# test repr
_ = str(prd)
assert (
model.edit_role(model.rowCount() - 1, playlistmodel.Col.TITLE.value, prd)
== metadata["title"]
)
class TestMMMiscRowMove(unittest.TestCase):
PLAYLIST_NAME = "rowmove playlist"
ROWS_TO_CREATE = 11
def setUp(self):
db.create_all()
with db.Session() as session:
self.playlist = Playlists(session, self.PLAYLIST_NAME)
self.model = playlistmodel.PlaylistModel(self.playlist.id)
for row in range(self.ROWS_TO_CREATE):
self.model.insert_row(proposed_row_number=row, note=str(row))
session.commit()
def tearDown(self):
db.drop_all()
def test_move_rows_test2(self):
# move row 3 to row 5
self.model.move_rows([3], 5)
# Check we have all rows and plr_rownums are correct
for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row
if row not in [3, 4, 5]:
assert self.model.playlist_rows[row].note == str(row)
elif row == 3:
assert self.model.playlist_rows[row].note == str(4)
elif row == 4:
assert self.model.playlist_rows[row].note == str(5)
elif row == 5:
assert self.model.playlist_rows[row].note == str(3)
def test_move_rows_test3(self):
# move row 4 to row 3
self.model.move_rows([4], 3)
# Check we have all rows and plr_rownums are correct
for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row
if row not in [3, 4]:
assert self.model.playlist_rows[row].note == str(row)
elif row == 3:
assert self.model.playlist_rows[row].note == str(4)
elif row == 4:
assert self.model.playlist_rows[row].note == str(3)
def test_move_rows_test4(self):
# move row 4 to row 2
self.model.move_rows([4], 2)
# Check we have all rows and plr_rownums are correct
for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row
if row not in [2, 3, 4]:
assert self.model.playlist_rows[row].note == str(row)
elif row == 2:
assert self.model.playlist_rows[row].note == str(4)
elif row == 3:
assert self.model.playlist_rows[row].note == str(2)
elif row == 4:
assert self.model.playlist_rows[row].note == str(3)
def test_move_rows_test5(self):
# move rows [1, 4, 5, 10] → 8
self.model.move_rows([1, 4, 5, 10], 8)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row
new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 2, 3, 6, 7, 8, 9, 1, 4, 5, 10]
def test_move_rows_test6(self):
# move rows [3, 6] → 5
self.model.move_rows([3, 6], 5)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row
new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 4, 5, 3, 6, 7, 8, 9, 10]
def test_move_rows_test7(self):
# move rows [3, 5, 6] → 8
self.model.move_rows([3, 5, 6], 8)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row
new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 4, 7, 8, 9, 10, 3, 5, 6]
def test_move_rows_test8(self):
# move rows [7, 8, 10] → 5
self.model.move_rows([7, 8, 10], 5)
# Check we have all rows and plr_rownums are correct
new_order = []
for row in range(self.model.rowCount()):
assert row in self.model.playlist_rows
assert self.model.playlist_rows[row].plr_rownum == row
new_order.append(int(self.model.playlist_rows[row].note))
assert new_order == [0, 1, 2, 3, 4, 7, 8, 10, 5, 6, 9]
def test_insert_header_row_end(self):
# insert header row at end of playlist
note_text = "test text"
assert self.model.rowCount() == self.ROWS_TO_CREATE
self.model.insert_row(proposed_row_number=None, note=note_text)
assert self.model.rowCount() == self.ROWS_TO_CREATE + 1
prd = self.model.playlist_rows[self.model.rowCount() - 1]
# Test against edit_role because display_role for headers is
# handled differently (sets up row span)
assert (
self.model.edit_role(
self.model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd
)
== note_text
)
def test_insert_header_row_middle(self):
# insert header row in middle of playlist
note_text = "test text"
insert_row = 6
self.model.insert_row(proposed_row_number=insert_row, note=note_text)
assert self.model.rowCount() == self.ROWS_TO_CREATE + 1
prd = self.model.playlist_rows[insert_row]
# Test against edit_role because display_role for headers is
# handled differently (sets up row span)
assert (
self.model.edit_role(
self.model.rowCount() - 1, playlistmodel.Col.NOTE.value, prd
)
== note_text
)
def test_add_track_to_header(self):
note_text = "test text"
insert_row = 6
self.model.insert_row(proposed_row_number=insert_row, note=note_text)
assert self.model.rowCount() == self.ROWS_TO_CREATE + 1
prd = self.model.playlist_rows[1]
self.model.add_track_to_header(insert_row, prd.track_id)
def test_reverse_row_groups_one_row(self):
rows_to_move = [3]
result = self.model._reversed_contiguous_row_groups(rows_to_move)
assert len(result) == 1
assert result[0] == [3]
def test_reverse_row_groups_multiple_row(self):
rows_to_move = [2, 3, 4, 5, 7, 9, 10, 13, 17, 20, 21]
result = self.model._reversed_contiguous_row_groups(rows_to_move)
assert result == [[20, 21], [17], [13], [9, 10], [7], [2, 3, 4, 5]]
def test_move_one_row_between_playlists_to_end(self):
from_rows = [3]
to_row = self.ROWS_TO_CREATE
destination_playlist = "destination"
model_src = self.model
with db.Session() as session:
playlist_dst = Playlists(session, destination_playlist)
model_dst = playlistmodel.PlaylistModel(playlist_dst.id)
for row in range(self.ROWS_TO_CREATE):
model_dst.insert_row(proposed_row_number=row, note=str(row))
model_src.move_rows_between_playlists(from_rows, to_row, model_dst.playlist_id)
model_dst.refresh_data(session)
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)
assert sorted([a.plr_rownum for a in model_src.playlist_rows.values()]) == list(
range(len(model_src.playlist_rows))
)
def test_move_one_row_between_playlists_to_middle(self):
from_rows = [3]
to_row = 2
destination_playlist = "destination"
model_src = self.model
with db.Session() as session:
playlist_dst = Playlists(session, destination_playlist)
model_dst = playlistmodel.PlaylistModel(playlist_dst.id)
for row in range(self.ROWS_TO_CREATE):
model_dst.insert_row(proposed_row_number=row, note=str(row))
model_src.move_rows_between_playlists(from_rows, to_row, model_dst.playlist_id)
model_dst.refresh_data(session)
# Check the rows of the destination model
row_notes = []
for row_number in range(model_dst.rowCount()):
index = model_dst.index(
row_number, playlistmodel.Col.TITLE.value, QModelIndex()
)
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)
assert [int(a) for a in row_notes] == [0, 1, 3, 2, 3, 4, 5, 6, 7, 8, 9, 10]
def test_move_multiple_rows_between_playlists_to_end(self):
from_rows = [1, 3, 4]
to_row = 2
destination_playlist = "destination"
model_src = self.model
with db.Session() as session:
playlist_dst = Playlists(session, destination_playlist)
model_dst = playlistmodel.PlaylistModel(playlist_dst.id)
for row in range(self.ROWS_TO_CREATE):
model_dst.insert_row(proposed_row_number=row, note=str(row))
model_src.move_rows_between_playlists(from_rows, to_row, model_dst.playlist_id)
model_dst.refresh_data(session)
# Check the rows of the destination model
row_notes = []
for row_number in range(model_dst.rowCount()):
index = model_dst.index(
row_number, playlistmodel.Col.TITLE.value, QModelIndex()
)
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)
assert [int(a) for a in row_notes] == [0, 1, 3, 4, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# # def test_edit_header(monkeypatch, session): # edit header row in middle of playlist
# # monkeypatch.setattr(playlistmodel, "Session", session)
# # note_text = "test text"
# # initial_row_count = 11
# # insert_row = 6
# # model = create_model_with_playlist_rows(session, initial_row_count)
# # model.insert_header_row(insert_row, note_text)
# # assert model.rowCount() == initial_row_count + 1
# # prd = model.playlist_rows[insert_row]
# # # Test against edit_role because display_role for headers is
# # # handled differently (sets up row span)
# # assert (
# # model.edit_role(model.rowCount(), playlistmodel.Col.NOTE.value, prd)
# # == note_text
# # )