Compare commits
17 Commits
d3a709642b
...
4e73ea6e6a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e73ea6e6a | ||
|
|
c9b45848dd | ||
|
|
fd0d8b15f7 | ||
|
|
7d0e1c809f | ||
|
|
5cae8e4b19 | ||
|
|
8177e03387 | ||
|
|
f4923314d8 | ||
|
|
24787578bc | ||
|
|
1f4e7cb054 | ||
|
|
92e1a1cac8 | ||
|
|
52a773176c | ||
|
|
cedc7180d4 | ||
|
|
728ac0f8dc | ||
|
|
4741c1d33f | ||
|
|
aa52f33d58 | ||
|
|
2f18ef5f44 | ||
|
|
4927f237ab |
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
36
app/log.py
Executable file → Normal 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):
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
from newlogger import log
|
||||
from log import log
|
||||
|
||||
|
||||
# Testing
|
||||
def fa():
|
||||
log.debug("fa Debug message")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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("")
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
354
poetry.lock
generated
@ -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"
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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) == []
|
||||
|
||||
Loading…
Reference in New Issue
Block a user