Compare commits

..

6 Commits

Author SHA1 Message Date
Keith Edmunds
57f038c704 Implement row mark and paste
Fixed #132
2022-12-19 15:28:03 +00:00
Keith Edmunds
999a98e2ad Check before moving unplayed tracks
Fixes #151
2022-12-18 23:11:05 +00:00
Keith Edmunds
2ada8a27fe Tidy up log.py 2022-12-18 22:23:17 +00:00
Keith Edmunds
bd9c8a84b9 Implement stackprinter 2022-12-18 22:20:55 +00:00
Keith Edmunds
693e8f195d Notify when issue #147 occurs 2022-12-18 21:03:45 +00:00
Keith Edmunds
d9851adf65 Fix inability to play tracks with colon in path
Fixes #103
2022-12-17 19:47:17 +00:00
12 changed files with 242 additions and 42 deletions

5
.envrc
View File

@ -1,4 +1,9 @@
layout poetry layout poetry
export MAIL_PASSWORD="ewacyay5seu2qske"
export MAIL_PORT=587
export MAIL_SERVER="smtp.fastmail.com"
export MAIL_USERNAME="kae@midnighthax.com"
export MAIL_USE_TLS=True
branch=$(git branch --show-current) branch=$(git branch --show-current)
if on_git_branch master; then if on_git_branch master; then
export MM_ENV="PRODUCTION" export MM_ENV="PRODUCTION"

View File

@ -12,6 +12,7 @@ class Config(object):
CART_DIRECTORY = "/home/kae/radio/CartTracks" CART_DIRECTORY = "/home/kae/radio/CartTracks"
CARTS_COUNT = 10 CARTS_COUNT = 10
CARTS_HIDE = True CARTS_HIDE = True
COLON_IN_PATH_FIX = True
COLOUR_BITRATE_LOW = "#ffcdd2" COLOUR_BITRATE_LOW = "#ffcdd2"
COLOUR_BITRATE_MEDIUM = "#ffeb6f" COLOUR_BITRATE_MEDIUM = "#ffeb6f"
COLOUR_BITRATE_OK = "#dcedc8" COLOUR_BITRATE_OK = "#dcedc8"
@ -53,6 +54,7 @@ class Config(object):
DEFAULT_IMPORT_DIRECTORY = "/home/kae/Nextcloud/tmp" DEFAULT_IMPORT_DIRECTORY = "/home/kae/Nextcloud/tmp"
DEFAULT_OUTPUT_DIRECTORY = "/home/kae/music/Singles" DEFAULT_OUTPUT_DIRECTORY = "/home/kae/music/Singles"
DISPLAY_SQL = False DISPLAY_SQL = False
ERRORS_FROM = ['noreply@midnighthax.com']
ERRORS_TO = ['kae@midnighthax.com'] ERRORS_TO = ['kae@midnighthax.com']
FADE_STEPS = 20 FADE_STEPS = 20
FADE_TIME = 3000 FADE_TIME = 3000

View File

@ -1,8 +1,11 @@
import os import os
import psutil import psutil
import shutil import shutil
import smtplib
import ssl
import tempfile import tempfile
from email.message import EmailMessage
from mutagen.flac import FLAC # type: ignore from mutagen.flac import FLAC # type: ignore
from mutagen.mp3 import MP3 # type: ignore from mutagen.mp3 import MP3 # type: ignore
from pydub import effects from pydub import effects
@ -56,20 +59,14 @@ def fade_point(
return int(trim_ms) return int(trim_ms)
def file_is_readable(path: str, check_colon: bool = True) -> bool: def file_is_readable(path: str) -> bool:
""" """
Returns True if passed path is readable, else False Returns True if passed path is readable, else False
vlc cannot read files with a colon in the path vlc cannot read files with a colon in the path
""" """
if os.access(path, os.R_OK): return os.access(path, os.R_OK)
if check_colon:
return ':' not in path
else:
return True
return False
def get_audio_segment(path: str) -> Optional[AudioSegment]: def get_audio_segment(path: str) -> Optional[AudioSegment]:
@ -165,6 +162,32 @@ def leading_silence(
return min(trim_ms, len(audio_segment)) return min(trim_ms, len(audio_segment))
def send_mail(to_addr, from_addr, subj, body):
# From https://docs.python.org/3/library/email.examples.html
# Create a text/plain message
msg = EmailMessage()
msg.set_content(body)
msg['Subject'] = subj
msg['From'] = from_addr
msg['To'] = to_addr
# Send the message via SMTP server.
context = ssl.create_default_context()
try:
s = smtplib.SMTP(host=Config.MAIL_SERVER, port=Config.MAIL_PORT)
if Config.MAIL_USE_TLS:
s.starttls(context=context)
if Config.MAIL_USERNAME and Config.MAIL_PASSWORD:
s.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD)
s.send_message(msg)
except Exception as e:
print(e)
finally:
s.quit()
def ms_to_mmss(ms: int, decimals: int = 0, negative: bool = False) -> str: def ms_to_mmss(ms: int, decimals: int = 0, negative: bool = False) -> str:
"""Convert milliseconds to mm:ss""" """Convert milliseconds to mm:ss"""

View File

@ -2,6 +2,7 @@
import logging import logging
import logging.handlers import logging.handlers
import stackprinter
import sys import sys
import traceback import traceback
@ -54,26 +55,24 @@ syslog.addFilter(local_filter)
stderr.addFilter(local_filter) stderr.addFilter(local_filter)
stderr.addFilter(debug_filter) stderr.addFilter(debug_filter)
# create formatter and add it to the handlers
stderr_fmt = logging.Formatter('[%(asctime)s] %(leveltag)s: %(message)s', class VerboseExceptionFormatter(logging.Formatter):
datefmt='%H:%M:%S') def formatException(self, exc_info):
syslog_fmt = logging.Formatter( msg = stackprinter.format(exc_info)
'[%(name)s] %(module)s.%(funcName)s - %(leveltag)s: %(message)s' lines = msg.split('\n')
) lines_indented = ["" + line + "\n" for line in lines]
stderr.setFormatter(stderr_fmt) msg_indented = "".join(lines_indented)
syslog.setFormatter(syslog_fmt) return msg_indented
stderr_fmt = '[%(asctime)s] %(leveltag)s: %(message)s'
stderr_formatter = VerboseExceptionFormatter(stderr_fmt, datefmt='%H:%M:%S')
stderr.setFormatter(stderr_formatter)
syslog_fmt = '[%(name)s] %(module)s.%(funcName)s - %(leveltag)s: %(message)s'
syslog_formatter = VerboseExceptionFormatter(syslog_fmt)
syslog.setFormatter(syslog_formatter)
# add the handlers to the log # add the handlers to the log
log.addHandler(stderr) log.addHandler(stderr)
log.addHandler(syslog) log.addHandler(syslog)
def log_uncaught_exceptions(ex_cls, ex, tb):
print("\033[1;31;47m")
logging.critical(''.join(traceback.format_tb(tb)))
print("\033[1;37;40m")
logging.critical('{0}: {1}'.format(ex_cls, ex))
sys.excepthook = log_uncaught_exceptions

View File

@ -23,6 +23,7 @@ from sqlalchemy import (
select, select,
String, String,
UniqueConstraint, UniqueConstraint,
update,
) )
# from sqlalchemy.exc import IntegrityError # from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import ( from sqlalchemy.orm import (
@ -555,6 +556,23 @@ class PlaylistRows(Base):
return plrs return plrs
@staticmethod
def move_rows_down(session: Session, playlist_id: int, starting_row: int,
move_by: int) -> None:
"""
Create space to insert move_by additional rows by incremented row
number from starting_row to end of playlist
"""
session.execute(
update(PlaylistRows)
.where(
(PlaylistRows.playlist_id == playlist_id),
(PlaylistRows.row_number >= starting_row)
)
.values(row_number=PlaylistRows.row_number + move_by)
)
class Settings(Base): class Settings(Base):
"""Manage settings""" """Manage settings"""

View File

@ -111,7 +111,11 @@ class Music:
status = -1 status = -1
self.track_path = path self.track_path = path
self.player = self.VLC.media_player_new(path) if Config.COLON_IN_PATH_FIX:
media = self.VLC.media_new_path(path)
self.player = media.player_new_from_media()
else:
self.player = self.VLC.media_player_new(path)
if self.player: if self.player:
self.player.audio_set_volume(self.max_volume) self.player.audio_set_volume(self.max_volume)
self.current_track_start_time = datetime.now() self.current_track_start_time = datetime.now()

View File

@ -2,6 +2,7 @@
from log import log from log import log
import argparse import argparse
import stackprinter
import subprocess import subprocess
import sys import sys
import threading import threading
@ -141,6 +142,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.next_track_playlist_tab: Optional[PlaylistTab] = None self.next_track_playlist_tab: Optional[PlaylistTab] = None
self.previous_track: Optional[TrackData] = None self.previous_track: Optional[TrackData] = None
self.previous_track_position: Optional[int] = None self.previous_track_position: Optional[int] = None
self.selected_plrs = None
# Set colours that will be used by playlist row stripes # Set colours that will be used by playlist row stripes
palette = QPalette() palette = QPalette()
@ -391,10 +393,12 @@ class Window(QMainWindow, Ui_MainWindow):
self.actionImport.triggered.connect(self.import_track) self.actionImport.triggered.connect(self.import_track)
self.actionInsertSectionHeader.triggered.connect(self.insert_header) self.actionInsertSectionHeader.triggered.connect(self.insert_header)
self.actionInsertTrack.triggered.connect(self.insert_track) self.actionInsertTrack.triggered.connect(self.insert_track)
self.actionMark_for_moving.triggered.connect(self.cut_rows)
self.actionMoveSelected.triggered.connect(self.move_selected) self.actionMoveSelected.triggered.connect(self.move_selected)
self.actionNew_from_template.triggered.connect(self.new_from_template) self.actionNew_from_template.triggered.connect(self.new_from_template)
self.actionNewPlaylist.triggered.connect(self.create_and_show_playlist) self.actionNewPlaylist.triggered.connect(self.create_and_show_playlist)
self.actionOpenPlaylist.triggered.connect(self.open_playlist) self.actionOpenPlaylist.triggered.connect(self.open_playlist)
self.actionPaste.triggered.connect(self.paste_rows)
self.actionPlay_next.triggered.connect(self.play_next) self.actionPlay_next.triggered.connect(self.play_next)
self.actionSave_as_template.triggered.connect(self.save_as_template) self.actionSave_as_template.triggered.connect(self.save_as_template)
self.actionSearch.triggered.connect(self.search_playlist) self.actionSearch.triggered.connect(self.search_playlist)
@ -449,6 +453,17 @@ class Window(QMainWindow, Ui_MainWindow):
idx = self.tabPlaylist.addTab(playlist_tab, playlist.name) idx = self.tabPlaylist.addTab(playlist_tab, playlist.name)
self.tabPlaylist.setCurrentIndex(idx) self.tabPlaylist.setCurrentIndex(idx)
def cut_rows(self) -> None:
"""
Cut rows ready for pasting.
"""
with Session() as session:
# Save the selected PlaylistRows items ready for a later
# paste
self.selected_plrs = (
self.visible_playlist_tab().get_selected_playlistrows(session))
def debug(self): def debug(self):
"""Invoke debugger""" """Invoke debugger"""
@ -826,7 +841,11 @@ class Window(QMainWindow, Ui_MainWindow):
with Session() as session: with Session() as session:
unplayed_playlist_rows = PlaylistRows.get_unplayed_rows( unplayed_playlist_rows = PlaylistRows.get_unplayed_rows(
session, playlist_id) session, playlist_id)
self.move_playlist_rows(session, unplayed_playlist_rows) if helpers.ask_yes_no("Move tracks",
f"Move {len(unplayed_playlist_rows)} tracks:"
" Are you sure?"
):
self.move_playlist_rows(session, unplayed_playlist_rows)
def new_from_template(self) -> None: def new_from_template(self) -> None:
"""Create new playlist from template""" """Create new playlist from template"""
@ -859,6 +878,68 @@ class Window(QMainWindow, Ui_MainWindow):
playlist.mark_open(session) playlist.mark_open(session)
self.create_playlist_tab(session, playlist) self.create_playlist_tab(session, playlist)
def paste_rows(self) -> None:
"""
Paste earlier cut rows.
Process:
- ensure we have some cut rows
- if not pasting at end of playlist, move later rows down
- update plrs with correct playlist and row
- if moving between playlists: renumber source playlist rows
- else: check integrity of playlist rows
"""
if not self.selected_plrs:
return
playlist_tab = self.visible_playlist_tab()
dst_playlist_id = playlist_tab.playlist_id
with Session() as session:
# Create space in destination playlist
if playlist_tab.selectionModel().hasSelection():
row = playlist_tab.currentRow()
PlaylistRows.move_rows_down(session, dst_playlist_id,
row, len(self.selected_plrs))
session.commit()
src_playlist_id = None
dst_row = row
for plr in self.selected_plrs:
# Update moved rows
session.add(plr)
if not src_playlist_id:
src_playlist_id = plr.playlist_id
plr.playlist_id = dst_playlist_id
plr.row_number = row
row += 1
# Need to commit each row individually else only one row
# gets updated (don't know why)
session.commit()
# Update display
self.visible_playlist_tab().populate(session, dst_playlist_id)
# If source playlist is not destination playlist, fixup row
# numbers and update display
if src_playlist_id != dst_playlist_id:
PlaylistRows.fixup_rownumbers(session, src_playlist_id)
# Update source playlist_tab if visible (if not visible, it
# will be re-populated when it is opened)
source_playlist_tab = None
for tab in range(self.tabPlaylist.count()):
if self.tabPlaylist.widget(tab).playlist_id == \
src_playlist_id:
source_playlist_tab = self.tabPlaylist.widget(tab)
break
if source_playlist_tab:
source_playlist_tab.populate(session, src_playlist_id)
# Reset so rows can't be repasted
self.selected_plrs = None
def play_next(self) -> None: def play_next(self) -> None:
""" """
Play next track. Play next track.
@ -1557,6 +1638,13 @@ if __name__ == "__main__":
win = Window() win = Window()
win.show() win.show()
sys.exit(app.exec()) sys.exit(app.exec())
except Exception: except Exception as exc:
msg = "Unhandled Exception caught by musicmuster.main()" from helpers import send_mail
log.exception(msg, exc_info=True, stack_info=True)
msg = stackprinter.format(exc)
send_mail(Config.ERRORS_TO, Config.ERRORS_FROM,
"Exception from musicmuster", msg)
print("\033[1;31;47mUnhandled exception starts\033[1;37;40m")
stackprinter.show(style="darkbg2")
print("\033[1;31;47mUnhandled exception ends\033[1;37;40m")

View File

@ -1,4 +1,5 @@
import re import re
import stackprinter
import subprocess import subprocess
import threading import threading
@ -45,6 +46,7 @@ from helpers import (
get_relative_date, get_relative_date,
ms_to_mmss, ms_to_mmss,
open_in_audacity, open_in_audacity,
send_mail,
set_track_metadata, set_track_metadata,
) )
from log import log from log import log
@ -285,6 +287,21 @@ class PlaylistTab(QTableWidget):
else: else:
current = next_row = False current = next_row = False
# Cut/paste
act_cut = self.menu.addAction(
"Mark for moving")
act_cut.triggered.connect(
lambda: self.musicmuster.cut_rows())
act_paste = self.menu.addAction(
"Paste")
act_paste.setDisabled(
self.musicmuster.selected_plrs is None)
act_paste.triggered.connect(
lambda: self.musicmuster.paste_rows())
self.menu.addSeparator()
if track_row: if track_row:
# Info # Info
act_info = self.menu.addAction('Info') act_info = self.menu.addAction('Info')
@ -435,7 +452,7 @@ class PlaylistTab(QTableWidget):
update_current = row == self._get_current_track_row() update_current = row == self._get_current_track_row()
update_next = row == self._get_next_track_row() update_next = row == self._get_next_track_row()
if self.edit_cell_type == TITLE: if self.edit_cell_type == TITLE:
log.debug(f"KAE: _cell_changed:438, {new_text=}") log.debug(f"KAE: _cell_changed:440, {new_text=}")
track.title = new_text track.title = new_text
elif self.edit_cell_type == ARTIST: elif self.edit_cell_type == ARTIST:
track.artist = new_text track.artist = new_text
@ -614,7 +631,7 @@ class PlaylistTab(QTableWidget):
self.setItem(row, START_GAP, start_gap_item) self.setItem(row, START_GAP, start_gap_item)
title_item = QTableWidgetItem(row_data.track.title) title_item = QTableWidgetItem(row_data.track.title)
log.debug(f"KAE: insert_row:615, {title_item.text()=}") log.debug(f"KAE: insert_row:619, {title_item.text()=}")
self.setItem(row, TITLE, title_item) self.setItem(row, TITLE, title_item)
artist_item = QTableWidgetItem(row_data.track.artist) artist_item = QTableWidgetItem(row_data.track.artist)
@ -824,14 +841,12 @@ class PlaylistTab(QTableWidget):
"""Scroll currently-playing row to top""" """Scroll currently-playing row to top"""
current_row = self._get_current_track_row() current_row = self._get_current_track_row()
log.debug(f"KAE: playlists.scroll_current_to_top(), {current_row=}")
self._scroll_to_top(current_row) self._scroll_to_top(current_row)
def scroll_next_to_top(self) -> None: def scroll_next_to_top(self) -> None:
"""Scroll nextly-playing row to top""" """Scroll nextly-playing row to top"""
next_row = self._get_next_track_row() next_row = self._get_next_track_row()
log.debug(f"KAE: playlists.scroll_next_to_top(), {next_row=}")
self._scroll_to_top(next_row) self._scroll_to_top(next_row)
def set_search(self, text: str) -> None: def set_search(self, text: str) -> None:
@ -1515,11 +1530,13 @@ class PlaylistTab(QTableWidget):
return self.selectionModel().selectedRows()[0].row() return self.selectionModel().selectedRows()[0].row()
def _get_selected_rows(self) -> List[int]: def _get_selected_rows(self) -> List[int]:
"""Return a list of selected row numbers""" """Return a list of selected row numbers sorted by row"""
# Use a set to deduplicate result (a selected row will have all # Use a set to deduplicate result (a selected row will have all
# items in that row selected) # items in that row selected)
return [row for row in set([a.row() for a in self.selectedItems()])] return sorted(
[row for row in set([a.row() for a in self.selectedItems()])]
)
def _get_unreadable_track_rows(self) -> List[int]: def _get_unreadable_track_rows(self) -> List[int]:
"""Return rows marked as unreadable, or None""" """Return rows marked as unreadable, or None"""
@ -1953,8 +1970,12 @@ class PlaylistTab(QTableWidget):
# FIXME temporary workaround to issue #147 # FIXME temporary workaround to issue #147
try: try:
self.item(playlist_row.row_number, column).setText(new_text) self.item(playlist_row.row_number, column).setText(new_text)
except AttributeError: except AttributeError as exc:
pass msg = f"Issue 147 occurred. {playlist_row=}, {additional_text=}"
msg += "\n\n"
msg += stackprinter.format(exc)
helpers.send_mail(Config.ERRORS_TO, Confit.ERRORS_FROM,
"Issue #147 from musicmuster", msg)
def _update_row(self, session, row: int, track: Tracks) -> None: def _update_row(self, session, row: int, track: Tracks) -> None:
""" """
@ -1969,7 +1990,7 @@ class PlaylistTab(QTableWidget):
item_startgap.setBackground(QColor("white")) item_startgap.setBackground(QColor("white"))
item_title = self.item(row, TITLE) item_title = self.item(row, TITLE)
log.debug(f"KAE: _update_row:1958, {track.title=}") log.debug(f"KAE: _update_row:1978, {track.title=}")
item_title.setText(track.title) item_title.setText(track.title)
item_artist = self.item(row, ARTIST) item_artist = self.item(row, ARTIST)

View File

@ -866,6 +866,9 @@ padding-left: 8px;</string>
<addaction name="action_Clear_selection"/> <addaction name="action_Clear_selection"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionEnable_controls"/> <addaction name="actionEnable_controls"/>
<addaction name="separator"/>
<addaction name="actionMark_for_moving"/>
<addaction name="actionPaste"/>
</widget> </widget>
<widget class="QMenu" name="menuSearc_h"> <widget class="QMenu" name="menuSearc_h">
<property name="title"> <property name="title">
@ -1178,6 +1181,22 @@ padding-left: 8px;</string>
<string>Edit cart &amp;1...</string> <string>Edit cart &amp;1...</string>
</property> </property>
</action> </action>
<action name="actionMark_for_moving">
<property name="text">
<string>Mark for moving</string>
</property>
<property name="shortcut">
<string>Ctrl+C</string>
</property>
</action>
<action name="actionPaste">
<property name="text">
<string>Paste</string>
</property>
<property name="shortcut">
<string>Ctrl+V</string>
</property>
</action>
</widget> </widget>
<customwidgets> <customwidgets>
<customwidget> <customwidget>

View File

@ -499,6 +499,10 @@ class Ui_MainWindow(object):
self.actionDebug.setObjectName("actionDebug") self.actionDebug.setObjectName("actionDebug")
self.actionAdd_cart = QtWidgets.QAction(MainWindow) self.actionAdd_cart = QtWidgets.QAction(MainWindow)
self.actionAdd_cart.setObjectName("actionAdd_cart") self.actionAdd_cart.setObjectName("actionAdd_cart")
self.actionMark_for_moving = QtWidgets.QAction(MainWindow)
self.actionMark_for_moving.setObjectName("actionMark_for_moving")
self.actionPaste = QtWidgets.QAction(MainWindow)
self.actionPaste.setObjectName("actionPaste")
self.menuFile.addAction(self.actionNewPlaylist) self.menuFile.addAction(self.actionNewPlaylist)
self.menuFile.addAction(self.actionOpenPlaylist) self.menuFile.addAction(self.actionOpenPlaylist)
self.menuFile.addAction(self.actionClosePlaylist) self.menuFile.addAction(self.actionClosePlaylist)
@ -530,6 +534,9 @@ class Ui_MainWindow(object):
self.menuPlaylist.addAction(self.action_Clear_selection) self.menuPlaylist.addAction(self.action_Clear_selection)
self.menuPlaylist.addSeparator() self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionEnable_controls) self.menuPlaylist.addAction(self.actionEnable_controls)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionMark_for_moving)
self.menuPlaylist.addAction(self.actionPaste)
self.menuSearc_h.addAction(self.actionSearch) self.menuSearc_h.addAction(self.actionSearch)
self.menuSearc_h.addAction(self.actionFind_next) self.menuSearc_h.addAction(self.actionFind_next)
self.menuSearc_h.addAction(self.actionFind_previous) self.menuSearc_h.addAction(self.actionFind_previous)
@ -635,5 +642,9 @@ class Ui_MainWindow(object):
self.actionNew_from_template.setText(_translate("MainWindow", "New from template...")) self.actionNew_from_template.setText(_translate("MainWindow", "New from template..."))
self.actionDebug.setText(_translate("MainWindow", "Debug")) self.actionDebug.setText(_translate("MainWindow", "Debug"))
self.actionAdd_cart.setText(_translate("MainWindow", "Edit cart &1...")) self.actionAdd_cart.setText(_translate("MainWindow", "Edit cart &1..."))
self.actionMark_for_moving.setText(_translate("MainWindow", "Mark for moving"))
self.actionMark_for_moving.setShortcut(_translate("MainWindow", "Ctrl+C"))
self.actionPaste.setText(_translate("MainWindow", "Paste"))
self.actionPaste.setShortcut(_translate("MainWindow", "Ctrl+V"))
from infotabs import InfoTabs from infotabs import InfoTabs
import icons_rc import icons_rc

11
poetry.lock generated
View File

@ -624,6 +624,14 @@ pure-eval = "*"
[package.extras] [package.extras]
tests = ["pytest", "typeguard", "pygments", "littleutils", "cython"] tests = ["pytest", "typeguard", "pygments", "littleutils", "cython"]
[[package]]
name = "stackprinter"
version = "0.2.10"
description = "Debug-friendly stack traces, with variable values and semantic highlighting"
category = "main"
optional = false
python-versions = ">=3.4"
[[package]] [[package]]
name = "text-unidecode" name = "text-unidecode"
version = "1.3" version = "1.3"
@ -708,7 +716,7 @@ python-versions = "*"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.9" python-versions = "^3.9"
content-hash = "91e055875df86707e1ce1544b1d29126265011d750897912daa37af3fe005498" content-hash = "0fdda77377246e18b5e85459fa2c26173f14467f32e71c576b30fa0899ced8b0"
[metadata.files] [metadata.files]
alembic = [ alembic = [
@ -1073,6 +1081,7 @@ stack-data = [
{file = "stack_data-0.2.0-py3-none-any.whl", hash = "sha256:999762f9c3132308789affa03e9271bbbe947bf78311851f4d485d8402ed858e"}, {file = "stack_data-0.2.0-py3-none-any.whl", hash = "sha256:999762f9c3132308789affa03e9271bbbe947bf78311851f4d485d8402ed858e"},
{file = "stack_data-0.2.0.tar.gz", hash = "sha256:45692d41bd633a9503a5195552df22b583caf16f0b27c4e58c98d88c8b648e12"}, {file = "stack_data-0.2.0.tar.gz", hash = "sha256:45692d41bd633a9503a5195552df22b583caf16f0b27c4e58c98d88c8b648e12"},
] ]
stackprinter = []
text-unidecode = [] text-unidecode = []
thefuzz = [] thefuzz = []
tinytag = [ tinytag = [

View File

@ -23,6 +23,7 @@ thefuzz = "^0.19.0"
python-Levenshtein = "^0.12.2" python-Levenshtein = "^0.12.2"
pyfzf = "^0.3.1" pyfzf = "^0.3.1"
pydymenu = "^0.5.2" pydymenu = "^0.5.2"
stackprinter = "^0.2.10"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
ipdb = "^0.13.9" ipdb = "^0.13.9"