Skip to content

Commit

Permalink
expand optional dependencies for core functionality (#74)
Browse files Browse the repository at this point in the history
* optional dependencies: tqdm, xmltodict, lxml, pillow

* update docs for optional dependencies

* fix bugs in optional import logic

* update packages + change dependabot config

* fix test optional dependencies

* remove 'all' dependencies from docs
  • Loading branch information
geo-martino authored May 19, 2024
1 parent 0576829 commit 1236f83
Show file tree
Hide file tree
Showing 35 changed files with 1,487 additions and 1,277 deletions.
2 changes: 1 addition & 1 deletion .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ per-file-ignores =
docs/_howto/scripts/*:E402
tests/**/test_*.py:F811
max-line-length = 120
max-complexity = 10
max-complexity = 12
6 changes: 5 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ updates:
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
groups:
python-packages:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
interval: "weekly"
2 changes: 1 addition & 1 deletion docs/_howto/scripts/remote.new-music.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def match_date(alb) -> bool:
kind = RemoteObjectType.ALBUM
key = api.collection_item_map[kind]

bar = library.logger.get_progress_bar(iterable=albums_need_extend, desc="Getting album tracks", unit="albums")
bar = library.logger.get_iterator(iterable=albums_need_extend, desc="Getting album tracks", unit="albums")
for album in bar:
api.extend_items(album.response, kind=kind, key=key)
album.refresh(skip_checks=False)
Expand Down
4 changes: 4 additions & 0 deletions docs/howto.local.library.load.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ You can create one of any of the supported local library types for this guide as

* MusicBee

.. note::
To be able to use a MusicBee library, you will need to have installed the ``musicbee`` optional dependencies.
See :ref:`installation` for more details.

.. literalinclude:: _howto/scripts/local.library.load.py
:language: Python
:lines: 8-10
Expand Down
4 changes: 4 additions & 0 deletions docs/howto.local.playlist.load-save.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ Load a playlist

You can load a playlist as follows:

.. note::
To be able to use the XAutoPF playlist type, you will need to have installed the ``musicbee`` optional dependencies.
See :ref:`installation` for more details.

.. literalinclude:: _howto/scripts/local.playlist.load-save.py
:language: Python
:lines: 13-17
Expand Down
4 changes: 4 additions & 0 deletions docs/howto.local.track.load-save.rst
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ The following is an example for doing this with Spotify as the data source:
Modify the track's tags
-----------------------

.. note::
To be able to modify a track's images, you will need to have installed the ``images`` optional dependencies.
See :ref:`installation` for more details.

1. Change some tags:

.. literalinclude:: _howto/scripts/local.track.load-save.py
Expand Down
16 changes: 16 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ What's in this documentation
* How to get started with contributing to Musify
* Reference documentation

.. _installation:

Installation
------------

Expand All @@ -37,6 +39,20 @@ Install through pip using one of the following commands:
# or
python -m pip install musify
This package has various optional dependencies for optional functionality.
Should you wish to take advantage of some or all of this functionality, install the optional dependencies as follows:

.. code-block:: bash
pip install musify[all] # installs all optional dependencies
pip install musify[bars] # dependencies for displaying progress bars on longer running processes
pip install musify[images] # dependencies for processing images
pip install musify[musicbee] # dependencies for working with a local MusicBee library and its playlist types
# or you may install any combination of these e.g.
pip install musify[bars,images,musicbee]
.. toctree::
:maxdepth: 1
:caption: 📜 How to...
Expand Down
4 changes: 4 additions & 0 deletions musify/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ class MusifyAttributeError(MusifyError, AttributeError):
"""Exception raised for invalid attributes."""


class MusifyImportError(MusifyError, ImportError):
"""Exception raised for import errors, usually from missing modules."""


###########################################################################
## Enum errors
###########################################################################
Expand Down
27 changes: 22 additions & 5 deletions musify/file/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,29 @@
from urllib.error import URLError
from urllib.request import urlopen, Request

from PIL import Image, UnidentifiedImageError

from musify.file.exception import ImageLoadError
from musify.utils import required_modules_installed

try:
from PIL import Image, UnidentifiedImageError
ImageType = Image.Image
except ImportError:
Image = None
UnidentifiedImageError = None
ImageType = None

REQUIRED_MODULES = [Image, UnidentifiedImageError]

def open_image(source: str | bytes | Path | Request) -> Image.Image:

def open_image(source: str | bytes | Path | Request) -> ImageType:
"""
Open Image object from a given URL or file path
:return: The loaded :py:class:`Image.Image`
:raise ImageLoadError: If the image cannot be loaded.
:raise ModuleImportError: If required modules are not installed.
"""
required_modules_installed(REQUIRED_MODULES, "open_image")

try: # open image from link
if isinstance(source, Request) or (isinstance(source, str) and source.startswith("http")):
Expand All @@ -35,8 +46,14 @@ def open_image(source: str | bytes | Path | Request) -> Image.Image:
raise ImageLoadError(f"{source} | Failed to open image")


def get_image_bytes(image: Image.Image) -> bytes:
"""Extracts bytes from a given Image file"""
def get_image_bytes(image: ImageType) -> bytes:
"""
Extracts bytes from a given Image file.
:raise ModuleImportError: If required modules are not installed.
"""
required_modules_installed(REQUIRED_MODULES, "get_image_bytes")

image_bytes_arr = BytesIO()
image.save(image_bytes_arr, format=image.format)
return image_bytes_arr.getvalue()
4 changes: 2 additions & 2 deletions musify/libraries/local/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def save_tracks(
track: executor.submit(track.save, tags=tags, replace=replace, dry_run=dry_run)
for track in self.tracks
}
bar = self.logger.get_progress_bar(futures.items(), desc="Updating tracks", unit="tracks")
bar = self.logger.get_iterator(futures.items(), desc="Updating tracks", unit="tracks")

return {track: future.result() for track, future in bar if future.result().updated}

Expand Down Expand Up @@ -151,7 +151,7 @@ def merge_tracks(self, tracks: Collection[Track], tags: UnitIterable[TagField] =
f"Merging library of {len(self)} items with {len(tracks)} items on tags: "
f"{', '.join(tag_names)} \33[0m"
)
tracks = self.logger.get_progress_bar(iterable=tracks, desc="Merging library", unit="tracks")
tracks = self.logger.get_iterator(iterable=tracks, desc="Merging library", unit="tracks")

tags = to_collection(tags)
if Fields.IMAGES in tags or Fields.ALL in tags:
Expand Down
6 changes: 3 additions & 3 deletions musify/libraries/local/library/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ def load_tracks(self) -> None:

with ThreadPoolExecutor(thread_name_prefix="track-loader") as executor:
tasks = executor.map(self.load_track, self._track_paths)
bar = self.logger.get_progress_bar(
bar = self.logger.get_iterator(
tasks, desc="Loading tracks", unit="tracks", total=len(self._track_paths)
)
self._tracks = list(bar)
Expand Down Expand Up @@ -356,7 +356,7 @@ def load_playlists(self) -> None:

with ThreadPoolExecutor(thread_name_prefix="playlist-loader") as executor:
tasks = executor.map(self.load_playlist, self._playlist_paths.values())
bar = self.logger.get_progress_bar(
bar = self.logger.get_iterator(
tasks, desc="Loading playlists", unit="playlists", total=len(self._playlist_paths)
)
self._playlists = {pl.name: pl for pl in sorted(bar, key=lambda x: x.name.casefold())}
Expand Down Expand Up @@ -386,7 +386,7 @@ def save_playlists(self, dry_run: bool = True) -> dict[str, Result]:
"""
with ThreadPoolExecutor(thread_name_prefix="playlist-saver") as executor:
futures = {name: executor.submit(pl.save, dry_run=dry_run) for name, pl in self.playlists.items()}
bar = self.logger.get_progress_bar(futures.items(), desc="Updating playlists", unit="playlists")
bar = self.logger.get_iterator(futures.items(), desc="Updating playlists", unit="playlists")

return dict(bar)

Expand Down
34 changes: 24 additions & 10 deletions musify/libraries/local/library/musicbee.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,6 @@
from os.path import join, exists, normpath
from typing import Any

import xmltodict
from lxml import etree
from lxml.etree import iterparse

from musify.file.base import File
from musify.file.exception import FileDoesNotExistError
from musify.file.path_mapper import PathMapper, PathStemMapper
Expand All @@ -24,7 +20,21 @@
from musify.libraries.remote.core.processors.wrangle import RemoteDataWrangler
from musify.processors.base import Filter
from musify.types import Number
from musify.utils import to_collection
from musify.utils import to_collection, required_modules_installed

try:
import xmltodict
from lxml import etree
# noinspection PyProtectedMember
from lxml.etree import _Element as Element
except ImportError:
xmltodict = None
etree = None

from typing import Never
Element = Never

REQUIRED_MODULES = [xmltodict, etree]


class MusicBee(LocalLibrary, File):
Expand Down Expand Up @@ -75,6 +85,8 @@ def __init__(
path_mapper: PathMapper = PathMapper(),
remote_wrangler: RemoteDataWrangler = None,
):
required_modules_installed(REQUIRED_MODULES, self)

#: The absolute path of the musicbee folder containing settings and library files.
self.musicbee_folder = musicbee_folder

Expand Down Expand Up @@ -340,12 +352,14 @@ class XMLLibraryParser:
timestamp_format = "%Y-%m-%dT%H:%M:%SZ"

def __init__(self, path: str, path_keys: Iterable[str] | None = None):
required_modules_installed(REQUIRED_MODULES, self)

#: Path to the XML file.
self.path: str = path
#: A list of keys in the XML file that need to be processed as system paths.
self.path_keys: frozenset[str] = frozenset(path_keys) if path_keys else frozenset()
#: Stores the iterparse operator for parsing XML file
self._iterparse: iterparse | None = None
self._iterparse: etree.iterparse | None = None

@classmethod
def to_xml_timestamp(cls, timestamp: datetime | None) -> str | None:
Expand All @@ -371,7 +385,7 @@ def from_xml_path(path: str) -> str:
"""Clean the file paths as found in the MusicBee XML library file to a standard system path"""
return normpath(urllib.parse.unquote(path.removeprefix("file://localhost/")))

def _iter_elements(self) -> Iterator[etree.Element]:
def _iter_elements(self) -> Iterator[Element]:
for event, element in self._iterparse:
yield element

Expand All @@ -391,7 +405,7 @@ def _parse_value(self, value: Any, tag: str, parent: str | None = None):
elif tag in ['true', 'false']:
return tag == 'true'

def _parse_element(self, element: etree._Element | None = None) -> Any:
def _parse_element(self, element: Element | None = None) -> Any:
elem = next(self._iter_elements())
peek = element.getnext() if element is not None else None

Expand All @@ -410,7 +424,7 @@ def _parse_element(self, element: etree._Element | None = None) -> Any:
else:
raise XMLReaderError(f"Unrecognised element: {element.tag}, {element.text}")

def _parse_array(self, element: etree._Element | None = None) -> list[Any]:
def _parse_array(self, element: Element | None = None) -> list[Any]:
array = []

if element is not None and element.tag == "array" and element.text is None:
Expand Down Expand Up @@ -479,7 +493,7 @@ def parse(self) -> dict[str, Any]:

return results

def _unparse_dict(self, element: etree._Element, data: Mapping[str, Any]):
def _unparse_dict(self, element: Element, data: Mapping[str, Any]):
sub_element: etree._Element = etree.SubElement(element, "dict")
for key, value in data.items():
etree.SubElement(sub_element, "key").text = str(key)
Expand Down
9 changes: 7 additions & 2 deletions musify/libraries/local/playlist/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@
from musify.file.path_mapper import PathMapper
from musify.libraries.local.playlist.base import LocalPlaylist
from musify.libraries.local.playlist.m3u import M3U
from musify.libraries.local.playlist.xautopf import XAutoPF
from musify.libraries.local.playlist.xautopf import XAutoPF, REQUIRED_MODULES as REQUIRED_XAUTOPF_MODULES
from musify.libraries.local.track import LocalTrack
from musify.libraries.remote.core.processors.wrangle import RemoteDataWrangler
from musify.utils import required_modules_installed

PLAYLIST_CLASSES = frozenset({M3U, XAutoPF})
_playlist_classes = {M3U}
if required_modules_installed(REQUIRED_XAUTOPF_MODULES):
_playlist_classes.add(XAutoPF)

PLAYLIST_CLASSES = frozenset(_playlist_classes)
PLAYLIST_FILETYPES = frozenset(filetype for c in PLAYLIST_CLASSES for filetype in c.valid_extensions)


Expand Down
19 changes: 15 additions & 4 deletions musify/libraries/local/playlist/xautopf.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,11 @@
from os.path import exists
from typing import Any

import xmltodict

from musify.core.base import MusifyItem
from musify.core.enum import Fields, Field, TagFields
from musify.core.printer import PrettyPrinter
from musify.core.result import Result
from musify.exception import FieldError
from musify.exception import FieldError, MusifyImportError
from musify.file.base import File
from musify.file.path_mapper import PathMapper
from musify.libraries.local.playlist.base import LocalPlaylist
Expand All @@ -26,6 +24,13 @@
from musify.processors.sort import ItemSorter, ShuffleMode
from musify.utils import to_collection

try:
import xmltodict
except ImportError:
xmltodict = None

REQUIRED_MODULES = [xmltodict]

AutoMatcher = FilterMatcher[
LocalTrack, FilterDefinedList[LocalTrack], FilterDefinedList[LocalTrack], FilterComparers[LocalTrack]
]
Expand Down Expand Up @@ -123,6 +128,9 @@ def __init__(
*_,
**__
):
if xmltodict is None:
raise MusifyImportError(f"Cannot create {self.__class__.__name__} object. Required modules: xmltodict")

self._validate_type(path)

self._parser = XMLPlaylistParser(path=path, path_mapper=path_mapper)
Expand Down Expand Up @@ -297,6 +305,9 @@ def description(self, value: str | None):
self.xml_source.pop("Description", None)

def __init__(self, path: str, path_mapper: PathMapper = PathMapper()):
if xmltodict is None:
raise MusifyImportError(f"Cannot create {self.__class__.__name__} object. Required modules: xmltodict")

self._path = path
#: Maps paths stored in the playlist file.
self.path_mapper = path_mapper
Expand Down Expand Up @@ -574,7 +585,7 @@ def get_sorter(self) -> ItemSorter | None:

return ItemSorter(fields=fields, shuffle_mode=shuffle_mode, shuffle_weight=shuffle_weight)

# TODO: remove defined_sort workaround here - see cls.custom_sort
# TODO: remove defined_sort workaround here - see self.defined_sort
return ItemSorter(fields=fields or next(iter(self.defined_sort.values())))

def parse_sorter(self, sorter: ItemSorter | None = None) -> None:
Expand Down
Loading

0 comments on commit 1236f83

Please sign in to comment.