This commit is contained in:
2024-12-27 22:00:28 +09:00
commit 2353324570
56 changed files with 8265 additions and 0 deletions

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,22 @@
from typing import ClassVar
class SingletonMeta(type):
"""
The Singleton class can be implemented in different ways in Python. Some
possible methods include: base class, decorator, metaclass. We will use the
metaclass because it is best suited for this purpose.
"""
_instances: ClassVar[dict] = {}
def __call__(cls, *args, **kwargs):
"""
Possible changes to the value of the `__init__` argument do not affect
the returned instance.
"""
if cls not in cls._instances:
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]

View File

@ -0,0 +1,55 @@
import base64
import pathlib
from Crypto.Cipher import AES
from Crypto.Util import Counter
def decrypt_security_token(security_token: str) -> (str, str):
"""
Decrypts security token into key and nonce pair
security_token should match the securityToken value from the web response
"""
# Do not change this
master_key = "UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754="
# Decode the base64 strings to ascii strings
master_key = base64.b64decode(master_key)
security_token = base64.b64decode(security_token)
# Get the IV from the first 16 bytes of the securityToken
iv = security_token[:16]
encrypted_st = security_token[16:]
# Initialize decryptor
decryptor = AES.new(master_key, AES.MODE_CBC, iv)
# Decrypt the security token
decrypted_st = decryptor.decrypt(encrypted_st)
# Get the audio stream decryption key and nonce from the decrypted security token
key = decrypted_st[:16]
nonce = decrypted_st[16:24]
return key, nonce
def decrypt_file(path_file_encrypted: pathlib.Path, path_file_destination: pathlib.Path, key: str, nonce: str) -> None:
"""
Decrypts an encrypted MQA file given the file, key and nonce.
TODO: Is it really only necessary for MQA of for all other formats, too?
"""
# Initialize counter and file decryptor
counter = Counter.new(64, prefix=nonce, initial_value=0)
decryptor = AES.new(key, AES.MODE_CTR, counter=counter)
# Open and decrypt
with path_file_encrypted.open("rb") as f_src:
audio_decrypted = decryptor.decrypt(f_src.read())
# Replace with decrypted file
with path_file_destination.open("wb") as f_dst:
f_dst.write(audio_decrypted)

View File

@ -0,0 +1,14 @@
class LoginError(Exception):
pass
class MediaUnknown(Exception):
pass
class UnknownManifestFormat(Exception):
pass
class MediaMissing(Exception):
pass

201
tidal_dl_ng/helper/gui.py Normal file
View File

@ -0,0 +1,201 @@
import re
from PySide6 import QtCore, QtGui, QtWidgets
from tidalapi import Album, Mix, Playlist, Track, UserPlaylist, Video
from tidalapi.artist import Artist
from tidalapi.media import Quality
def get_table_data(
item: QtWidgets.QTreeWidgetItem, column: int
) -> Track | Video | Album | Artist | Mix | Playlist | UserPlaylist:
result: Track | Video | Album | Artist = item.data(column, QtCore.Qt.ItemDataRole.UserRole)
return result
def get_table_text(item: QtWidgets.QTreeWidgetItem, column: int) -> str:
result: str = item.text(column)
return result
def get_results_media_item(
index: QtCore.QModelIndex, proxy: QtCore.QSortFilterProxyModel, model: QtGui.QStandardItemModel
) -> Track | Video | Album | Artist | Playlist | Mix:
# Switch column to "obj" column and map proxy data to our model.
item: QtGui.QStandardItem = model.itemFromIndex(proxy.mapToSource(index.siblingAtColumn(1)))
result: Track | Video | Album | Artist = item.data(QtCore.Qt.ItemDataRole.UserRole)
return result
def get_user_list_media_item(item: QtWidgets.QTreeWidgetItem) -> Mix | Playlist | UserPlaylist:
result: Mix | Playlist | UserPlaylist = get_table_data(item, 1)
return result
def get_queue_download_media(
item: QtWidgets.QTreeWidgetItem,
) -> Mix | Playlist | UserPlaylist | Track | Video | Album | Artist:
result: Mix | Playlist | UserPlaylist | Track | Video | Album | Artist = get_table_data(item, 1)
return result
def get_queue_download_quality(
item: QtWidgets.QTreeWidgetItem,
) -> Quality:
result: Quality = get_table_text(item, 4)
return result
def set_table_data(
item: QtWidgets.QTreeWidgetItem, data: Track | Video | Album | Artist | Mix | Playlist | UserPlaylist, column: int
):
item.setData(column, QtCore.Qt.ItemDataRole.UserRole, data)
def set_results_media(item: QtWidgets.QTreeWidgetItem, media: Track | Video | Album | Artist):
set_table_data(item, media, 1)
def set_user_list_media(
item: QtWidgets.QTreeWidgetItem, media: Track | Video | Album | Artist | Mix | Playlist | UserPlaylist
):
set_table_data(item, media, 1)
def set_queue_download_media(
item: QtWidgets.QTreeWidgetItem, media: Mix | Playlist | UserPlaylist | Track | Video | Album | Artist
):
set_table_data(item, media, 1)
class FilterHeader(QtWidgets.QHeaderView):
filter_activated = QtCore.Signal()
def __init__(self, parent):
super().__init__(QtCore.Qt.Horizontal, parent)
self._editors = []
self._padding = 4
self.setCascadingSectionResizes(True)
self.setSectionResizeMode(QtWidgets.QHeaderView.Interactive)
self.setStretchLastSection(True)
self.setDefaultAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter)
self.setSortIndicatorShown(False)
self.setSectionsMovable(True)
self.sectionResized.connect(self.adjust_positions)
parent.horizontalScrollBar().valueChanged.connect(self.adjust_positions)
def set_filter_boxes(self, count):
while self._editors:
editor = self._editors.pop()
editor.deleteLater()
for _ in range(count):
editor = QtWidgets.QLineEdit(self.parent())
editor.setPlaceholderText("Filter")
editor.setClearButtonEnabled(True)
editor.returnPressed.connect(self.filter_activated.emit)
self._editors.append(editor)
self.adjust_positions()
def sizeHint(self):
size = super().sizeHint()
if self._editors:
height = self._editors[0].sizeHint().height()
size.setHeight(size.height() + height + self._padding)
return size
def updateGeometries(self):
if self._editors:
height = self._editors[0].sizeHint().height()
self.setViewportMargins(0, 0, 0, height + self._padding)
else:
self.setViewportMargins(0, 0, 0, 0)
super().updateGeometries()
self.adjust_positions()
def adjust_positions(self):
for index, editor in enumerate(self._editors):
height = editor.sizeHint().height()
editor.move(self.sectionPosition(index) - self.offset() + 2, height + (self._padding // 2))
editor.resize(self.sectionSize(index), height)
def filter_text(self, index) -> str:
if 0 <= index < len(self._editors):
return self._editors[index].text()
return ""
def set_filter_text(self, index, text):
if 0 <= index < len(self._editors):
self._editors[index].setText(text)
def clear_filters(self):
for editor in self._editors:
editor.clear()
class HumanProxyModel(QtCore.QSortFilterProxyModel):
def _human_key(self, key):
parts = re.split(r"(\d*\.\d+|\d+)", key)
return tuple((e.swapcase() if i % 2 == 0 else float(e)) for i, e in enumerate(parts))
def lessThan(self, source_left, source_right):
data_left = source_left.data()
data_right = source_right.data()
if isinstance(data_left, str) and isinstance(data_right, str):
return self._human_key(data_left) < self._human_key(data_right)
return super().lessThan(source_left, source_right)
@property
def filters(self):
if not hasattr(self, "_filters"):
self._filters = []
return self._filters
@filters.setter
def filters(self, filters):
self._filters = filters
self.invalidateFilter()
def filterAcceptsRow(self, source_row: int, source_parent: QtCore.QModelIndex) -> bool:
model = self.sourceModel()
source_index = model.index(source_row, 0, source_parent)
result: [bool] = []
# Show top level children
for child_row in range(model.rowCount(source_index)):
if self.filterAcceptsRow(child_row, source_index):
return True
# Filter for actual needle
for i, text in self.filters:
if 0 <= i < self.columnCount():
ix = self.sourceModel().index(source_row, i, source_parent)
data = ix.data()
# Append results to list to enable an AND operator for filtering.
result.append(bool(re.search(rf"{text}", data, re.MULTILINE | re.IGNORECASE)) if data else False)
# If no filter set, just set the result to True.
if not result:
result.append(True)
return all(result)

324
tidal_dl_ng/helper/path.py Normal file
View File

@ -0,0 +1,324 @@
import math
import os
import pathlib
import posixpath
import re
import sys
from urllib.parse import unquote, urlsplit
from pathvalidate import sanitize_filename, sanitize_filepath
from pathvalidate.error import ValidationError
from tidalapi import Album, Mix, Playlist, Track, UserPlaylist, Video
from tidalapi.media import AudioExtensions
from tidal_dl_ng import __name_display__
from tidal_dl_ng.constants import FILENAME_SANITIZE_PLACEHOLDER, UNIQUIFY_THRESHOLD, MediaType
from tidal_dl_ng.helper.tidal import name_builder_album_artist, name_builder_artist, name_builder_title
def path_home() -> str:
if "XDG_CONFIG_HOME" in os.environ:
return os.environ["XDG_CONFIG_HOME"]
elif "HOME" in os.environ:
return os.environ["HOME"]
elif "HOMEDRIVE" in os.environ and "HOMEPATH" in os.environ:
return os.path.join(os.environ["HOMEDRIVE"], os.environ["HOMEPATH"])
else:
return os.path.abspath("./")
def path_config_base() -> str:
# https://wiki.archlinux.org/title/XDG_Base_Directory
# X11 workaround: If user specified config path is set, do not point to "~/.config"
path_user_custom: str = os.environ.get("XDG_CONFIG_HOME", "")
path_config: str = ".config" if not path_user_custom else ""
path_base: str = os.path.join(path_home(), path_config, __name_display__)
return path_base
def path_file_log() -> str:
return os.path.join(path_config_base(), "app.log")
def path_file_token() -> str:
return os.path.join(path_config_base(), "token.json")
def path_file_settings() -> str:
return os.path.join(path_config_base(), "settings.json")
def format_path_media(
fmt_template: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, album_track_num_pad_min: int = 0
) -> str:
result = fmt_template
# Search track format template for placeholder.
regex = r"\{(.+?)\}"
matches = re.finditer(regex, fmt_template, re.MULTILINE)
for _matchNum, match in enumerate(matches, start=1):
template_str = match.group()
result_fmt = format_str_media(match.group(1), media, album_track_num_pad_min)
if result_fmt != match.group(1):
value = sanitize_filename(result_fmt)
result = result.replace(template_str, value)
return result
def format_str_media(
name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, album_track_num_pad_min: int = 0
) -> str:
result: str = name
try:
match name:
case "artist_name":
if isinstance(media, Track | Video):
if hasattr(media, "artists"):
result = name_builder_artist(media)
elif hasattr(media, "artist"):
result = media.artist.name
case "album_artist":
result = name_builder_album_artist(media)
case "track_title":
if isinstance(media, Track | Video):
result = name_builder_title(media)
case "mix_name":
if isinstance(media, Mix):
result = media.title
case "playlist_name":
if isinstance(media, Playlist | UserPlaylist):
result = media.name
case "album_title":
if isinstance(media, Album):
result = media.name
elif isinstance(media, Track):
result = media.album.name
case "album_track_num":
if isinstance(media, Track | Video):
num_tracks: int = media.album.num_tracks if hasattr(media, "album") else 1
count_digits: int = int(math.log10(num_tracks)) + 1
count_digits_computed: int = (
count_digits if count_digits > album_track_num_pad_min else album_track_num_pad_min
)
result = str(media.track_num).zfill(count_digits_computed)
case "album_num_tracks":
if isinstance(media, Track | Video):
result = str(media.album.num_tracks if hasattr(media, "album") else 1)
case "track_id":
if isinstance(media, Track | Video):
result = str(media.id)
case "playlist_id":
if isinstance(media, Playlist):
result = str(media.id)
case "album_id":
if isinstance(media, Album):
result = str(media.id)
elif isinstance(media, Track):
result = str(media.album.id)
case "track_duration_seconds":
if isinstance(media, Track | Video):
result = str(media.duration)
case "track_duration_minutes":
if isinstance(media, Track | Video):
m, s = divmod(media.duration, 60)
result = f"{m:01d}:{s:02d}"
case "album_duration_seconds":
if isinstance(media, Album):
result = str(media.duration)
case "album_duration_minutes":
if isinstance(media, Album):
m, s = divmod(media.duration, 60)
result = f"{m:01d}:{s:02d}"
case "playlist_duration_seconds":
if isinstance(media, Album):
result = str(media.duration)
case "playlist_duration_minutes":
if isinstance(media, Album):
m, s = divmod(media.duration, 60)
result = f"{m:01d}:{s:02d}"
case "album_year":
if isinstance(media, Album):
result = str(media.year)
elif isinstance(media, Track):
result = str(media.album.year)
case "video_quality":
if isinstance(media, Video):
result = media.video_quality
case "track_quality":
if isinstance(media, Track):
result = ", ".join(tag for tag in media.media_metadata_tags)
case "track_explicit":
if isinstance(media, Track | Video):
result = " (Explicit)" if media.explicit else ""
case "album_explicit":
if isinstance(media, Album):
result = " (Explicit)" if media.explicit else ""
case "album_num_volumes":
if isinstance(media, Album):
result = str(media.num_volumes)
case "track_volume_num":
if isinstance(media, Track | Video):
result = str(media.volume_num)
case "track_volume_num_optional":
if isinstance(media, Track | Video):
num_volumes: int = media.album.num_volumes if hasattr(media, "album") else 1
result = "" if num_volumes == 1 else str(media.volume_num)
case "track_volume_num_optional_CD":
if isinstance(media, Track | Video):
num_volumes: int = media.album.num_volumes if hasattr(media, "album") else 1
result = "" if num_volumes == 1 else f"CD{media.volume_num!s}"
except Exception as e:
# TODO: Implement better exception logging.
print(e)
pass
return result
def get_format_template(
media: Track | Album | Playlist | UserPlaylist | Video | Mix | MediaType, settings
) -> str | bool:
result = False
if isinstance(media, Track) or media == MediaType.TRACK:
result = settings.data.format_track
elif isinstance(media, Album) or media == MediaType.ALBUM or media == MediaType.ARTIST:
result = settings.data.format_album
elif isinstance(media, Playlist | UserPlaylist) or media == MediaType.PLAYLIST:
result = settings.data.format_playlist
elif isinstance(media, Mix) or media == MediaType.MIX:
result = settings.data.format_mix
elif isinstance(media, Video) or media == MediaType.VIDEO:
result = settings.data.format_video
return result
def path_file_sanitize(path_file: str, adapt: bool = False, uniquify: bool = False) -> (bool, str):
# Split into path and filename
pathname, filename = os.path.split(path_file)
file_extension: str = pathlib.Path(path_file).suffix
# Sanitize path
try:
pathname_sanitized: str = sanitize_filepath(
pathname, replacement_text=" ", validate_after_sanitize=True, platform="auto"
)
except ValidationError:
# If adaption of path is allowed in case of an error set path to HOME.
if adapt:
pathname_sanitized: str = str(pathlib.Path.home())
else:
raise
# Sanitize filename
try:
filename_sanitized: str = sanitize_filename(
filename, replacement_text=" ", validate_after_sanitize=True, platform="auto"
)
# Check if the file extension was removed by shortening the filename length
if not filename_sanitized.endswith(file_extension):
# Add the original file extension
file_suffix: str = FILENAME_SANITIZE_PLACEHOLDER + file_extension
filename_sanitized = filename_sanitized[: -len(file_suffix)] + file_suffix
except ValidationError as e:
# TODO: Implement proper exception handling and logging.
# Hacky stuff, since the sanitizing function does not shorten the filename somehow (bug?)
# TODO: Remove after pathvalidate update.
# If filename too long
if e.description.startswith("[PV1101]"):
byte_ct: int = len(filename.encode("utf-8")) - 255
filename_sanitized = (
filename[: -byte_ct - len(FILENAME_SANITIZE_PLACEHOLDER) - len(file_extension)]
+ FILENAME_SANITIZE_PLACEHOLDER
+ file_extension
)
else:
print(e)
# Join path and filename
result: str = os.path.join(pathname_sanitized, filename_sanitized)
# Uniquify
if uniquify:
unique_suffix: str = file_unique_suffix(result)
if unique_suffix:
file_suffix = unique_suffix + file_extension
# For most OS filename has a character limit of 255.
filename_sanitized = (
filename_sanitized[: -len(file_suffix)] + file_suffix
if len(filename_sanitized + unique_suffix) > 255
else filename_sanitized[: -len(file_extension)] + file_suffix
)
# Join path and filename
result = os.path.join(pathname_sanitized, filename_sanitized)
return result
def file_unique_suffix(path_file: str, seperator: str = "_") -> str:
threshold_zfill: int = len(str(UNIQUIFY_THRESHOLD))
count: int = 0
path_file_tmp: str = path_file
unique_suffix: str = ""
while check_file_exists(path_file_tmp) and count < UNIQUIFY_THRESHOLD:
count += 1
unique_suffix = seperator + str(count).zfill(threshold_zfill)
filename, file_extension = os.path.splitext(path_file_tmp)
path_file_tmp = filename + unique_suffix + file_extension
return unique_suffix
def check_file_exists(path_file: pathlib.Path, extension_ignore: bool = False) -> bool:
if extension_ignore:
path_file_stem: str = pathlib.Path(path_file).stem
path_parent: pathlib.Path = pathlib.Path(path_file).parent
path_files: [str] = []
for extension in AudioExtensions:
path_files.append(str(path_parent.joinpath(path_file_stem + extension)))
else:
path_files: [str] = [path_file]
result = bool(sum([[True] if os.path.isfile(_file) else [] for _file in path_files], []))
return result
def resource_path(relative_path):
try:
base_path = sys._MEIPASS
except Exception:
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
def url_to_filename(url: str) -> str:
"""Return basename corresponding to url.
>>> print(url_to_filename('http://example.com/path/to/file%C3%80?opt=1'))
fileÀ
>>> print(url_to_filename('http://example.com/slash%2fname')) # '/' in name
Taken from https://gist.github.com/zed/c2168b9c52b032b5fb7d
Traceback (most recent call last):
...
ValueError
"""
urlpath: str = urlsplit(url).path
basename: str = posixpath.basename(unquote(urlpath))
if os.path.basename(basename) != basename or unquote(posixpath.basename(urlpath)) != basename:
raise ValueError # reject '%2f' or 'dir%5Cbasename.ext' on Windows
return basename

205
tidal_dl_ng/helper/tidal.py Normal file
View File

@ -0,0 +1,205 @@
from collections.abc import Callable
from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video
from tidalapi.artist import Artist, Role
from tidalapi.media import MediaMetadataTags, Quality
from tidalapi.session import SearchTypes
from tidalapi.user import LoggedInUser
from tidal_dl_ng.constants import FAVORITES, MediaType
from tidal_dl_ng.helper.exceptions import MediaUnknown
def name_builder_artist(media: Track | Video | Album) -> str:
return ", ".join(artist.name for artist in media.artists)
def name_builder_album_artist(media: Track | Album) -> str:
artists_tmp: [str] = []
artists: [Artist] = media.album.artists if isinstance(media, Track) else media.artists
for artist in artists:
if Role.main in artist.roles:
artists_tmp.append(artist.name)
return ", ".join(artists_tmp)
def name_builder_title(media: Track | Video | Mix | Playlist | Album | Video) -> str:
result: str = (
media.title if isinstance(media, Mix) else media.full_name if hasattr(media, "full_name") else media.name
)
return result
def name_builder_item(media: Track) -> str:
return f"{name_builder_artist(media)} - {name_builder_title(media)}"
def get_tidal_media_id(url_or_id_media: str) -> str:
id_dirty = url_or_id_media.rsplit("/", 1)[-1]
id_media = id_dirty.rsplit("?", 1)[0]
return id_media
def get_tidal_media_type(url_media: str) -> MediaType | bool:
result: MediaType | bool = False
url_split = url_media.split("/")[-2]
if len(url_split) > 1:
media_name = url_media.split("/")[-2]
if media_name == "track":
result = MediaType.TRACK
elif media_name == "video":
result = MediaType.VIDEO
elif media_name == "album":
result = MediaType.ALBUM
elif media_name == "playlist":
result = MediaType.PLAYLIST
elif media_name == "mix":
result = MediaType.MIX
elif media_name == "artist":
result = MediaType.ARTIST
return result
def search_results_all(session: Session, needle: str, types_media: SearchTypes = None) -> dict[str, [SearchTypes]]:
limit: int = 300
offset: int = 0
done: bool = False
result: dict[str, [SearchTypes]] = {}
while not done:
tmp_result: dict[str, [SearchTypes]] = session.search(
query=needle, models=types_media, limit=limit, offset=offset
)
tmp_done: bool = True
for key, value in tmp_result.items():
# Append pagination results, if there are any
if offset == 0:
result = tmp_result
tmp_done = False
elif bool(value):
result[key] += value
tmp_done = False
# Next page
offset += limit
done = tmp_done
return result
def items_results_all(
media_list: [Mix | Playlist | Album | Artist], videos_include: bool = True
) -> [Track | Video | Album]:
result: [Track | Video | Album] = []
if isinstance(media_list, Mix):
result = media_list.items()
else:
func_get_items_media: [Callable] = []
if isinstance(media_list, Playlist | Album):
if videos_include:
func_get_items_media.append(media_list.items)
else:
func_get_items_media.append(media_list.tracks)
else:
func_get_items_media.append(media_list.get_albums)
func_get_items_media.append(media_list.get_ep_singles)
result = paginate_results(func_get_items_media)
return result
def all_artist_album_ids(media_artist: Artist) -> [int | None]:
result: [int] = []
func_get_items_media: [Callable] = [media_artist.get_albums, media_artist.get_ep_singles]
albums: [Album] = paginate_results(func_get_items_media)
for album in albums:
result.append(album.id)
return result
def paginate_results(func_get_items_media: [Callable]) -> [Track | Video | Album | Playlist | UserPlaylist]:
result: [Track | Video | Album] = []
for func_media in func_get_items_media:
limit: int = 100
offset: int = 0
done: bool = False
if func_media.__func__ == LoggedInUser.playlist_and_favorite_playlists:
limit: int = 50
while not done:
tmp_result: [Track | Video | Album | Playlist | UserPlaylist] = func_media(limit=limit, offset=offset)
if bool(tmp_result):
result += tmp_result
# Get the next page in the next iteration.
offset += limit
else:
done = True
return result
def user_media_lists(session: Session) -> [Playlist | UserPlaylist | Mix]:
user_playlists: [Playlist | UserPlaylist] = paginate_results([session.user.playlist_and_favorite_playlists])
user_mixes: [Mix] = session.mixes().categories[0].items
result: [Playlist | UserPlaylist | Mix] = user_playlists + user_mixes
return result
def instantiate_media(
session: Session,
media_type: type[MediaType.TRACK, MediaType.VIDEO, MediaType.ALBUM, MediaType.PLAYLIST, MediaType.MIX],
id_media: str,
) -> Track | Video | Album | Playlist | Mix | Artist:
if media_type == MediaType.TRACK:
media = session.track(id_media, with_album=True)
elif media_type == MediaType.VIDEO:
media = session.video(id_media)
elif media_type == MediaType.ALBUM:
media = session.album(id_media)
elif media_type == MediaType.PLAYLIST:
media = session.playlist(id_media)
elif media_type == MediaType.MIX:
media = session.mix(id_media)
elif media_type == MediaType.ARTIST:
media = session.artist(id_media)
else:
raise MediaUnknown
return media
def quality_audio_highest(media: Track | Album) -> Quality:
quality: Quality
if MediaMetadataTags.hi_res_lossless in media.media_metadata_tags:
quality = Quality.hi_res_lossless
elif MediaMetadataTags.lossless in media.media_metadata_tags:
quality = Quality.high_lossless
else:
quality = media.audio_quality
return quality
def favorite_function_factory(tidal, favorite_item: str):
function_name: str = FAVORITES[favorite_item]["function_name"]
function_list: Callable = getattr(tidal.session.user.favorites, function_name)
return function_list

View File

@ -0,0 +1,26 @@
from collections.abc import Callable
class LoggerWrapped:
fn_print: Callable = None
def __init__(self, fn_print: Callable):
self.fn_print = fn_print
def debug(self, value):
self.fn_print(value)
def warning(self, value):
self.fn_print(value)
def info(self, value):
self.fn_print(value)
def error(self, value):
self.fn_print(value)
def critical(self, value):
self.fn_print(value)
def exception(self, value):
self.fn_print(value)