Compare commits

...

17 Commits

Author SHA1 Message Date
Keith Edmunds
4e73ea6e6a Black formatting 2025-02-05 17:46:16 +00:00
Keith Edmunds
c9b45848dd Refine and fix file_importer tests 2025-02-05 17:43:38 +00:00
Keith Edmunds
fd0d8b15f7 Poetry only for dependency management 2025-02-02 17:54:44 +00:00
Keith Edmunds
7d0e1c809f Update environment 2025-02-02 17:52:15 +00:00
Keith Edmunds
5cae8e4b19 File importer - more tests 2025-02-01 22:11:01 +00:00
Keith Edmunds
8177e03387 Tweak pyproject.toml for v2 2025-01-31 10:00:55 +00:00
Keith Edmunds
f4923314d8 Remove spurious logging. Start 10ms timer at a better time.
The 10ms timer was paused for five seconds when starting a track to
avoid a short pause (issue #223). That fixed the problem. However, it
doesn't need to be started until the fade graph starts changing, so we
now don't start it until then. It's possible that this may help the
occasional 'slow to refresh after moving tracks' issue that has been
seen which may be caused by timer ticks piling up and needing to be
serviced.
2025-01-31 09:55:21 +00:00
Keith Edmunds
24787578bc Tweaks to FileImporter and tests 2025-01-31 09:55:21 +00:00
Keith Edmunds
1f4e7cb054 Cleanup around new logging 2025-01-31 09:55:21 +00:00
Keith Edmunds
92e1a1cac8 New FileImporter working, tests to be written 2025-01-31 09:55:21 +00:00
Keith Edmunds
52a773176c Refine module and function logging to stderr 2025-01-31 09:55:21 +00:00
Keith Edmunds
cedc7180d4 WIP: FileImporter runs but needs more testing 2025-01-31 09:55:21 +00:00
Keith Edmunds
728ac0f8dc Add function name to console log output 2025-01-31 09:55:21 +00:00
Keith Edmunds
4741c1d33f Make failure to connect to OBS a warning, not error 2025-01-31 09:55:21 +00:00
Keith Edmunds
aa52f33d58 Fixup new logging 2025-01-31 09:55:21 +00:00
Keith Edmunds
2f18ef5f44 Cascade deleted tracks to playlist_rows and playdates 2025-01-31 09:55:21 +00:00
Keith Edmunds
4927f237ab Use locking when creating singleton 2025-01-31 09:55:21 +00:00
15 changed files with 976 additions and 557 deletions

View File

@ -1,9 +1,10 @@
# Standard library imports
from __future__ import annotations
from dataclasses import dataclass, field
from dataclasses import dataclass
from enum import auto, Enum
import functools
import threading
from typing import NamedTuple
# Third party imports
@ -34,12 +35,18 @@ def singleton(cls):
"""
Make a class a Singleton class (see
https://realpython.com/primer-on-python-decorators/#creating-singletons)
Added locking.
"""
lock = threading.Lock()
@functools.wraps(cls)
def wrapper_singleton(*args, **kwargs):
if not wrapper_singleton.instance:
wrapper_singleton.instance = cls(*args, **kwargs)
if wrapper_singleton.instance is None:
with lock:
if wrapper_singleton.instance is None: # Check still None
wrapper_singleton.instance = cls(*args, **kwargs)
return wrapper_singleton.instance
wrapper_singleton.instance = None

View File

@ -2,7 +2,6 @@
import datetime as dt
import logging
import os
from typing import Optional
# PyQt imports
@ -35,8 +34,6 @@ class Config(object):
COLOUR_UNREADABLE = "#dc3545"
COLOUR_WARNING_TIMER = "#ffc107"
DBFS_SILENCE = -50
DEBUG_FUNCTIONS: list[Optional[str]] = []
DEBUG_MODULES: list[Optional[str]] = []
DEFAULT_COLUMN_WIDTH = 200
DISPLAY_SQL = False
DO_NOT_IMPORT = "Do not import"
@ -83,6 +80,7 @@ class Config(object):
MAIL_USERNAME = os.environ.get("MAIL_USERNAME")
MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") is not None
MAX_IMPORT_MATCHES = 5
MAX_IMPORT_THREADS = 3
MAX_INFO_TABS = 5
MAX_MISSING_FILES_TO_REPORT = 10
MILLISECOND_SIGFIGS = 0
@ -126,5 +124,5 @@ class Config(object):
# These rely on earlier definitions
HIDE_PLAYED_MODE = HIDE_PLAYED_MODE_SECTIONS
IMPORT_DESTINATION = "/tmp/mm" # os.path.join(ROOT, "Singles")
IMPORT_DESTINATION = os.path.join(ROOT, "Singles")
REPLACE_FILES_DEFAULT_DESTINATION = os.path.dirname(REPLACE_FILES_DEFAULT_SOURCE)

View File

@ -147,12 +147,15 @@ class TracksTable(Model):
title: Mapped[str] = mapped_column(String(256), index=True)
playlistrows: Mapped[list[PlaylistRowsTable]] = relationship(
"PlaylistRowsTable", back_populates="track"
"PlaylistRowsTable",
back_populates="track",
cascade="all, delete-orphan",
)
playlists = association_proxy("playlistrows", "playlist")
playdates: Mapped[list[PlaydatesTable]] = relationship(
"PlaydatesTable",
back_populates="track",
cascade="all, delete-orphan",
lazy="joined",
)

View File

@ -3,6 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from fuzzywuzzy import fuzz # type: ignore
import os.path
import threading
from typing import Optional, Sequence
import os
import shutil
@ -10,7 +11,6 @@ import shutil
# PyQt imports
from PyQt6.QtCore import (
pyqtSignal,
QObject,
QThread,
)
from PyQt6.QtWidgets import (
@ -30,6 +30,7 @@ from PyQt6.QtWidgets import (
from classes import (
ApplicationError,
MusicMusterSignals,
singleton,
Tags,
)
from config import Config
@ -53,7 +54,6 @@ class ThreadData:
base_model: PlaylistModel
row_number: int
worker: Optional[DoTrackImport] = None
@dataclass
@ -62,9 +62,10 @@ class TrackFileData:
Data structure to hold details of file to be imported
"""
source_path: str
tags: Tags = Tags()
destination_path: str = ""
import_this_file: bool = True
import_this_file: bool = False
error: str = ""
file_path_to_remove: Optional[str] = None
track_id: int = 0
@ -85,6 +86,7 @@ class TrackMatchData:
track_id: int
@singleton
class FileImporter:
"""
Class to manage the import of new tracks. Sanity checks are carried
@ -97,11 +99,16 @@ class FileImporter:
The actual import is handled by the DoTrackImport class.
"""
# Place to keep a reference to importer workers. This is an instance
# variable to allow tests access. As this is a singleton, a class
# variable or an instance variable are effectively the same thing.
workers: dict[str, DoTrackImport] = {}
def __init__(
self, base_model: PlaylistModel, row_number: Optional[int] = None
) -> None:
"""
Set up class
Initialise the FileImporter singleton instance.
"""
# Create ModelData
@ -109,23 +116,10 @@ class FileImporter:
row_number = base_model.rowCount()
self.model_data = ThreadData(base_model=base_model, row_number=row_number)
# Populate self.import_files_data
for infile in [
os.path.join(Config.REPLACE_FILES_DEFAULT_SOURCE, f)
for f in os.listdir(Config.REPLACE_FILES_DEFAULT_SOURCE)
if f.endswith((".mp3", ".flac"))
]:
self.import_files_data[infile] = TrackFileData()
# Place to keep a reference to importer threads
self.threads: list[QThread] = []
# Data structure to track files to import
self.import_files_data: dict[str, TrackFileData] = {}
# Dictionary of exsting tracks indexed by track.id
self.existing_tracks = self._get_existing_tracks()
self.import_files_data: list[TrackFileData] = []
# Get signals
self.signals = MusicMusterSignals()
def _get_existing_tracks(self) -> Sequence[Tracks]:
@ -136,19 +130,15 @@ class FileImporter:
with db.Session() as session:
return Tracks.get_all(session)
def do_import(self) -> None:
def start(self) -> None:
"""
Populate self.import_files_data, which is a TrackFileData object for each entry.
- Validate files to be imported
- Find matches and similar files
- Get user choices for each import file
- Validate self.import_files_data integrity
- Tell the user which files won't be imported and why
- Import the files, one by one.
Build a TrackFileData object for each new file to import, add it
to self.import_files_data, and trigger importing.
"""
if not self.import_files_data:
new_files: list[str] = []
if not os.listdir(Config.REPLACE_FILES_DEFAULT_SOURCE):
show_OK(
"File import",
f"No files in {Config.REPLACE_FILES_DEFAULT_SOURCE} to import",
@ -156,78 +146,113 @@ class FileImporter:
)
return
for path in self.import_files_data.keys():
self.validate_file(path)
if self.import_files_data[path].import_this_file:
self.find_similar(path)
if len(self.import_files_data[path].track_match_data) > 1:
self.sort_track_match_data(path)
selection = self.get_user_choices(path)
self.process_selection(path, selection)
if self.import_files_data[path].import_this_file:
self.validate_file_data(path)
# Refresh list of existing tracks as they may have been updated
# by previous imports
self.existing_tracks = self._get_existing_tracks()
for infile in [
os.path.join(Config.REPLACE_FILES_DEFAULT_SOURCE, f)
for f in os.listdir(Config.REPLACE_FILES_DEFAULT_SOURCE)
if f.endswith((".mp3", ".flac"))
]:
if infile in [a.source_path for a in self.import_files_data]:
log.debug(f"file_importer.start skipping {infile=}, already queued")
else:
new_files.append(infile)
self.import_files_data.append(self.populate_trackfiledata(infile))
# Tell user which files won't be imported and why
self.inform_user()
# Start the import of all other files
self.import_next_file()
self.inform_user(
[
a
for a in self.import_files_data
if a.source_path in new_files and a.import_this_file is False
]
)
def validate_file(self, path: str) -> None:
# Remove do-not-import entries from queue
self.import_files_data[:] = [
a for a in self.import_files_data if a.import_this_file is not False
]
# Start the import if necessary
log.debug(
f"Import files prepared: {[a.source_path for a in self.import_files_data]}"
)
self._import_next_file()
def populate_trackfiledata(self, path: str) -> TrackFileData:
"""
- check all files are readable
- check all files have tags
- Mark failures not to be imported and populate error text.
Populate TrackFileData object for path:
On return, the following TrackFileData fields should be set:
tags: Yes
destination_path: No
import_this_file: Yes (set by default)
error: No (only set if an error is detected)
file_path_to_remove: No
track_id: No
track_match_data: No
- Validate file to be imported
- Find matches and similar files
- Get user choices for each import file
- Validate self.import_files_data integrity
- Tell the user which files won't be imported and why
- Import the files, one by one.
"""
for path in self.import_files_data.keys():
if file_is_unreadable(path):
self.import_files_data[path].import_this_file = False
self.import_files_data[path].error = f"{path} is unreadable"
continue
tfd = TrackFileData(source_path=path)
try:
self.import_files_data[path].tags = get_tags(path)
except ApplicationError as e:
self.import_files_data[path].import_this_file = False
self.import_files_data[path].error = f"Tag errors ({str(e)})"
continue
if self.check_file_readable(tfd):
if self.check_file_tags(tfd):
self.find_similar(tfd)
if len(tfd.track_match_data) > 1:
self.sort_track_match_data(tfd)
selection = self.get_user_choices(tfd)
if self.process_selection(tfd, selection):
if self.validate_file_data(tfd):
tfd.import_this_file = True
def find_similar(self, path: str) -> None:
return tfd
def check_file_readable(self, tfd: TrackFileData) -> bool:
"""
Check file is readable.
Return True if it is.
Populate error and return False if not.
"""
if file_is_unreadable(tfd.source_path):
tfd.import_this_file = False
tfd.error = f"{tfd.source_path} is unreadable"
return False
return True
def check_file_tags(self, tfd: TrackFileData) -> bool:
"""
Add tags to tfd
Return True if successful.
Populate error and return False if not.
"""
try:
tfd.tags = get_tags(tfd.source_path)
except ApplicationError as e:
tfd.import_this_file = False
tfd.error = f"of tag errors ({str(e)})"
return False
return True
def find_similar(self, tfd: TrackFileData) -> None:
"""
- Search title in existing tracks
- if score >= Config.FUZZYMATCH_MINIMUM_LIST:
- get artist score
- add TrackMatchData to self.import_files_data[path].track_match_data
On return, the following TrackFileData fields should be set:
tags: Yes
destination_path: No
import_this_file: Yes (set by default)
error: No (only set if an error is detected)
file_path_to_remove: No
track_id: No
track_match_data: YES, IN THIS FUNCTION
"""
title = self.import_files_data[path].tags.title
artist = self.import_files_data[path].tags.artist
title = tfd.tags.title
artist = tfd.tags.artist
for existing_track in self.existing_tracks:
title_score = self._get_match_score(title, existing_track.title)
if title_score >= Config.FUZZYMATCH_MINIMUM_LIST:
artist_score = self._get_match_score(artist, existing_track.artist)
self.import_files_data[path].track_match_data.append(
tfd.track_match_data.append(
TrackMatchData(
artist=existing_track.artist,
artist_match=artist_score,
@ -237,14 +262,12 @@ class FileImporter:
)
)
def sort_track_match_data(self, path: str) -> None:
def sort_track_match_data(self, tfd: TrackFileData) -> None:
"""
Sort matched tracks in artist-similarity order
"""
self.import_files_data[path].track_match_data.sort(
key=lambda x: x.artist_match, reverse=True
)
tfd.track_match_data.sort(key=lambda x: x.artist_match, reverse=True)
def _get_match_score(self, str1: str, str2: str) -> float:
"""
@ -266,7 +289,7 @@ class FileImporter:
return combined_score
def get_user_choices(self, path: str) -> int:
def get_user_choices(self, tfd: TrackFileData) -> int:
"""
Find out whether user wants to import this as a new track,
overwrite an existing track or not import it at all.
@ -282,15 +305,12 @@ class FileImporter:
choices.append((Config.IMPORT_AS_NEW, 0, ""))
# New track details
new_track_description = (
f"{self.import_files_data[path].tags.title} "
f"({self.import_files_data[path].tags.artist})"
)
new_track_description = f"{tfd.tags.title} ({tfd.tags.artist})"
# Select 'import as new' as default unless the top match is good
# enough
default = 1
track_match_data = self.import_files_data[path].track_match_data
track_match_data = tfd.track_match_data
if track_match_data:
if (
track_match_data[0].artist_match
@ -323,48 +343,41 @@ class FileImporter:
else:
return -1
def process_selection(self, path: str, selection: int) -> None:
def process_selection(self, tfd: TrackFileData, selection: int) -> bool:
"""
Process selection from PickMatch
"""
if selection < 0:
# User cancelled
self.import_files_data[path].import_this_file = False
self.import_files_data[path].error = "you asked not to import this file"
tfd.import_this_file = False
tfd.error = "you asked not to import this file"
return False
elif selection > 0:
# Import and replace track
self.replace_file(path=path, track_id=selection)
self.replace_file(tfd, track_id=selection)
else:
# Import as new
self.import_as_new(path=path)
self.import_as_new(tfd)
def replace_file(self, path: str, track_id: int) -> None:
return True
def replace_file(self, tfd: TrackFileData, track_id: int) -> None:
"""
Set up to replace an existing file.
On return, the following TrackFileData fields should be set:
tags: Yes
destination_path: YES, IN THIS FUNCTION
import_this_file: Yes (set by default)
error: No (only set if an error is detected)
file_path_to_remove: YES, IN THIS FUNCTION
track_id: YES, IN THIS FUNCTION
track_match_data: Yes
"""
ifd = self.import_files_data[path]
log.debug(f"replace_file({tfd=}, {track_id=})")
if track_id < 1:
raise ApplicationError(f"No track ID: replace_file({path=}, {track_id=})")
raise ApplicationError(f"No track ID: replace_file({tfd=}, {track_id=})")
ifd.track_id = track_id
tfd.track_id = track_id
existing_track_path = self._get_existing_track(track_id).path
ifd.file_path_to_remove = existing_track_path
tfd.file_path_to_remove = existing_track_path
# If the existing file in the Config.IMPORT_DESTINATION
# directory, replace it with the imported file name; otherwise,
@ -372,11 +385,11 @@ class FileImporter:
# names from CDs, etc.
if os.path.dirname(existing_track_path) == Config.IMPORT_DESTINATION:
ifd.destination_path = os.path.join(
Config.IMPORT_DESTINATION, os.path.basename(path)
tfd.destination_path = os.path.join(
Config.IMPORT_DESTINATION, os.path.basename(tfd.source_path)
)
else:
ifd.destination_path = existing_track_path
tfd.destination_path = existing_track_path
def _get_existing_track(self, track_id: int) -> Tracks:
"""
@ -391,61 +404,49 @@ class FileImporter:
return existing_track_records[0]
def import_as_new(self, path: str) -> None:
def import_as_new(self, tfd: TrackFileData) -> None:
"""
Set up to import as a new file.
On return, the following TrackFileData fields should be set:
tags: Yes
destination_path: YES, IN THIS FUNCTION
import_this_file: Yes (set by default)
error: No (only set if an error is detected)
file_path_to_remove: No (not needed now)
track_id: Yes
track_match_data: Yes
"""
ifd = self.import_files_data[path]
ifd.destination_path = os.path.join(
Config.IMPORT_DESTINATION, os.path.basename(path)
tfd.destination_path = os.path.join(
Config.IMPORT_DESTINATION, os.path.basename(tfd.source_path)
)
def validate_file_data(self, path: str) -> None:
def validate_file_data(self, tfd: TrackFileData) -> bool:
"""
Check the data structures for integrity
Return True if all OK
Populate error and return False if not.
"""
ifd = self.import_files_data[path]
# Check import_this_file
if not ifd.import_this_file:
return
# Check tags
if not (ifd.tags.artist and ifd.tags.title):
raise ApplicationError(f"validate_file_data: {ifd.tags=}, {path=}")
if not (tfd.tags.artist and tfd.tags.title):
raise ApplicationError(
f"validate_file_data: {tfd.tags=}, {tfd.source_path=}"
)
# Check file_path_to_remove
if ifd.file_path_to_remove and not os.path.exists(ifd.file_path_to_remove):
if tfd.file_path_to_remove and not os.path.exists(tfd.file_path_to_remove):
# File to remove is missing, but this isn't a major error. We
# may be importing to replace a deleted file.
ifd.file_path_to_remove = ""
tfd.file_path_to_remove = ""
# Check destination_path
if not ifd.destination_path:
if not tfd.destination_path:
raise ApplicationError(
f"validate_file_data: no destination path set ({path=})"
f"validate_file_data: no destination path set ({tfd.source_path=})"
)
# If destination path is the same as file_path_to_remove, that's
# OK, otherwise if this is a new import then check check
# OK, otherwise if this is a new import then check that
# destination path doesn't already exists
if ifd.track_id == 0 and ifd.destination_path != ifd.file_path_to_remove:
while os.path.exists(ifd.destination_path):
if tfd.track_id == 0 and tfd.destination_path != tfd.file_path_to_remove:
while os.path.exists(tfd.destination_path):
msg = (
"New import requested but default destination path ({ifd.destination_path}) "
"already exists. Click OK and choose where to save this track"
f"New import requested but default destination path ({tfd.destination_path})"
" already exists. Click OK and choose where to save this track"
)
show_OK(title="Desintation path exists", msg=msg, parent=None)
# Get output filename
@ -455,92 +456,123 @@ class FileImporter:
directory=Config.IMPORT_DESTINATION,
)
if pathspec:
ifd.destination_path = pathspec[0]
if pathspec == "":
# User cancelled
tfd.error = "You did not select a location to save this track"
return False
tfd.destination_path = pathspec[0]
else:
ifd.import_this_file = False
ifd.error = "destination file already exists"
return
tfd.error = "destination file already exists"
return False
# The desintation path should not already exist in the
# database (becquse if it does, it points to a non-existent
# file). Check that because the path field in the database is
# unique and so adding a duplicate will give a db integrity
# error.
with db.Session() as session:
if Tracks.get_by_path(session, tfd.destination_path):
tfd.error = (
"Importing a new track but destination path already exists "
f"in database ({tfd.destination_path})"
)
return False
# Check track_id
if ifd.track_id < 0:
raise ApplicationError(f"validate_file_data: track_id < 0, {path=}")
if tfd.track_id < 0:
raise ApplicationError(
f"validate_file_data: track_id < 0, {tfd.source_path=}"
)
def inform_user(self) -> None:
return True
def inform_user(self, tfds: list[TrackFileData]) -> None:
"""
Tell user about files that won't be imported
"""
msgs: list[str] = []
for path, entry in self.import_files_data.items():
if entry.import_this_file is False:
msgs.append(
f"{os.path.basename(path)} will not be imported because {entry.error}"
)
for tfd in tfds:
msgs.append(
f"{os.path.basename(tfd.source_path)} will not be imported because {tfd.error}"
)
if msgs:
show_OK("File not imported", "\r\r".join(msgs))
log.debug("\r\r".join(msgs))
def import_next_file(self) -> None:
def _import_next_file(self) -> None:
"""
Import the next file sequentially.
This is called when an import completes so will be called asynchronously.
Protect with a lock.
"""
while True:
if not self.import_files_data:
self.signals.status_message_signal.emit("All files imported", 10000)
return
lock = threading.Lock()
# Get details for next file to import
path, tfd = self.import_files_data.popitem()
if tfd.import_this_file:
break
with lock:
while len(self.workers) < Config.MAX_IMPORT_THREADS:
try:
tfd = self.import_files_data.pop()
filename = os.path.basename(tfd.source_path)
log.debug(f"Processing {filename}")
log.debug(
f"remaining files: {[a.source_path for a in self.import_files_data]}"
)
self.signals.status_message_signal.emit(
f"Importing {filename}", 10000
)
self._start_import(tfd)
except IndexError:
log.debug("import_next_file: no files remaining in queue")
break
print(f"import_next_file {path=}")
def _start_import(self, tfd: TrackFileData) -> None:
"""
Start thread to import track
"""
# Create and start a thread for processing
worker = DoTrackImport(
import_file_path=path,
filename = os.path.basename(tfd.source_path)
log.debug(f"_start_import({filename=})")
self.workers[tfd.source_path] = DoTrackImport(
import_file_path=tfd.source_path,
tags=tfd.tags,
destination_path=tfd.destination_path,
track_id=tfd.track_id,
file_path_to_remove=tfd.file_path_to_remove,
)
thread = QThread()
self.threads.append(thread)
log.debug(f"{self.workers[tfd.source_path]=} created")
# Move worker to thread
worker.moveToThread(thread)
self.workers[tfd.source_path].import_finished.connect(
self.post_import_processing
)
self.workers[tfd.source_path].finished.connect(lambda: self.cleanup_thread(tfd))
self.workers[tfd.source_path].finished.connect(
self.workers[tfd.source_path].deleteLater
)
# Connect signals and slots
thread.started.connect(worker.run)
thread.started.connect(lambda: print(f"Thread starting for {path=}"))
self.workers[tfd.source_path].start()
worker.import_finished.connect(self.post_import_processing)
worker.import_finished.connect(thread.quit)
worker.import_finished.connect(lambda: print(f"Worker ended for {path=}"))
# Ensure cleanup only after thread is fully stopped
thread.finished.connect(lambda: self.cleanup_thread(thread, worker))
thread.finished.connect(lambda: print(f"Thread ended for {path=}"))
# Start the thread
print(f"Calling thread.start() for {path=}")
thread.start()
def cleanup_thread(self, thread, worker):
def cleanup_thread(self, tfd: TrackFileData) -> None:
"""
Remove references to finished threads/workers to prevent leaks.
"""
worker.deleteLater()
thread.deleteLater()
if thread in self.threads:
self.threads.remove(thread)
log.debug(f"cleanup_thread({tfd.source_path=})")
def post_import_processing(self, track_id: int) -> None:
if tfd.source_path in self.workers:
del self.workers[tfd.source_path]
else:
log.error(f"Couldn't find {tfd.source_path=} in {self.workers.keys()=}")
log.debug(f"After cleanup_thread: {self.workers.keys()=}")
def post_import_processing(self, source_path: str, track_id: int) -> None:
"""
If track already in playlist, refresh it else insert it
"""
log.debug(f"post_import_processing({track_id=})")
log.debug(f"post_import_processing({source_path=}, {track_id=})")
if self.model_data:
if self.model_data.base_model:
@ -548,16 +580,16 @@ class FileImporter:
track_id, self.model_data.row_number
)
# Process next file
self.import_next_file()
# Process next file(s)
self._import_next_file()
class DoTrackImport(QObject):
class DoTrackImport(QThread):
"""
Class to manage the actual import of tracks in a thread.
"""
import_finished = pyqtSignal(int)
import_finished = pyqtSignal(str, int)
def __init__(
self,
@ -565,6 +597,7 @@ class DoTrackImport(QObject):
tags: Tags,
destination_path: str,
track_id: int,
file_path_to_remove: Optional[str] = None,
) -> None:
"""
Save parameters
@ -575,9 +608,13 @@ class DoTrackImport(QObject):
self.tags = tags
self.destination_track_path = destination_path
self.track_id = track_id
self.file_path_to_remove = file_path_to_remove
self.signals = MusicMusterSignals()
def __repr__(self) -> str:
return f"<DoTrackImport(id={hex(id(self))}, import_file_path={self.import_file_path}"
def run(self) -> None:
"""
Either create track objects from passed files or update exising track
@ -586,26 +623,21 @@ class DoTrackImport(QObject):
And add to visible playlist or update playlist if track already present.
"""
temp_file: Optional[str] = None
self.signals.status_message_signal.emit(
f"Importing {os.path.basename(self.import_file_path)}", 5000
)
# Get audio metadata in this thread rather than calling function to save interactive time
self.audio_metadata = helpers.get_audio_metadata(self.import_file_path)
# If destination exists, move it out of the way
if os.path.exists(self.destination_track_path):
temp_file = self.destination_track_path + ".TMP"
shutil.move(self.destination_track_path, temp_file)
# Move file to destination
# Remove old file if so requested
if self.file_path_to_remove and os.path.exists(self.file_path_to_remove):
os.unlink(self.file_path_to_remove)
# Move new file to destination
shutil.move(self.import_file_path, self.destination_track_path)
# Clean up
if temp_file and os.path.exists(temp_file):
os.unlink(temp_file)
with db.Session() as session:
self.signals.status_message_signal.emit(
f"Importing {os.path.basename(self.import_file_path)}", 5000
)
if self.track_id == 0:
# Import new track
try:
@ -630,6 +662,9 @@ class DoTrackImport(QObject):
if hasattr(track, key):
setattr(track, key, value)
track.path = self.destination_track_path
else:
log.error(f"Unable to retrieve {self.track_id=}")
return
session.commit()
helpers.normalise_track(self.destination_track_path)
@ -637,7 +672,7 @@ class DoTrackImport(QObject):
self.signals.status_message_signal.emit(
f"{os.path.basename(self.import_file_path)} imported", 10000
)
self.import_finished.emit(track.id)
self.import_finished.emit(self.import_file_path, track.id)
class PickMatch(QDialog):

View File

@ -200,9 +200,9 @@ def get_tags(path: str) -> Tags:
try:
tag = TinyTag.get(path)
except FileNotFoundError:
raise ApplicationError(f"File not found: get_tags({path=})")
raise ApplicationError(f"File not found: {path}")
except TinyTagException:
raise ApplicationError(f"Can't read tags: get_tags({path=})")
raise ApplicationError(f"Can't read tags in {path}")
if (
tag.title is None
@ -210,7 +210,7 @@ def get_tags(path: str) -> Tags:
or tag.bitrate is None
or tag.duration is None
):
raise ApplicationError(f"Missing tags: get_tags({path=})")
raise ApplicationError(f"Missing tags in {path}")
return Tags(
title=tag.title,

36
app/log.py Executable file → Normal file
View File

@ -1,5 +1,6 @@
#!/usr/bin/env python3
# Standard library imports
from collections import defaultdict
import logging
import logging.config
import logging.handlers
@ -20,15 +21,38 @@ from config import Config
class FunctionFilter(logging.Filter):
"""Filter to allow category-based logging to stderr."""
def __init__(self, functions: set[str]):
def __init__(self, module_functions: dict[str, list[str]]):
super().__init__()
self.functions = functions
self.modules: list[str] = []
self.functions: defaultdict[str, list[str]] = defaultdict(list)
for module in module_functions.keys():
if module_functions[module]:
for function in module_functions[module]:
self.functions[module].append(function)
else:
self.modules.append(module)
def filter(self, record: logging.LogRecord) -> bool:
return (
getattr(record, "funcName", None) in self.functions
and getattr(record, "levelname", None) == "DEBUG"
)
if not getattr(record, "levelname", None) == "DEBUG":
# Only prcess DEBUG messages
return False
module = getattr(record, "module", None)
if not module:
# No module in record
return False
# Process if this is a module we're tracking
if module in self.modules:
return True
# Process if this is a function we're tracking
if getattr(record, "funcName", None) in self.functions[module]:
return True
return False
class LevelTagFilter(logging.Filter):

View File

@ -4,18 +4,24 @@ disable_existing_loggers: True
formatters:
colored:
(): colorlog.ColoredFormatter
format: "%(log_color)s[%(asctime)s] %(filename)s:%(lineno)s %(message)s"
format: "%(log_color)s[%(asctime)s] %(filename)s.%(funcName)s:%(lineno)s %(blue)s%(message)s"
datefmt: "%H:%M:%S"
syslog:
format: "[%(name)s] %(filename)s:%(lineno)s %(leveltag)s: %(message)s"
filters:
leveltag:
(): newlogger.LevelTagFilter
(): log.LevelTagFilter
category_filter:
(): newlogger.FunctionFilter
functions: !!set
fb: null
(): log.FunctionFilter
module_functions:
# Optionally additionally log some debug calls to stderr
# log all debug calls in a module:
# module-name: []
# log debug calls for some functions in a module:
# module-name:
# - function-name-1
# - function-name-2
handlers:
stderr:

View File

@ -1,5 +1,7 @@
#!/usr/bin/env python3
from newlogger import log
from log import log
# Testing
def fa():
log.debug("fa Debug message")

View File

@ -154,14 +154,11 @@ class _FadeCurve:
if self.region is None:
# Create the region now that we're into fade
log.debug("issue223: _FadeCurve: create region")
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region)
# Update region position
if self.region:
# Next line is very noisy
# log.debug("issue223: _FadeCurve: update region")
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
@ -578,7 +575,6 @@ class RowAndTrack:
def play(self, position: Optional[float] = None) -> None:
"""Play track"""
log.debug(f"issue223: RowAndTrack: play {self.track_id=}")
now = dt.datetime.now()
self.start_time = now

View File

@ -630,7 +630,9 @@ class Window(QMainWindow, Ui_MainWindow):
self.signals.search_songfacts_signal.connect(self.open_songfacts_browser)
self.signals.search_wikipedia_signal.connect(self.open_wikipedia_browser)
def create_playlist(self, session: Session, playlist_name: str) -> Optional[Playlists]:
def create_playlist(
self, session: Session, playlist_name: str
) -> Optional[Playlists]:
"""Create new playlist"""
log.debug(f"create_playlist({playlist_name=}")
@ -856,11 +858,8 @@ class Window(QMainWindow, Ui_MainWindow):
# We need to keep a reference to the FileImporter else it will be
# garbage collected while import threads are still running
self.importer = FileImporter(
self.current.base_model,
self.current_row_or_end()
)
self.importer.do_import()
self.importer = FileImporter(self.current.base_model, self.current_row_or_end())
self.importer.start()
def insert_header(self) -> None:
"""Show dialog box to enter header text and add to playlist"""
@ -973,7 +972,9 @@ class Window(QMainWindow, Ui_MainWindow):
playlist.delete(session)
session.commit()
else:
raise ApplicationError(f"Unrecognised action from EditDeleteDialog: {action=}")
raise ApplicationError(
f"Unrecognised action from EditDeleteDialog: {action=}"
)
def mark_rows_for_moving(self) -> None:
"""
@ -1191,8 +1192,6 @@ class Window(QMainWindow, Ui_MainWindow):
- Update headers
"""
log.debug(f"issue223: play_next({position=})")
# If there is no next track set, return.
if track_sequence.next is None:
log.error("musicmuster.play_next(): no next track selected")
@ -1203,10 +1202,9 @@ class Window(QMainWindow, Ui_MainWindow):
return
# Issue #223 concerns a very short pause (maybe 0.1s) sometimes
# when starting to play at track.
# Resolution appears to be to disable timer10 for a short time.
# Length of time and re-enabling of timer10 both in update_clocks.
# when starting to play at track. Resolution appears to be to
# disable timer10 for a short time. Timer is re-enabled in
# update_clocks.
self.timer10.stop()
log.debug("issue223: play_next: 10ms timer disabled")
@ -1225,38 +1223,29 @@ class Window(QMainWindow, Ui_MainWindow):
# Restore volume if -3dB active
if self.btnDrop3db.isChecked():
log.debug("issue223: play_next: Reset -3db button")
self.btnDrop3db.setChecked(False)
# Play (new) current track
log.info(f"Play: {track_sequence.current.title}")
log.debug(f"Play: {track_sequence.current.title}")
track_sequence.current.play(position)
# Update clocks now, don't wait for next tick
log.debug("issue223: play_next: update_clocks()")
self.update_clocks()
# Show closing volume graph
if track_sequence.current.fade_graph:
log.debug(
f"issue223: play_next: set up fade_graph, {track_sequence.current.title=}"
)
track_sequence.current.fade_graph.GraphWidget = self.widgetFadeVolume
track_sequence.current.fade_graph.clear()
track_sequence.current.fade_graph.plot()
else:
log.debug("issue223: play_next: No fade_graph")
# Disable play next controls
self.catch_return_key = True
self.show_status_message("Play controls: Disabled", 0)
# Notify playlist
log.debug("issue223: play_next: notify playlist")
self.active_tab().current_track_started()
# Update headers
log.debug("issue223: play_next: update headers")
self.update_headers()
with db.Session() as session:
last_played = Playdates.last_played_tracks(session)
@ -1476,9 +1465,7 @@ class Window(QMainWindow, Ui_MainWindow):
helpers.show_warning(
self, "Duplicate template", "Template name already in use"
)
Playlists.save_as_template(
session, self.current.playlist_id, template_name
)
Playlists.save_as_template(session, self.current.playlist_id, template_name)
session.commit()
helpers.show_OK("Template", "Template saved", self)
@ -1729,15 +1716,6 @@ class Window(QMainWindow, Ui_MainWindow):
# If track is playing, update track clocks time and colours
if track_sequence.current and track_sequence.current.is_playing():
# see play_next() and issue #223.
# TODO: find a better way of handling this
if (
track_sequence.current.time_playing() > 5000
and not self.timer10.isActive()
):
self.timer10.start(10)
log.debug("issue223: update_clocks: 10ms timer enabled")
# Elapsed time
self.label_elapsed_timer.setText(
helpers.ms_to_mmss(track_sequence.current.time_playing())
@ -1765,14 +1743,22 @@ class Window(QMainWindow, Ui_MainWindow):
if self.frame_silent.styleSheet() != css_fade:
self.frame_silent.setStyleSheet(css_fade)
# Five seconds before fade starts, set warning colour on
# time to silence box and enable play controls
# WARNING_MS_BEFORE_FADE milliseconds before fade starts, set
# warning colour on time to silence box and enable play
# controls. This is also a good time to re-enable the 10ms
# timer (see play_next() and issue #223).
elif time_to_fade <= Config.WARNING_MS_BEFORE_FADE:
self.frame_fade.setStyleSheet(
f"background: {Config.COLOUR_WARNING_TIMER}"
)
self.catch_return_key = False
self.show_status_message("Play controls: Enabled", 0)
# Re-enable 10ms timer (see above)
if not self.timer10.isActive():
self.timer10.start(10)
log.debug("issue223: update_clocks: 10ms timer enabled")
else:
self.frame_silent.setStyleSheet("")
self.frame_fade.setStyleSheet("")

View File

@ -1030,7 +1030,7 @@ class PlaylistModel(QAbstractTableModel):
log.debug(f"{self}: OBS scene changed to '{scene_name}'")
continue
except obswebsocket.exceptions.ConnectionFailure:
log.error(f"{self}: OBS connection refused")
log.warning(f"{self}: OBS connection refused")
return
def previous_track_ended(self) -> None:
@ -1151,6 +1151,7 @@ class PlaylistModel(QAbstractTableModel):
]:
if ts:
ts.update_playlist_and_row(session)
session.commit()
self.update_track_times()

View File

@ -213,10 +213,10 @@ class PlaylistDelegate(QStyledItemDelegate):
doc.setTextWidth(option.rect.width())
doc.setDefaultFont(option.font)
doc.setDocumentMargin(Config.ROW_PADDING)
if '\n' in option.text:
txt = option.text.replace('\n', '<br>')
elif '\u2028' in option.text:
txt = option.text.replace('\u2028', '<br>')
if "\n" in option.text:
txt = option.text.replace("\n", "<br>")
elif "\u2028" in option.text:
txt = option.text.replace("\u2028", "<br>")
else:
txt = option.text
doc.setHtml(txt)

354
poetry.lock generated
View File

@ -56,34 +56,34 @@ test = ["astroid (>=2,<4)", "pytest", "pytest-cov", "pytest-xdist"]
[[package]]
name = "black"
version = "24.10.0"
version = "25.1.0"
description = "The uncompromising code formatter."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"},
{file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"},
{file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"},
{file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"},
{file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"},
{file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"},
{file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"},
{file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"},
{file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"},
{file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"},
{file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"},
{file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"},
{file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"},
{file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"},
{file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"},
{file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"},
{file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"},
{file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"},
{file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"},
{file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"},
{file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"},
{file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"},
{file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"},
{file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"},
{file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"},
{file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"},
{file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"},
{file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"},
{file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"},
{file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"},
{file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"},
{file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"},
{file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"},
{file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"},
{file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"},
{file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"},
{file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"},
{file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"},
{file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"},
{file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"},
{file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"},
{file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"},
{file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"},
{file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"},
]
[package.dependencies]
@ -433,14 +433,14 @@ ipython = {version = ">=7.31.1", markers = "python_version >= \"3.11\""}
[[package]]
name = "ipython"
version = "8.31.0"
version = "8.32.0"
description = "IPython: Productive Interactive Computing"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "ipython-8.31.0-py3-none-any.whl", hash = "sha256:46ec58f8d3d076a61d128fe517a51eb730e3aaf0c184ea8c17d16e366660c6a6"},
{file = "ipython-8.31.0.tar.gz", hash = "sha256:b6a2274606bec6166405ff05e54932ed6e5cfecaca1fc05f2cacde7bb074d70b"},
{file = "ipython-8.32.0-py3-none-any.whl", hash = "sha256:cae85b0c61eff1fc48b0a8002de5958b6528fa9c8defb1894da63f42613708aa"},
{file = "ipython-8.32.0.tar.gz", hash = "sha256:be2c91895b0b9ea7ba49d33b23e2040c352b33eb6a519cca7ce6e0c743444251"},
]
[package.dependencies]
@ -1034,23 +1034,6 @@ files = [
{file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
]
[[package]]
name = "pdbp"
version = "1.6.1"
description = "pdbp (Pdb+): A drop-in replacement for pdb and pdbpp."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pdbp-1.6.1-py3-none-any.whl", hash = "sha256:f10bad2ee044c0e5c168cb0825abfdbdc01c50013e9755df5261b060bdd35c22"},
{file = "pdbp-1.6.1.tar.gz", hash = "sha256:f4041642952a05df89664e166d5bd379607a0866ddd753c06874f65552bdf40b"},
]
[package.dependencies]
colorama = {version = ">=0.4.6", markers = "platform_system == \"Windows\""}
pygments = ">=2.18.0"
tabcompleter = ">=1.4.0"
[[package]]
name = "pexpect"
version = "4.9.0"
@ -1399,36 +1382,36 @@ files = [
[[package]]
name = "pyqt6-sip"
version = "13.9.1"
version = "13.10.0"
description = "The sip module support for PyQt6"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "PyQt6_sip-13.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e996d320744ca8342cad6f9454345330d4f06bce129812d032bda3bad6967c5c"},
{file = "PyQt6_sip-13.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ab85aaf155828331399c59ebdd4d3b0358e42c08250e86b43d56d9873df148a"},
{file = "PyQt6_sip-13.9.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22d66256b800f552ade51a463510bf905f3cb318aae00ff4288fae4de5d0e600"},
{file = "PyQt6_sip-13.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:5643c92424fe62cb0b33378fef3d28c1525f91ada79e8a15bd9a05414a09503d"},
{file = "PyQt6_sip-13.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:57b5312ef13c1766bdf69b317041140b184eb24a51e1e23ce8fc5386ba8dffb2"},
{file = "PyQt6_sip-13.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5004514b08b045ad76425cf3618187091a668d972b017677b1b4b193379ef553"},
{file = "PyQt6_sip-13.9.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:accab6974b2758296400120fdcc9d1f37785b2ea2591f00656e1776f058ded6c"},
{file = "PyQt6_sip-13.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:1ec52e962f54137a19208b6e95b6bd9f7a403eb25d7237768a99306cd9db26d1"},
{file = "PyQt6_sip-13.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:6e6c1e2592187934f4e790c0c099d0033e986dcef7bdd3c06e3895ffa995e9fc"},
{file = "PyQt6_sip-13.9.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1fb405615970e85b622b13b4cad140ff1e4182eb8334a0b27a4698e6217b89b0"},
{file = "PyQt6_sip-13.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c800db3464481e87b1d2b84523b075df1e8fc7856c6f9623dc243f89be1cb604"},
{file = "PyQt6_sip-13.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c1942e107b0243ced9e510d507e0f27aeea9d6b13e0a1b7c06fd52a62e0d41f7"},
{file = "PyQt6_sip-13.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:552ff8fdc41f5769d3eccc661f022ed496f55f6e0a214c20aaf56e56385d61b6"},
{file = "PyQt6_sip-13.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:976c7758f668806d4df7a8853f390ac123d5d1f73591ed368bdb8963574ff589"},
{file = "PyQt6_sip-13.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:56ce0afb19cd8a8c63ff93ae506dffb74f844b88adaa6673ebc0dec43af48a76"},
{file = "PyQt6_sip-13.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d7726556d1ca7a7ed78e19ba53285b64a2a8f6ad7ff4cb18a1832efca1a3102"},
{file = "PyQt6_sip-13.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:14f95c6352e3b85dc26bf59cfbf77a470ecbd5fcdcf00af4b648f0e1b9eefb9e"},
{file = "PyQt6_sip-13.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:3c269052c770c09b61fce2f2f9ea934a67dfc65f443d59629b4ccc8f89751890"},
{file = "PyQt6_sip-13.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:8b2ac36d6e04db6099614b9c1178a2f87788c7ffc3826571fb63d36ddb4c401d"},
{file = "PyQt6_sip-13.9.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:69a879cfc94f4984d180321b76f52923861cd5bf4969aa885eef7591ee932517"},
{file = "PyQt6_sip-13.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fa27b51ae4c7013b3700cf0ecf46907d1333ae396fc6511311920485cbce094b"},
{file = "PyQt6_sip-13.9.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1d322ded1d1fea339cc6ac65b768e72c69c486eebb7db6ccde061b5786d74cc5"},
{file = "PyQt6_sip-13.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:8c207528992d59b0801458aa6fcff118e5c099608ef0fc6ff8bccbdc23f29c04"},
{file = "pyqt6_sip-13.9.1.tar.gz", hash = "sha256:15be741d1ae8c82bb7afe9a61f3cf8c50457f7d61229a1c39c24cd6e8f4d86dc"},
{file = "PyQt6_sip-13.10.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e7b1258963717cfae1d30e262bb784db808072a8a674d98f57c2076caaa50499"},
{file = "PyQt6_sip-13.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d27a3fed2a461f179d3cde6a74530fbad629ccaa66ed739b9544fda1932887af"},
{file = "PyQt6_sip-13.10.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:0422781c77b85eefd7a26f104c5998ede178a16b0fd35212664250215b6e5e4c"},
{file = "PyQt6_sip-13.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:f64183dde2af36515dab515f4301a5a8d9b3658b231769fa48fe6287dc52f375"},
{file = "PyQt6_sip-13.10.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e78fb8036b18f6258a1af0956c5a3cec1dd3d8dd5196ecd89a31b529bf40e82"},
{file = "PyQt6_sip-13.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e19d5887fa3003a635419644dfed3158cb15eb566fc27b1ed56913a5767a71dc"},
{file = "PyQt6_sip-13.10.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:079bb946edc3960f08d92b3a8eebff55d3abb51bc2a0583b6683dfd9f77a616a"},
{file = "PyQt6_sip-13.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:90974f5dbba1f5d1d2ca9b1cfdfd5258e5e3cfacead03f0df674d54c69973ea7"},
{file = "PyQt6_sip-13.10.0-cp311-cp311-win_arm64.whl", hash = "sha256:bbefd5539eeda4dec37e8b6dfc362ba240ec31279060336bcceaff572807dac8"},
{file = "PyQt6_sip-13.10.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:48791db2914fc39c3218519a02d2a5fd3fcd354a1be3141a57bf2880701486f2"},
{file = "PyQt6_sip-13.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:466d6b4791973c9fcbdc2e0087ed194b9ea802a8c3948867a849498f0841c70c"},
{file = "PyQt6_sip-13.10.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ae15358941f127cd3d1ab09c1ebd45c4dabb0b2e91587b9eebde0279d0039c54"},
{file = "PyQt6_sip-13.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:ad573184fa8b00041944e5a17d150ab0d08db2d2189e39c9373574ebab3f2e58"},
{file = "PyQt6_sip-13.10.0-cp312-cp312-win_arm64.whl", hash = "sha256:2d579d810d0047d40bde9c6aef281d6ed218db93c9496ebc9e55b9e6f27a229d"},
{file = "PyQt6_sip-13.10.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7b6e250c2e7c14702a623f2cc1479d7fb8db2b6eee9697cac10d06fe79c281bb"},
{file = "PyQt6_sip-13.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fcb30756568f8cd59290f9ef2ae5ee3e72ff9cdd61a6f80c9e3d3b95ae676be"},
{file = "PyQt6_sip-13.10.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:757ac52c92b2ef0b56ecc7cd763b55a62d3c14271d7ea8d03315af85a70090ff"},
{file = "PyQt6_sip-13.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:571900c44a3e38738d696234d94fe2043972b9de0633505451c99e2922cb6a34"},
{file = "PyQt6_sip-13.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:39cba2cc71cf80a99b4dc8147b43508d4716e128f9fb99f5eb5860a37f082282"},
{file = "PyQt6_sip-13.10.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f026a1278f9c2a745542d4a05350f2392d4cf339275fb8efccb47b0f213d120"},
{file = "PyQt6_sip-13.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:548c70bc40d993be0eb011e1bbc41ba7c95f6af375613b58217f39ad8d703345"},
{file = "PyQt6_sip-13.10.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:21417ffd2c489afef114cb09683bbc0fb24d78df848a21fc0d09e70ecbb0a4a4"},
{file = "PyQt6_sip-13.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:6e1b1f7a29290afc83bcd9970e0cffa2d0da87d81796b6eab7b6f583e4f49652"},
{file = "pyqt6_sip-13.10.0.tar.gz", hash = "sha256:d6daa95a0bd315d9ec523b549e0ce97455f61ded65d5eafecd83ed2aa4ae5350"},
]
[[package]]
@ -1483,22 +1466,6 @@ files = [
[package.dependencies]
numpy = ">=1.22.0"
[[package]]
name = "pyreadline3"
version = "3.5.4"
description = "A python implementation of GNU readline."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
markers = "platform_system == \"Windows\""
files = [
{file = "pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6"},
{file = "pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7"},
]
[package.extras]
dev = ["build", "flake8", "mypy", "pytest", "twine"]
[[package]]
name = "pytest"
version = "8.3.4"
@ -1522,18 +1489,18 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments
[[package]]
name = "pytest-cov"
version = "5.0.0"
version = "6.0.0"
description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["dev"]
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"},
{file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"},
{file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"},
]
[package.dependencies]
coverage = {version = ">=5.2.1", extras = ["toml"]}
coverage = {version = ">=7.5", extras = ["toml"]}
pytest = ">=4.6"
[package.extras]
@ -1606,100 +1573,100 @@ files = [
[[package]]
name = "rapidfuzz"
version = "3.11.0"
version = "3.12.1"
description = "rapid fuzzy string matching"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "rapidfuzz-3.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb8a54543d16ab1b69e2c5ed96cabbff16db044a50eddfc028000138ca9ddf33"},
{file = "rapidfuzz-3.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:231c8b2efbd7f8d2ecd1ae900363ba168b8870644bb8f2b5aa96e4a7573bde19"},
{file = "rapidfuzz-3.11.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54e7f442fb9cca81e9df32333fb075ef729052bcabe05b0afc0441f462299114"},
{file = "rapidfuzz-3.11.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:906f1f2a1b91c06599b3dd1be207449c5d4fc7bd1e1fa2f6aef161ea6223f165"},
{file = "rapidfuzz-3.11.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ed59044aea9eb6c663112170f2399b040d5d7b162828b141f2673e822093fa8"},
{file = "rapidfuzz-3.11.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cb1965a28b0fa64abdee130c788a0bc0bb3cf9ef7e3a70bf055c086c14a3d7e"},
{file = "rapidfuzz-3.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b488b244931d0291412917e6e46ee9f6a14376625e150056fe7c4426ef28225"},
{file = "rapidfuzz-3.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f0ba13557fec9d5ffc0a22826754a7457cc77f1b25145be10b7bb1d143ce84c6"},
{file = "rapidfuzz-3.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3871fa7dfcef00bad3c7e8ae8d8fd58089bad6fb21f608d2bf42832267ca9663"},
{file = "rapidfuzz-3.11.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b2669eafee38c5884a6e7cc9769d25c19428549dcdf57de8541cf9e82822e7db"},
{file = "rapidfuzz-3.11.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ffa1bb0e26297b0f22881b219ffc82a33a3c84ce6174a9d69406239b14575bd5"},
{file = "rapidfuzz-3.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:45b15b8a118856ac9caac6877f70f38b8a0d310475d50bc814698659eabc1cdb"},
{file = "rapidfuzz-3.11.0-cp310-cp310-win32.whl", hash = "sha256:22033677982b9c4c49676f215b794b0404073f8974f98739cb7234e4a9ade9ad"},
{file = "rapidfuzz-3.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:be15496e7244361ff0efcd86e52559bacda9cd975eccf19426a0025f9547c792"},
{file = "rapidfuzz-3.11.0-cp310-cp310-win_arm64.whl", hash = "sha256:714a7ba31ba46b64d30fccfe95f8013ea41a2e6237ba11a805a27cdd3bce2573"},
{file = "rapidfuzz-3.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8724a978f8af7059c5323d523870bf272a097478e1471295511cf58b2642ff83"},
{file = "rapidfuzz-3.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8b63cb1f2eb371ef20fb155e95efd96e060147bdd4ab9fc400c97325dfee9fe1"},
{file = "rapidfuzz-3.11.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82497f244aac10b20710448645f347d862364cc4f7d8b9ba14bd66b5ce4dec18"},
{file = "rapidfuzz-3.11.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:339607394941801e6e3f6c1ecd413a36e18454e7136ed1161388de674f47f9d9"},
{file = "rapidfuzz-3.11.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84819390a36d6166cec706b9d8f0941f115f700b7faecab5a7e22fc367408bc3"},
{file = "rapidfuzz-3.11.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eea8d9e20632d68f653455265b18c35f90965e26f30d4d92f831899d6682149b"},
{file = "rapidfuzz-3.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b659e1e2ea2784a9a397075a7fc395bfa4fe66424042161c4bcaf6e4f637b38"},
{file = "rapidfuzz-3.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1315cd2a351144572e31fe3df68340d4b83ddec0af8b2e207cd32930c6acd037"},
{file = "rapidfuzz-3.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a7743cca45b4684c54407e8638f6d07b910d8d811347b9d42ff21262c7c23245"},
{file = "rapidfuzz-3.11.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5bb636b0150daa6d3331b738f7c0f8b25eadc47f04a40e5c23c4bfb4c4e20ae3"},
{file = "rapidfuzz-3.11.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:42f4dd264ada7a9aa0805ea0da776dc063533917773cf2df5217f14eb4429eae"},
{file = "rapidfuzz-3.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51f24cb39e64256221e6952f22545b8ce21cacd59c0d3e367225da8fc4b868d8"},
{file = "rapidfuzz-3.11.0-cp311-cp311-win32.whl", hash = "sha256:aaf391fb6715866bc14681c76dc0308f46877f7c06f61d62cc993b79fc3c4a2a"},
{file = "rapidfuzz-3.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:ebadd5b8624d8ad503e505a99b8eb26fe3ea9f8e9c2234e805a27b269e585842"},
{file = "rapidfuzz-3.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:d895998fec712544c13cfe833890e0226585cf0391dd3948412441d5d68a2b8c"},
{file = "rapidfuzz-3.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f382fec4a7891d66fb7163c90754454030bb9200a13f82ee7860b6359f3f2fa8"},
{file = "rapidfuzz-3.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dfaefe08af2a928e72344c800dcbaf6508e86a4ed481e28355e8d4b6a6a5230e"},
{file = "rapidfuzz-3.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92ebb7c12f682b5906ed98429f48a3dd80dd0f9721de30c97a01473d1a346576"},
{file = "rapidfuzz-3.11.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a1b3ebc62d4bcdfdeba110944a25ab40916d5383c5e57e7c4a8dc0b6c17211a"},
{file = "rapidfuzz-3.11.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c6d7fea39cb33e71de86397d38bf7ff1a6273e40367f31d05761662ffda49e4"},
{file = "rapidfuzz-3.11.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99aebef8268f2bc0b445b5640fd3312e080bd17efd3fbae4486b20ac00466308"},
{file = "rapidfuzz-3.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4469307f464ae3089acf3210b8fc279110d26d10f79e576f385a98f4429f7d97"},
{file = "rapidfuzz-3.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:eb97c53112b593f89a90b4f6218635a9d1eea1d7f9521a3b7d24864228bbc0aa"},
{file = "rapidfuzz-3.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ef8937dae823b889c0273dfa0f0f6c46a3658ac0d851349c464d1b00e7ff4252"},
{file = "rapidfuzz-3.11.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d95f9e9f3777b96241d8a00d6377cc9c716981d828b5091082d0fe3a2924b43e"},
{file = "rapidfuzz-3.11.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:b1d67d67f89e4e013a5295e7523bc34a7a96f2dba5dd812c7c8cb65d113cbf28"},
{file = "rapidfuzz-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d994cf27e2f874069884d9bddf0864f9b90ad201fcc9cb2f5b82bacc17c8d5f2"},
{file = "rapidfuzz-3.11.0-cp312-cp312-win32.whl", hash = "sha256:ba26d87fe7fcb56c4a53b549a9e0e9143f6b0df56d35fe6ad800c902447acd5b"},
{file = "rapidfuzz-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1f7efdd7b7adb32102c2fa481ad6f11923e2deb191f651274be559d56fc913b"},
{file = "rapidfuzz-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:ed78c8e94f57b44292c1a0350f580e18d3a3c5c0800e253f1583580c1b417ad2"},
{file = "rapidfuzz-3.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e60814edd0c9b511b5f377d48b9782b88cfe8be07a98f99973669299c8bb318a"},
{file = "rapidfuzz-3.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f28952da055dbfe75828891cd3c9abf0984edc8640573c18b48c14c68ca5e06"},
{file = "rapidfuzz-3.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e8f93bc736020351a6f8e71666e1f486bb8bd5ce8112c443a30c77bfde0eb68"},
{file = "rapidfuzz-3.11.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76a4a11ba8f678c9e5876a7d465ab86def047a4fcc043617578368755d63a1bc"},
{file = "rapidfuzz-3.11.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc0e0d41ad8a056a9886bac91ff9d9978e54a244deb61c2972cc76b66752de9c"},
{file = "rapidfuzz-3.11.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e8ea35f2419c7d56b3e75fbde2698766daedb374f20eea28ac9b1f668ef4f74"},
{file = "rapidfuzz-3.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd340bbd025302276b5aa221dccfe43040c7babfc32f107c36ad783f2ffd8775"},
{file = "rapidfuzz-3.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:494eef2c68305ab75139034ea25328a04a548d297712d9cf887bf27c158c388b"},
{file = "rapidfuzz-3.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5a167344c1d6db06915fb0225592afdc24d8bafaaf02de07d4788ddd37f4bc2f"},
{file = "rapidfuzz-3.11.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8c7af25bda96ac799378ac8aba54a8ece732835c7b74cfc201b688a87ed11152"},
{file = "rapidfuzz-3.11.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d2a0f7e17f33e7890257367a1662b05fecaf56625f7dbb6446227aaa2b86448b"},
{file = "rapidfuzz-3.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d0d26c7172bdb64f86ee0765c5b26ea1dc45c52389175888ec073b9b28f4305"},
{file = "rapidfuzz-3.11.0-cp313-cp313-win32.whl", hash = "sha256:6ad02bab756751c90fa27f3069d7b12146613061341459abf55f8190d899649f"},
{file = "rapidfuzz-3.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:b1472986fd9c5d318399a01a0881f4a0bf4950264131bb8e2deba9df6d8c362b"},
{file = "rapidfuzz-3.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:c408f09649cbff8da76f8d3ad878b64ba7f7abdad1471efb293d2c075e80c822"},
{file = "rapidfuzz-3.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1bac4873f6186f5233b0084b266bfb459e997f4c21fc9f029918f44a9eccd304"},
{file = "rapidfuzz-3.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4f9f12c2d0aa52b86206d2059916153876a9b1cf9dfb3cf2f344913167f1c3d4"},
{file = "rapidfuzz-3.11.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dd501de6f7a8f83557d20613b58734d1cb5f0be78d794cde64fe43cfc63f5f2"},
{file = "rapidfuzz-3.11.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4416ca69af933d4a8ad30910149d3db6d084781d5c5fdedb713205389f535385"},
{file = "rapidfuzz-3.11.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0821b9bdf18c5b7d51722b906b233a39b17f602501a966cfbd9b285f8ab83cd"},
{file = "rapidfuzz-3.11.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d0edecc3f90c2653298d380f6ea73b536944b767520c2179ec5d40b9145e47aa"},
{file = "rapidfuzz-3.11.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4513dd01cee11e354c31b75f652d4d466c9440b6859f84e600bdebfccb17735a"},
{file = "rapidfuzz-3.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d9727b85511b912571a76ce53c7640ba2c44c364e71cef6d7359b5412739c570"},
{file = "rapidfuzz-3.11.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ab9eab33ee3213f7751dc07a1a61b8d9a3d748ca4458fffddd9defa6f0493c16"},
{file = "rapidfuzz-3.11.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6b01c1ddbb054283797967ddc5433d5c108d680e8fa2684cf368be05407b07e4"},
{file = "rapidfuzz-3.11.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:3857e335f97058c4b46fa39ca831290b70de554a5c5af0323d2f163b19c5f2a6"},
{file = "rapidfuzz-3.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d98a46cf07c0c875d27e8a7ed50f304d83063e49b9ab63f21c19c154b4c0d08d"},
{file = "rapidfuzz-3.11.0-cp39-cp39-win32.whl", hash = "sha256:c36539ed2c0173b053dafb221458812e178cfa3224ade0960599bec194637048"},
{file = "rapidfuzz-3.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:ec8d7d8567e14af34a7911c98f5ac74a3d4a743cd848643341fc92b12b3784ff"},
{file = "rapidfuzz-3.11.0-cp39-cp39-win_arm64.whl", hash = "sha256:62171b270ecc4071be1c1f99960317db261d4c8c83c169e7f8ad119211fe7397"},
{file = "rapidfuzz-3.11.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f06e3c4c0a8badfc4910b9fd15beb1ad8f3b8fafa8ea82c023e5e607b66a78e4"},
{file = "rapidfuzz-3.11.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fe7aaf5a54821d340d21412f7f6e6272a9b17a0cbafc1d68f77f2fc11009dcd5"},
{file = "rapidfuzz-3.11.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25398d9ac7294e99876a3027ffc52c6bebeb2d702b1895af6ae9c541ee676702"},
{file = "rapidfuzz-3.11.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a52eea839e4bdc72c5e60a444d26004da00bb5bc6301e99b3dde18212e41465"},
{file = "rapidfuzz-3.11.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c87319b0ab9d269ab84f6453601fd49b35d9e4a601bbaef43743f26fabf496c"},
{file = "rapidfuzz-3.11.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3048c6ed29d693fba7d2a7caf165f5e0bb2b9743a0989012a98a47b975355cca"},
{file = "rapidfuzz-3.11.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b04f29735bad9f06bb731c214f27253bd8bedb248ef9b8a1b4c5bde65b838454"},
{file = "rapidfuzz-3.11.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7864e80a0d4e23eb6194254a81ee1216abdc53f9dc85b7f4d56668eced022eb8"},
{file = "rapidfuzz-3.11.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3794df87313dfb56fafd679b962e0613c88a293fd9bd5dd5c2793d66bf06a101"},
{file = "rapidfuzz-3.11.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d71da0012face6f45432a11bc59af19e62fac5a41f8ce489e80c0add8153c3d1"},
{file = "rapidfuzz-3.11.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff38378346b7018f42cbc1f6d1d3778e36e16d8595f79a312b31e7c25c50bd08"},
{file = "rapidfuzz-3.11.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:6668321f90aa02a5a789d4e16058f2e4f2692c5230252425c3532a8a62bc3424"},
{file = "rapidfuzz-3.11.0.tar.gz", hash = "sha256:a53ca4d3f52f00b393fab9b5913c5bafb9afc27d030c8a1db1283da6917a860f"},
{file = "rapidfuzz-3.12.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dbb7ea2fd786e6d66f225ef6eef1728832314f47e82fee877cb2a793ebda9579"},
{file = "rapidfuzz-3.12.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ae41361de05762c1eaa3955e5355de7c4c6f30d1ef1ea23d29bf738a35809ab"},
{file = "rapidfuzz-3.12.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc3c39e0317e7f68ba01bac056e210dd13c7a0abf823e7b6a5fe7e451ddfc496"},
{file = "rapidfuzz-3.12.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69f2520296f1ae1165b724a3aad28c56fd0ac7dd2e4cff101a5d986e840f02d4"},
{file = "rapidfuzz-3.12.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34dcbf5a7daecebc242f72e2500665f0bde9dd11b779246c6d64d106a7d57c99"},
{file = "rapidfuzz-3.12.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:773ab37fccf6e0513891f8eb4393961ddd1053c6eb7e62eaa876e94668fc6d31"},
{file = "rapidfuzz-3.12.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ecf0e6de84c0bc2c0f48bc03ba23cef2c5f1245db7b26bc860c11c6fd7a097c"},
{file = "rapidfuzz-3.12.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4dc2ebad4adb29d84a661f6a42494df48ad2b72993ff43fad2b9794804f91e45"},
{file = "rapidfuzz-3.12.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8389d98b9f54cb4f8a95f1fa34bf0ceee639e919807bb931ca479c7a5f2930bf"},
{file = "rapidfuzz-3.12.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:165bcdecbfed9978962da1d3ec9c191b2ff9f1ccc2668fbaf0613a975b9aa326"},
{file = "rapidfuzz-3.12.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:129d536740ab0048c1a06ccff73c683f282a2347c68069affae8dbc423a37c50"},
{file = "rapidfuzz-3.12.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1b67e390261ffe98ec86c771b89425a78b60ccb610c3b5874660216fcdbded4b"},
{file = "rapidfuzz-3.12.1-cp310-cp310-win32.whl", hash = "sha256:a66520180d3426b9dc2f8d312f38e19bc1fc5601f374bae5c916f53fa3534a7d"},
{file = "rapidfuzz-3.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:82260b20bc7a76556cecb0c063c87dad19246a570425d38f8107b8404ca3ac97"},
{file = "rapidfuzz-3.12.1-cp310-cp310-win_arm64.whl", hash = "sha256:3a860d103bbb25c69c2e995fdf4fac8cb9f77fb69ec0a00469d7fd87ff148f46"},
{file = "rapidfuzz-3.12.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6d9afad7b16d01c9e8929b6a205a18163c7e61b6cd9bcf9c81be77d5afc1067a"},
{file = "rapidfuzz-3.12.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bb424ae7240f2d2f7d8dda66a61ebf603f74d92f109452c63b0dbf400204a437"},
{file = "rapidfuzz-3.12.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42149e6d13bd6d06437d2a954dae2184dadbbdec0fdb82dafe92860d99f80519"},
{file = "rapidfuzz-3.12.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:760ac95d788f2964b73da01e0bdffbe1bf2ad8273d0437565ce9092ae6ad1fbc"},
{file = "rapidfuzz-3.12.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2cf27e8e4bf7bf9d92ef04f3d2b769e91c3f30ba99208c29f5b41e77271a2614"},
{file = "rapidfuzz-3.12.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:00ceb8ff3c44ab0d6014106c71709c85dee9feedd6890eff77c814aa3798952b"},
{file = "rapidfuzz-3.12.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8b61c558574fbc093d85940c3264c08c2b857b8916f8e8f222e7b86b0bb7d12"},
{file = "rapidfuzz-3.12.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:346a2d8f17224e99f9ef988606c83d809d5917d17ad00207237e0965e54f9730"},
{file = "rapidfuzz-3.12.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d60d1db1b7e470e71ae096b6456e20ec56b52bde6198e2dbbc5e6769fa6797dc"},
{file = "rapidfuzz-3.12.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2477da227e266f9c712f11393182c69a99d3c8007ea27f68c5afc3faf401cc43"},
{file = "rapidfuzz-3.12.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8499c7d963ddea8adb6cffac2861ee39a1053e22ca8a5ee9de1197f8dc0275a5"},
{file = "rapidfuzz-3.12.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:12802e5c4d8ae104fb6efeeb436098325ce0dca33b461c46e8df015c84fbef26"},
{file = "rapidfuzz-3.12.1-cp311-cp311-win32.whl", hash = "sha256:e1061311d07e7cdcffa92c9b50c2ab4192907e70ca01b2e8e1c0b6b4495faa37"},
{file = "rapidfuzz-3.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:c6e4ed63e204daa863a802eec09feea5448617981ba5d150f843ad8e3ae071a4"},
{file = "rapidfuzz-3.12.1-cp311-cp311-win_arm64.whl", hash = "sha256:920733a28c3af47870835d59ca9879579f66238f10de91d2b4b3f809d1ebfc5b"},
{file = "rapidfuzz-3.12.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f6235b57ae3faa3f85cb3f90c9fee49b21bd671b76e90fc99e8ca2bdf0b5e4a3"},
{file = "rapidfuzz-3.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af4585e5812632c357fee5ab781c29f00cd06bea58f8882ff244cc4906ba6c9e"},
{file = "rapidfuzz-3.12.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5942dc4460e5030c5f9e1d4c9383de2f3564a2503fe25e13e89021bcbfea2f44"},
{file = "rapidfuzz-3.12.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b31ab59e1a0df5afc21f3109b6cfd77b34040dbf54f1bad3989f885cfae1e60"},
{file = "rapidfuzz-3.12.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97c885a7a480b21164f57a706418c9bbc9a496ec6da087e554424358cadde445"},
{file = "rapidfuzz-3.12.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d844c0587d969ce36fbf4b7cbf0860380ffeafc9ac5e17a7cbe8abf528d07bb"},
{file = "rapidfuzz-3.12.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93c95dce8917bf428064c64024de43ffd34ec5949dd4425780c72bd41f9d969"},
{file = "rapidfuzz-3.12.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:834f6113d538af358f39296604a1953e55f8eeffc20cb4caf82250edbb8bf679"},
{file = "rapidfuzz-3.12.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a940aa71a7f37d7f0daac186066bf6668d4d3b7e7ef464cb50bc7ba89eae1f51"},
{file = "rapidfuzz-3.12.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ec9eaf73501c9a7de2c6938cb3050392e2ee0c5ca3921482acf01476b85a7226"},
{file = "rapidfuzz-3.12.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c5ec360694ac14bfaeb6aea95737cf1a6cf805b5fe8ea7fd28814706c7fa838"},
{file = "rapidfuzz-3.12.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b5e176524653ac46f1802bdd273a4b44a5f8d0054ed5013a8e8a4b72f254599"},
{file = "rapidfuzz-3.12.1-cp312-cp312-win32.whl", hash = "sha256:6f463c6f1c42ec90e45d12a6379e18eddd5cdf74138804d8215619b6f4d31cea"},
{file = "rapidfuzz-3.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:b894fa2b30cd6498a29e5c470cb01c6ea898540b7e048a0342775a5000531334"},
{file = "rapidfuzz-3.12.1-cp312-cp312-win_arm64.whl", hash = "sha256:43bb17056c5d1332f517b888c4e57846c4b5f936ed304917eeb5c9ac85d940d4"},
{file = "rapidfuzz-3.12.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:97f824c15bc6933a31d6e3cbfa90188ba0e5043cf2b6dd342c2b90ee8b3fd47c"},
{file = "rapidfuzz-3.12.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a973b3f5cabf931029a3ae4a0f72e3222e53d412ea85fc37ddc49e1774f00fbf"},
{file = "rapidfuzz-3.12.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df7880e012228722dec1be02b9ef3898ed023388b8a24d6fa8213d7581932510"},
{file = "rapidfuzz-3.12.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c78582f50e75e6c2bc38c791ed291cb89cf26a3148c47860c1a04d6e5379c8e"},
{file = "rapidfuzz-3.12.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2d7d9e6a04d8344b0198c96394c28874086888d0a2b2f605f30d1b27b9377b7d"},
{file = "rapidfuzz-3.12.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5620001fd4d6644a2f56880388179cc8f3767670f0670160fcb97c3b46c828af"},
{file = "rapidfuzz-3.12.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0666ab4c52e500af7ba5cc17389f5d15c0cdad06412c80312088519fdc25686d"},
{file = "rapidfuzz-3.12.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:27b4d440fa50b50c515a91a01ee17e8ede719dca06eef4c0cccf1a111a4cfad3"},
{file = "rapidfuzz-3.12.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83dccfd5a754f2a0e8555b23dde31f0f7920601bfa807aa76829391ea81e7c67"},
{file = "rapidfuzz-3.12.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b572b634740e047c53743ed27a1bb3b4f93cf4abbac258cd7af377b2c4a9ba5b"},
{file = "rapidfuzz-3.12.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7fa7b81fb52902d5f78dac42b3d6c835a6633b01ddf9b202a3ca8443be4b2d6a"},
{file = "rapidfuzz-3.12.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1d4fbff980cb6baef4ee675963c081f7b5d6580a105d6a4962b20f1f880e1fb"},
{file = "rapidfuzz-3.12.1-cp313-cp313-win32.whl", hash = "sha256:3fe8da12ea77271097b303fa7624cfaf5afd90261002314e3b0047d36f4afd8d"},
{file = "rapidfuzz-3.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:6f7e92fc7d2a7f02e1e01fe4f539324dfab80f27cb70a30dd63a95445566946b"},
{file = "rapidfuzz-3.12.1-cp313-cp313-win_arm64.whl", hash = "sha256:e31be53d7f4905a6a038296d8b773a79da9ee9f0cd19af9490c5c5a22e37d2e5"},
{file = "rapidfuzz-3.12.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bef5c91d5db776523530073cda5b2a276283258d2f86764be4a008c83caf7acd"},
{file = "rapidfuzz-3.12.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:841e0c2a5fbe8fc8b9b1a56e924c871899932c0ece7fbd970aa1c32bfd12d4bf"},
{file = "rapidfuzz-3.12.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:046fc67f3885d94693a2151dd913aaf08b10931639cbb953dfeef3151cb1027c"},
{file = "rapidfuzz-3.12.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4d2d39b2e76c17f92edd6d384dc21fa020871c73251cdfa017149358937a41d"},
{file = "rapidfuzz-3.12.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c5857dda85165b986c26a474b22907db6b93932c99397c818bcdec96340a76d5"},
{file = "rapidfuzz-3.12.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c26cd1b9969ea70dbf0dbda3d2b54ab4b2e683d0fd0f17282169a19563efeb1"},
{file = "rapidfuzz-3.12.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf56ea4edd69005786e6c80a9049d95003aeb5798803e7a2906194e7a3cb6472"},
{file = "rapidfuzz-3.12.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fbe7580b5fb2db8ebd53819171ff671124237a55ada3f64d20fc9a149d133960"},
{file = "rapidfuzz-3.12.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:018506a53c3b20dcbda8c93d4484b9eb1764c93d5ea16be103cf6b0d8b11d860"},
{file = "rapidfuzz-3.12.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:325c9c71b737fcd32e2a4e634c430c07dd3d374cfe134eded3fe46e4c6f9bf5d"},
{file = "rapidfuzz-3.12.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:930756639643e3aa02d3136b6fec74e5b9370a24f8796e1065cd8a857a6a6c50"},
{file = "rapidfuzz-3.12.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0acbd27543b158cb915fde03877383816a9e83257832818f1e803bac9b394900"},
{file = "rapidfuzz-3.12.1-cp39-cp39-win32.whl", hash = "sha256:80ff9283c54d7d29b2d954181e137deee89bec62f4a54675d8b6dbb6b15d3e03"},
{file = "rapidfuzz-3.12.1-cp39-cp39-win_amd64.whl", hash = "sha256:fd37e53f0ed239d0cec27b250cec958982a8ba252ce64aa5e6052de3a82fa8db"},
{file = "rapidfuzz-3.12.1-cp39-cp39-win_arm64.whl", hash = "sha256:4a4422e4f73a579755ab60abccb3ff148b5c224b3c7454a13ca217dfbad54da6"},
{file = "rapidfuzz-3.12.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b7cba636c32a6fc3a402d1cb2c70c6c9f8e6319380aaf15559db09d868a23e56"},
{file = "rapidfuzz-3.12.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b79286738a43e8df8420c4b30a92712dec6247430b130f8e015c3a78b6d61ac2"},
{file = "rapidfuzz-3.12.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dc1937198e7ff67e217e60bfa339f05da268d91bb15fec710452d11fe2fdf60"},
{file = "rapidfuzz-3.12.1-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b85817a57cf8db32dd5d2d66ccfba656d299b09eaf86234295f89f91be1a0db2"},
{file = "rapidfuzz-3.12.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04283c6f3e79f13a784f844cd5b1df4f518ad0f70c789aea733d106c26e1b4fb"},
{file = "rapidfuzz-3.12.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a718f740553aad5f4daef790191511da9c6eae893ee1fc2677627e4b624ae2db"},
{file = "rapidfuzz-3.12.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cbdf145c7e4ebf2e81c794ed7a582c4acad19e886d5ad6676086369bd6760753"},
{file = "rapidfuzz-3.12.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:0d03ad14a26a477be221fddc002954ae68a9e2402b9d85433f2d0a6af01aa2bb"},
{file = "rapidfuzz-3.12.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1187aeae9c89e838d2a0a2b954b4052e4897e5f62e5794ef42527bf039d469e"},
{file = "rapidfuzz-3.12.1-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd47dfb1bca9673a48b923b3d988b7668ee8efd0562027f58b0f2b7abf27144c"},
{file = "rapidfuzz-3.12.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:187cdb402e223264eebed2fe671e367e636a499a7a9c82090b8d4b75aa416c2a"},
{file = "rapidfuzz-3.12.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d6899b41bf6c30282179f77096c1939f1454836440a8ab05b48ebf7026a3b590"},
{file = "rapidfuzz-3.12.1.tar.gz", hash = "sha256:6a98bbca18b4a37adddf2d8201856441c26e9c981d8895491b5bc857b5f780eb"},
]
[package.extras]
@ -1852,21 +1819,6 @@ files = [
{file = "stackprinter-0.2.12.tar.gz", hash = "sha256:271efc75ebdcc1554e58168ea7779f98066d54a325f57c7dc19f10fa998ef01e"},
]
[[package]]
name = "tabcompleter"
version = "1.4.0"
description = "tabcompleter --- Autocompletion in the Python console."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "tabcompleter-1.4.0-py3-none-any.whl", hash = "sha256:d744aa735b49c0a6cc2fb8fcd40077fec47425e4388301010b14e6ce3311368b"},
{file = "tabcompleter-1.4.0.tar.gz", hash = "sha256:7562a9938e62f8e7c3be612c3ac4e14c5ec4307b58ba9031c148260e866e8814"},
]
[package.dependencies]
pyreadline3 = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "text-unidecode"
version = "1.3"
@ -1881,18 +1833,18 @@ files = [
[[package]]
name = "tinytag"
version = "1.10.1"
description = "Read music meta data and length of MP3, OGG, OPUS, MP4, M4A, FLAC, WMA and Wave files"
version = "2.0.0"
description = "Read audio file metadata"
optional = false
python-versions = ">=2.7"
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "tinytag-1.10.1-py3-none-any.whl", hash = "sha256:e437654d04c966fbbbdbf807af61eb9759f1d80e4173a7d26202506b37cfdaf0"},
{file = "tinytag-1.10.1.tar.gz", hash = "sha256:122a63b836f85094aacca43fc807aaee3290be3de17d134f5f4a08b509ae268f"},
{file = "tinytag-2.0.0-py3-none-any.whl", hash = "sha256:971b9dceae2d1de73b5e8300639ea0b41454633b899426e702aed15f0e72a9b4"},
{file = "tinytag-2.0.0.tar.gz", hash = "sha256:d041f53d15553bb148549bfbc7feab445caf7105ba95fa2ecb9827bb06b62275"},
]
[package.extras]
tests = ["flake8", "pytest", "pytest-cov"]
tests = ["coverage", "mypy", "pycodestyle", "pylint", "pytest"]
[[package]]
name = "toml"
@ -1928,7 +1880,7 @@ version = "6.1.0.20241221"
description = "Typing stubs for psutil"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
groups = ["main"]
files = [
{file = "types_psutil-6.1.0.20241221-py3-none-any.whl", hash = "sha256:8498dbe13285a9ba7d4b2fa934c569cc380efc74e3dacdb34ae16d2cdf389ec3"},
{file = "types_psutil-6.1.0.20241221.tar.gz", hash = "sha256:600f5a36bd5e0eb8887f0e3f3ff2cf154d90690ad8123c8a707bba4ab94d3185"},
@ -2039,4 +1991,4 @@ test = ["websockets"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<4.0"
content-hash = "2bb56bdbdce359e4f9026e74e0a62dd1a3b5099f56ce7a5c9cfaeff00db1915b"
content-hash = "f3ae28ded40829aef87731c3454146d49e8d1da824b07d264b1abfb678a9dac8"

View File

@ -8,43 +8,45 @@ authors = [
readme = "README.md"
requires-python = ">=3.11,<4.0"
dependencies = [
"tinytag>=1.10.1",
"SQLAlchemy>=2.0.36",
"python-vlc>=3.0.21203",
"mysqlclient>=2.2.5",
"mutagen>=1.47.0",
"alembic>=1.14.0",
"pydub>=0.25.1",
"python-slugify>=8.0.4",
"pyfzf>=0.3.1",
"pydymenu>=0.5.2",
"stackprinter>=0.2.10",
"pyqt6>=6.7.1",
"pyqtgraph>=0.13.3",
"colorlog>=6.9.0",
"alchemical>=1.0.2",
"obs-websocket-py>=1.0",
"pygame>=2.6.1",
"psutil>=6.1.0",
"pyqt6-webengine>=6.7.0",
"alembic>=1.14.0",
"colorlog>=6.9.0",
"fuzzywuzzy>=0.18.0",
"python-levenshtein>=0.26.1"
"mutagen>=1.47.0",
"mysqlclient>=2.2.5",
"obs-websocket-py>=1.0",
"psutil>=6.1.0",
"pydub>=0.25.1",
"pydymenu>=0.5.2",
"pyfzf>=0.3.1",
"pygame>=2.6.1",
"pyqt6>=6.7.1",
"pyqt6-webengine>=6.7.0",
"pyqtgraph>=0.13.3",
"python-levenshtein>=0.26.1",
"python-slugify>=8.0.4",
"python-vlc>=3.0.21203",
"SQLAlchemy>=2.0.36",
"stackprinter>=0.2.10",
"tinytag>=1.10.1",
"types-psutil>=6.0.0.20240621",
]
[tool.poetry]
package-mode = false
[tool.poetry.group.dev.dependencies]
ipdb = "^0.13.9"
pytest-qt = "^4.4.0"
pydub-stubs = "^0.25.1"
line-profiler = "^4.1.3"
flakehell = "^0.9.0"
ipdb = "^0.13.9"
line-profiler = "^4.2.0"
mypy = "^1.14.1"
pudb = "*"
mypy = "^1.7.0"
pytest-cov = "^5.0.0"
pytest = "^8.1.1"
black = "^24.3.0"
types-psutil = "^6.0.0.20240621"
pdbp = "^1.5.3"
pydub-stubs = "^0.25.1"
pytest = "^8.3.4"
pytest-qt = "^4.4.0"
black = "^25.1.0"
pytest-cov = "^6.0.0"
[build-system]
requires = ["poetry-core>=1.0.0"]

View File

@ -1,20 +1,33 @@
"""
Tests are named 'test_nnn_xxxx' where 'nn n' is a number. This is used to ensure that
the tests run in order as we rely (in some cases) upon the results of an earlier test.
Yes, we shouldn't do that.
"""
# Standard library imports
import os
import shutil
import tempfile
import unittest
from unittest.mock import patch
from unittest.mock import MagicMock, patch
# PyQt imports
from PyQt6.QtWidgets import QDialog, QFileDialog
# Third party imports
from mutagen.mp3 import MP3 # type: ignore
import pytest
from pytestqt.plugin import QtBot # type: ignore
# App imports
from config import Config
from app import musicmuster
from app.models import (
db,
Playlists,
Tracks,
)
from app import musicmuster
from config import Config
from file_importer import FileImporter
# Custom fixture to adapt qtbot for use with unittest.TestCase
@ -24,59 +37,453 @@ def qtbot_adapter(qapp, request):
request.cls.qtbot = QtBot(request)
# Wrapper to handle setup/teardown operations
def with_updown(function):
def test_wrapper(self, *args, **kwargs):
if callable(getattr(self, "up", None)):
self.up()
try:
function(self, *args, **kwargs)
finally:
if callable(getattr(self, "down", None)):
self.down()
test_wrapper.__doc__ = function.__doc__
return test_wrapper
# Fixture for tmp_path to be available in the class
@pytest.fixture(scope="class")
def class_tmp_path(request, tmp_path_factory):
"""Provide a class-wide tmp_path"""
request.cls.tmp_path = tmp_path_factory.mktemp("pytest_tmp")
# Apply the custom fixture to the test class
@pytest.mark.usefixtures("qtbot_adapter")
@pytest.mark.usefixtures("qtbot_adapter", "class_tmp_path")
class MyTestCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""Runs once before any test in this class"""
def up(self):
db.create_all()
self.widget = musicmuster.Window()
cls.widget = musicmuster.Window()
# Create a playlist for all tests
playlist_name = "file importer playlist"
with db.Session() as session:
playlist = Playlists(session, playlist_name)
self.widget.create_playlist_tab(playlist)
with self.qtbot.waitExposed(self.widget):
self.widget.show()
cls.widget.create_playlist_tab(playlist)
# Create our musicstore
cls.import_source = tempfile.mkdtemp(suffix="_MMsource_pytest", dir="/tmp")
Config.REPLACE_FILES_DEFAULT_SOURCE = cls.import_source
cls.musicstore = tempfile.mkdtemp(suffix="_MMstore_pytest", dir="/tmp")
Config.IMPORT_DESTINATION = cls.musicstore
@classmethod
def tearDownClass(cls):
"""Runs once after all tests"""
def down(self):
db.drop_all()
shutil.rmtree(cls.musicstore)
shutil.rmtree(cls.import_source)
@with_updown
@patch("file_importer.show_OK")
def test_import_no_files(self, mock_show_ok):
def setUp(self):
"""Runs before each test"""
with self.qtbot.waitExposed(self.widget):
self.widget.show()
def tearDown(self):
"""Runs after each test"""
self.widget.close() # Close UI to prevent side effects
def wait_for_workers(self, timeout: int = 10000):
"""
Let import threads workers run to completion
"""
def workers_empty():
assert FileImporter.workers == {}
self.qtbot.waitUntil(workers_empty, timeout=timeout)
def test_001_import_no_files(self):
"""Try importing with no files to import"""
self.widget.import_files_wrapper()
mock_show_ok.assert_called_once_with(
"File import",
f"No files in {Config.REPLACE_FILES_DEFAULT_SOURCE} to import",
None,
)
# @with_updown
# def test_import_no_files(self):
# """Try importing with no files to import"""
with patch("file_importer.show_OK") as mock_show_ok:
self.widget.import_files_wrapper()
mock_show_ok.assert_called_once_with(
"File import",
f"No files in {Config.REPLACE_FILES_DEFAULT_SOURCE} to import",
None,
)
# with patch("file_importer.show_OK") as mock_show_ok:
# self.widget.import_files_wrapper()
# mock_show_ok.assert_called_once_with(
# "File import",
# f"No files in {Config.REPLACE_FILES_DEFAULT_SOURCE} to import",
# None,
# )
def test_002_import_file_and_cancel(self):
"""Cancel file import"""
test_track_path = "testdata/isa.mp3"
shutil.copy(test_track_path, self.import_source)
with (
patch("file_importer.PickMatch") as MockPickMatch,
patch("file_importer.show_OK") as mock_show_ok,
):
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Rejected
mock_dialog_instance.selected_track_id = -1 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="I'm So Afraid (Fleetwood Mac)",
choices=[("Do not import", -1, ""), ("Import as new track", 0, "")],
default=1,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
# Ensure selected_track_id was accessed after dialog.exec()
assert mock_dialog_instance.selected_track_id < 0
mock_show_ok.assert_called_once_with(
"File not imported",
"isa.mp3 will not be imported because you asked not to import this file",
)
def test_003_import_first_file(self):
"""Import file into empty directory"""
test_track_path = "testdata/isa.mp3"
shutil.copy(test_track_path, self.import_source)
with patch("file_importer.PickMatch") as MockPickMatch:
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 0 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="I'm So Afraid (Fleetwood Mac)",
choices=[("Do not import", -1, ""), ("Import as new track", 0, "")],
default=1,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
# Ensure selected_track_id was accessed after dialog.exec()
assert mock_dialog_instance.selected_track_id == 0
self.wait_for_workers()
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 1
track = tracks[0]
assert track.title == "I'm So Afraid"
assert track.artist == "Fleetwood Mac"
track_file = os.path.join(
self.musicstore, os.path.basename(test_track_path)
)
assert track.path == track_file
assert os.path.exists(track_file)
assert os.listdir(self.import_source) == []
def test_004_import_second_file(self):
"""Import a second file"""
test_track_path = "testdata/lovecats.mp3"
shutil.copy(test_track_path, self.import_source)
with patch("file_importer.PickMatch") as MockPickMatch:
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 0 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="The Lovecats (The Cure)",
choices=[("Do not import", -1, ""), ("Import as new track", 0, "")],
default=1,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
# Ensure selected_track_id was accessed after dialog.exec()
assert mock_dialog_instance.selected_track_id == 0
self.wait_for_workers()
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 2
track = tracks[1]
assert track.title == "The Lovecats"
assert track.artist == "The Cure"
track_file = os.path.join(
self.musicstore, os.path.basename(test_track_path)
)
assert track.path == track_file
assert os.path.exists(track_file)
assert os.listdir(self.import_source) == []
def test_005_replace_file(self):
"""Import the same file again and update existing track"""
test_track_path = "testdata/lovecats.mp3"
shutil.copy(test_track_path, self.import_source)
with patch("file_importer.PickMatch") as MockPickMatch:
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 2 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="The Lovecats (The Cure)",
choices=[
("Do not import", -1, ""),
("Import as new track", 0, ""),
(
"The Lovecats (The Cure) (100%)",
2,
os.path.join(
self.musicstore, os.path.basename(test_track_path)
),
),
],
default=2,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
self.wait_for_workers()
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 2
track = tracks[1]
assert track.title == "The Lovecats"
assert track.artist == "The Cure"
assert track.id == 2
track_file = os.path.join(
self.musicstore, os.path.basename(test_track_path)
)
assert track.path == track_file
assert os.path.exists(track_file)
assert os.listdir(self.import_source) == []
def test_006_import_file_no_tags(self) -> None:
"""Try to import untagged file"""
test_track_path = "testdata/lovecats.mp3"
test_filename = os.path.basename(test_track_path)
shutil.copy(test_track_path, self.import_source)
import_file = os.path.join(self.import_source, test_filename)
assert os.path.exists(import_file)
# Remove tags
src = MP3(import_file)
src.delete()
src.save()
with patch("file_importer.show_OK") as mock_show_ok:
self.widget.import_files_wrapper()
mock_show_ok.assert_called_once_with(
"File not imported",
f"{test_filename} will not be imported because of tag errors "
f"(Missing tags in {import_file})",
)
def test_007_import_unreadable_file(self) -> None:
"""Import unreadable file"""
test_track_path = "testdata/lovecats.mp3"
test_filename = os.path.basename(test_track_path)
shutil.copy(test_track_path, self.import_source)
import_file = os.path.join(self.import_source, test_filename)
assert os.path.exists(import_file)
# Make undreadable
os.chmod(import_file, 0)
with patch("file_importer.show_OK") as mock_show_ok:
self.widget.import_files_wrapper()
mock_show_ok.assert_called_once_with(
"File not imported",
f"{test_filename} will not be imported because {import_file} is unreadable",
)
# clean up
os.chmod(import_file, 0o777)
os.unlink(import_file)
def test_008_import_new_file_existing_destination(self) -> None:
"""Import duplicate file"""
test_track_path = "testdata/lovecats.mp3"
test_filename = os.path.basename(test_track_path)
new_destination = os.path.join(self.musicstore, "lc2.mp3")
shutil.copy(test_track_path, self.import_source)
import_file = os.path.join(self.import_source, test_filename)
assert os.path.exists(import_file)
with (
patch("file_importer.PickMatch") as MockPickMatch,
patch.object(
QFileDialog, "getSaveFileName", return_value=(new_destination, "")
) as mock_file_dialog,
patch("file_importer.show_OK") as mock_show_ok,
):
mock_file_dialog.return_value = (
new_destination,
"",
) # Ensure mock correctly returns expected value
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 0 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="The Lovecats (The Cure)",
choices=[
("Do not import", -1, ""),
("Import as new track", 0, ""),
(
"The Lovecats (The Cure) (100%)",
2,
os.path.join(
self.musicstore, os.path.basename(test_track_path)
),
),
],
default=2,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
destination = os.path.join(self.musicstore, test_filename)
mock_show_ok.assert_called_once_with(
title="Desintation path exists",
msg=f"New import requested but default destination path ({destination}) "
"already exists. Click OK and choose where to save this track",
parent=None,
)
self.wait_for_workers()
# Ensure QFileDialog was called and returned expected value
assert mock_file_dialog.called # Ensure the mock was used
result = mock_file_dialog()
assert result[0] == new_destination # Validate return value
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 3
track = tracks[2]
assert track.title == "The Lovecats"
assert track.artist == "The Cure"
assert track.id == 3
assert track.path == new_destination
assert os.path.exists(new_destination)
assert os.listdir(self.import_source) == []
# Remove file so as not to interfere with later tests
session.delete(track)
tracks = Tracks.get_all(session)
assert len(tracks) == 2
session.commit()
os.unlink(new_destination)
assert not os.path.exists(new_destination)
def test_009_import_similar_file(self) -> None:
"""Import file with similar, but different, title"""
test_track_path = "testdata/lovecats.mp3"
test_filename = os.path.basename(test_track_path)
shutil.copy(test_track_path, self.import_source)
import_file = os.path.join(self.import_source, test_filename)
assert os.path.exists(import_file)
# Change title tag
src = MP3(import_file)
src["TIT2"].text[0] += " xyz"
src.save()
with patch("file_importer.PickMatch") as MockPickMatch:
# Create a mock instance of PickMatch
mock_dialog_instance = MagicMock()
MockPickMatch.return_value = mock_dialog_instance
# Simulate the user clicking OK in the dialog
mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.selected_track_id = 2 # Simulated return value
self.widget.import_files_wrapper()
# Ensure PickMatch was instantiated correctly
MockPickMatch.assert_called_once_with(
new_track_description="The Lovecats xyz (The Cure)",
choices=[
("Do not import", -1, ""),
("Import as new track", 0, ""),
(
"The Lovecats (The Cure) (93%)",
2,
os.path.join(
self.musicstore, os.path.basename(test_track_path)
),
),
],
default=2,
)
# Verify exec() was called
mock_dialog_instance.exec.assert_called_once()
self.wait_for_workers()
# Check track was imported
with db.Session() as session:
tracks = Tracks.get_all(session)
assert len(tracks) == 2
track = tracks[1]
assert track.title == "The Lovecats xyz"
assert track.artist == "The Cure"
assert track.id == 2
track_file = os.path.join(
self.musicstore, os.path.basename(test_track_path)
)
assert track.path == track_file
assert os.path.exists(track_file)
assert os.listdir(self.import_source) == []