Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

expand FilterMatcher functionality with GroupBy settings #52

Merged
merged 4 commits into from
Mar 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ per-file-ignores =
docs/_howto/scripts/*:E402
tests/**/test_*.py:F811
max-line-length = 120
# TODO: reduce this to 10
max-complexity = 17
10 changes: 10 additions & 0 deletions docs/contributing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ To run tests, you must have installed either the ``test`` or ``dev`` optional de
# OR
pytest path/to/test/file.py

As part of code validation, your code will be required to pass certain linting checks.
This project uses flake8 to help give an indication as to the quality of your code.
For the current checks that your code will be expected to pass,
please check the ``.flake8`` config file in the root of the project.

To run these checks locally, simply run the following command in a terminal:

.. code-block:: bash

flake8

Submitting your changes for review
==================================
Expand Down
13 changes: 13 additions & 0 deletions docs/release-history.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Added
* Property 'kind' to all objects which have an associated :py:class:`.RemoteObjectType`
* Introduced :py:class:`.MusifyItemSettable` class to allow distinction
between items that can have their properties set and those that can't
* Extend :py:class:`.FilterMatcher` with group_by tag functionality

Changed
-------
Expand All @@ -57,12 +58,24 @@ Changed
* Renamed 'kind' property on :py:class:`.LocalTrack` to 'type' to avoid clashing property names
* :py:class:`.ItemMatcher`, :py:class:`.RemoteItemChecker`, and :py:class:`.RemoteItemSearcher` now accept
all MusifyItem types that may have their URI property set manually.
* :py:class:`.ItemSorter` now shuffles randomly on unsupported types
+ prioritises fields settings over shuffle settings

Fixed
-----

* :py:class:`.Comparer` dynamic processor methods which process string values now cast expected types before processing

Removed
-------

* Redundant ShuffleBy enum and related arguments from :py:class:`.ItemSorter`

Documentation
-------------

* Added info on lint checking for the contributing page

0.8.1
=====

Expand Down
2 changes: 1 addition & 1 deletion musify/core/enum.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class MusifyEnum(IntEnum):
@classmethod
def map(cls, enum: Self) -> list[Self]:
"""
"Optional mapper to apply to the enum found during :py:meth:`all`, :py:meth:`from_name`,
Optional mapper to apply to the enum found during :py:meth:`all`, :py:meth:`from_name`,
and :py:meth:`from_value` calls
"""
return [enum]
Expand Down
9 changes: 0 additions & 9 deletions musify/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,6 @@ class TrackFieldMixin(TagField):
# noinspection PyUnresolvedReferences
@classmethod
def map(cls, enum: Self) -> list[Self]:
"""
Mapper to apply to the enum found during :py:meth:`from_name` and :py:meth:`from_value` calls,
or from :py:meth:`to_tag` and :py:meth:`to_tags` calls

Applies the following mapping:
* ``TRACK`` returns both ``TRACK_NUMBER`` and ``TRACK_TOTAL`` enums
* ``DISC`` returns both ``DISC_NUMBER`` and ``DISC_TOTAL`` enums
* all other enums return the enum in a unit list
"""
if enum == cls.TRACK:
return [cls.TRACK_NUMBER, cls.TRACK_TOTAL]
elif enum == cls.DISC:
Expand Down
10 changes: 3 additions & 7 deletions musify/libraries/core/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from musify.libraries.remote.core import RemoteResponse
from musify.libraries.remote.core.base import RemoteObject
from musify.libraries.remote.core.processors.wrangle import RemoteDataWrangler
from musify.processors.sort import ShuffleMode, ShuffleBy, ItemSorter
from musify.processors.sort import ShuffleMode, ItemSorter
from musify.types import UnitSequence


Expand Down Expand Up @@ -195,8 +195,7 @@ def clear(self) -> None:
def sort(
self,
fields: UnitSequence[Field | None] | Mapping[Field | None, bool] = (),
shuffle_mode: ShuffleMode = ShuffleMode.NONE,
shuffle_by: ShuffleBy = ShuffleBy.TRACK,
shuffle_mode: ShuffleMode | None = None,
shuffle_weight: float = 1.0,
key: Field | None = None,
reverse: bool = False,
Expand All @@ -210,7 +209,6 @@ def sort(
* List of tags/properties to sort by.
* Map of `{<tag/property>: <reversed>}`. If reversed is true, sort the ``tag/property`` in reverse.
:param shuffle_mode: The mode to use for shuffling.
:param shuffle_by: The field to shuffle by when shuffling.
:param shuffle_weight: The weights (between 0 and 1) to apply to shuffling modes that can use it.
This value will automatically be limited to within the accepted range 0 and 1.
:param key: Tag or property to sort on. Can be given instead of ``fields`` for a simple sort.
Expand All @@ -221,9 +219,7 @@ def sort(
if key is not None:
ItemSorter.sort_by_field(self.items, field=key)
else:
ItemSorter(
fields=fields, shuffle_mode=shuffle_mode, shuffle_by=shuffle_by, shuffle_weight=shuffle_weight
)(self.items)
ItemSorter(fields=fields, shuffle_mode=shuffle_mode, shuffle_weight=shuffle_weight)(self.items)

if reverse:
self.items.reverse()
Expand Down
12 changes: 8 additions & 4 deletions musify/libraries/core/object.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ class Track(MusifyItem, metaclass=ABCMeta):
# noinspection PyPropertyDefinition
@classmethod
@property
def kind(cls):
def kind(cls) -> RemoteObjectType:
"""The type of remote object associated with this class"""
return RemoteObjectType.TRACK

@property
Expand Down Expand Up @@ -180,7 +181,8 @@ class Playlist[T: Track](MusifyCollection[T], metaclass=ABCMeta):
# noinspection PyPropertyDefinition
@classmethod
@property
def kind(cls):
def kind(cls) -> RemoteObjectType:
"""The type of remote object associated with this class"""
return RemoteObjectType.PLAYLIST

@property
Expand Down Expand Up @@ -477,7 +479,8 @@ class Album[T: Track](MusifyCollection[T], metaclass=ABCMeta):
# noinspection PyPropertyDefinition
@classmethod
@property
def kind(cls):
def kind(cls) -> RemoteObjectType:
"""The type of remote object associated with this class"""
return RemoteObjectType.ALBUM

@property
Expand Down Expand Up @@ -599,7 +602,8 @@ class Artist[T: Track](MusifyCollection[T], metaclass=ABCMeta):
# noinspection PyPropertyDefinition
@classmethod
@property
def kind(cls):
def kind(cls) -> RemoteObjectType:
"""The type of remote object associated with this class"""
return RemoteObjectType.ARTIST

@property
Expand Down
9 changes: 2 additions & 7 deletions musify/libraries/local/playlist/xautopf.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from musify.processors.filter_matcher import FilterMatcher
from musify.processors.limit import ItemLimiter
from musify.processors.sort import ItemSorter
from musify.utils import merge_maps


@dataclass(frozen=True)
Expand Down Expand Up @@ -169,18 +170,12 @@ def save(self, dry_run: bool = True, *_, **__) -> SyncResultXAutoPF:

def _update_xml_paths(self, xml: dict[str, Any]) -> None:
"""Update the stored, parsed XML object with valid include and exclude paths"""
source = xml["SmartPlaylist"]["Source"]
output = self.matcher.to_xml(
items=self.tracks,
original=self._original,
path_mapper=lambda paths: self.path_mapper.unmap_many(paths, check_existence=False)
)

# assign values to stored, parsed XML map
for k, v in output.items():
source.pop(k, None)
if output.get(k):
source[k] = v
merge_maps(source=xml, new=output, extend=False, overwrite=True)

def _update_comparers(self, xml: dict[str, Any]) -> None:
"""Update the stored, parsed XML object with appropriately formatted comparer settings"""
Expand Down
2 changes: 1 addition & 1 deletion musify/libraries/remote/core/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def id(self) -> str:
@property
@abstractmethod
def kind(cls) -> RemoteObjectType:
"""The type of remote object this python object represents"""
"""The type of remote object this class represents"""
raise NotImplementedError

@abstractmethod
Expand Down
87 changes: 64 additions & 23 deletions musify/processors/filter_matcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from typing import Any

from musify.core.base import MusifyItem
from musify.core.enum import Fields
from musify.core.enum import Fields, TagField, TagFields
from musify.core.result import Result
from musify.file.base import File
from musify.file.path_mapper import PathMapper
Expand All @@ -30,11 +30,13 @@ class MatchResult[T: Any](Result):
excluded: Collection[T] = field(default=tuple())
#: Objects that matched :py:class:`Comparer` settings
compared: Collection[T] = field(default=tuple())
#: Objects that matched on any group_by settings
grouped: Collection[T] = field(default=tuple())

@property
def combined(self) -> list[T]:
"""Combine the individual results to one combined list"""
return [track for results in [self.compared, self.included] for track in results if track not in self.excluded]
return [track for track in [*self.compared, *self.included, *self.grouped] if track not in self.excluded]


class FilterMatcher[T: Any, U: Filter, V: Filter, X: FilterComparers](MusicBeeProcessor, FilterComposite[T]):
Expand All @@ -44,11 +46,13 @@ class FilterMatcher[T: Any, U: Filter, V: Filter, X: FilterComparers](MusicBeePr
:param include: A Filter for simple include comparisons to use when matching.
:param exclude: A Filter for simple exclude comparisons to use when matching.
:param comparers: A Filter for fine-grained comparisons to use when matching.
When not given or the given Filter if not ready,
When not given or the given Filter is not ready,
returns all given values on match unless include or exclude are defined and ready.
:param group_by: Once all other filters are applied, also include all other items that match this tag type
from the matched items for any remaining unmatched items.
"""

__slots__ = ("include", "exclude", "comparers")
__slots__ = ("include", "exclude", "comparers", "group_by")

@classmethod
def from_xml(
Expand Down Expand Up @@ -100,7 +104,10 @@ def from_xml(
filter_include.transform = lambda x: path_mapper.map(x, check_existence=False).casefold()
filter_exclude.transform = lambda x: path_mapper.map(x, check_existence=False).casefold()

return cls(include=filter_include, exclude=filter_exclude, comparers=filter_compare)
group_by_value = cls._pascal_to_snake(xml["SmartPlaylist"]["@GroupBy"])
group_by = None if group_by_value == "track" else TagFields.from_name(group_by_value)[0]

return cls(include=filter_include, exclude=filter_exclude, comparers=filter_compare, group_by=group_by)

def to_xml(
self,
Expand All @@ -123,44 +130,53 @@ def to_xml(
)
return {}

output_path_map: Mapping[str, File] = {item.path.casefold(): item for item in items}
items_mapped: Mapping[str, File] = {item.path.casefold(): item for item in items}

if self.comparers:
# match again on current conditions to check for differences from original list
# this ensures that the paths included in the XML output
# do not include paths that match any of the conditions in the comparers
# which ensures that the paths included in the XML output
# do not include paths that match any of the comparer or group_by conditions

# copy the list of tracks as the sorter will modify the list order
original = original.copy()
# get the last played track as reference in case comparer is looking for the playing tracks as reference
ItemSorter.sort_by_field(original, field=Fields.LAST_PLAYED, reverse=True)

compared_path_map = {
matched_mapped = {
item.path.casefold(): item for item in self.comparers(original, reference=original[0])
} if self.comparers.ready else {}
matched_mapped |= {
item.path.casefold(): item for item in self._get_group_by_results(original, matched_mapped.values())
}

# get new include/exclude paths based on the leftovers after matching on comparers
self.exclude.values = list(compared_path_map.keys() - output_path_map)
self.include.values = [v for v in list(output_path_map - compared_path_map.keys()) if v not in self.exclude]
# get new include/exclude paths based on the leftovers after matching on comparers and group_by settings
self.exclude.values = list(matched_mapped.keys() - items_mapped)
self.include.values = [v for v in list(items_mapped - matched_mapped.keys()) if v not in self.exclude]
else:
compared_path_map = output_path_map
matched_mapped = items_mapped

include_items = tuple(output_path_map[path] for path in self.include if path in output_path_map)
exclude_items = tuple(compared_path_map[path] for path in self.exclude if path in compared_path_map)
include_items = tuple(items_mapped[path] for path in self.include if path in items_mapped)
exclude_items = tuple(matched_mapped[path] for path in self.exclude if path in matched_mapped)

xml = {}
source = {}
if len(include_items) > 0: # assign include paths to XML object
xml["ExceptionsInclude"] = "|".join(path_mapper(include_items)).replace("&", "&amp;")
source["ExceptionsInclude"] = "|".join(path_mapper(include_items)).replace("&", "&amp;")
if len(exclude_items) > 0: # assign exclude paths to XML object
xml["Exceptions"] = "|".join(path_mapper(exclude_items)).replace("&", "&amp;")
source["Exceptions"] = "|".join(path_mapper(exclude_items)).replace("&", "&amp;")

return xml
return {
"SmartPlaylist": {
"@GroupBy": self.group_by.name.lower() if self.group_by else "track",
"Source": source,
}
}

def __init__(
self,
include: U = FilterDefinedList(),
exclude: V = FilterDefinedList(),
comparers: X = FilterComparers(),
group_by: TagField | None = None,
*_,
**__
):
Expand All @@ -169,12 +185,15 @@ def __init__(
#: The :py:class:`MusifyLogger` for this object
self.logger: MusifyLogger = logging.getLogger(__name__)

#: The comparers to use when processing for this filter
self.comparers = comparers
#: The filter that, when processed, returns items to include
self.include = include
#: The filter that, when processed, returns items to exclude
self.exclude = exclude
#: The comparers to use when processing for this filter
self.comparers = comparers
#: Once all other filters are applied, also include all other items that match this tag type
#: from the matched items for the remaining items given
self.group_by = group_by

def __call__(self, *args, **kwargs) -> list[T]:
return self.process(*args, **kwargs)
Expand All @@ -200,7 +219,29 @@ def process_to_result(self, values: Collection[T], reference: T | None = None, *
tracks_reduced = {track for track in values if track not in included}
compared = self.comparers(tracks_reduced, reference=reference) if self.comparers.ready else ()

return MatchResult(included=included, excluded=excluded, compared=compared)
result = MatchResult(included=included, excluded=excluded, compared=compared)
grouped = self._get_group_by_results(values, matched=result.combined)

if not grouped:
return result
return MatchResult(included=included, excluded=excluded, compared=compared, grouped=grouped)

def _get_group_by_results(self, values: Collection[T], matched: Collection[T]) -> tuple[T, ...]:
if not self.group_by or len(values) == len(matched):
return ()
tag_names = self.group_by.to_tag()
tag_values = {item[tag_name] for item in matched for tag_name in tag_names if hasattr(item, tag_name)}

return tuple(
item for item in values
if item not in matched
and any(item[tag_name] in tag_values for tag_name in tag_names if hasattr(item, tag_name))
)

def as_dict(self):
return {"include": self.include, "exclude": self.exclude, "comparers": self.comparers}
return {
"include": self.include,
"exclude": self.exclude,
"comparers": self.comparers,
"group_by": self.group_by.name.lower() if self.group_by else None
}
Loading