diff --git a/README.md b/README.md index 0fa5ed4..03c7a2b 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ Great! Next, we'll (1) configure Rosé, (2) mount the virtual filesystem, and fi # WARNING: The files in this directory WILL be modified by Rosé! music_source_dir = "~/.music-source" # The mountpoint for the virtual filesystem. - fuse_mount_dir = "~/music" + vfs.mount_dir = "~/music" ``` The full configuration specification is documented in [Configuration](./docs/CONFIGURATION.md). @@ -279,10 +279,10 @@ Great! Next, we'll (1) configure Rosé, (2) mount the virtual filesystem, and fi synchronization. Now that the virtual filesystem is mounted, let's go take a look! Navigate to the configured - `fuse_mount_dir`, and you should see your music available in the virtual filesystem! + `vfs.mount_dir`, and you should see your music available in the virtual filesystem! ```bash - $ cd $fuse_mount_dir + $ cd $vfs_mount_dir $ ls -1 '1. Releases' diff --git a/conftest.py b/conftest.py index 5deda6d..a2fc72c 100644 --- a/conftest.py +++ b/conftest.py @@ -11,7 +11,7 @@ from rose.cache import CACHE_SCHEMA_PATH, process_string_for_fts, update_cache from rose.common import VERSION -from rose.config import Config +from rose.config import Config, VirtualFSConfig from rose.templates import PathTemplateConfig logger = logging.getLogger(__name__) @@ -70,22 +70,10 @@ def config(isolated_dir: Path) -> Config: return Config( music_source_dir=music_source_dir, - fuse_mount_dir=mount_dir, cache_dir=cache_dir, max_proc=2, artist_aliases_map={}, artist_aliases_parents_map={}, - fuse_artists_whitelist=None, - fuse_genres_whitelist=None, - fuse_descriptors_whitelist=None, - fuse_labels_whitelist=None, - fuse_artists_blacklist=None, - fuse_genres_blacklist=None, - fuse_descriptors_blacklist=None, - fuse_labels_blacklist=None, - hide_genres_with_only_new_releases=False, - hide_descriptors_with_only_new_releases=False, - hide_labels_with_only_new_releases=False, cover_art_stems=["cover", "folder", "art", "front"], valid_art_exts=["jpg", "jpeg", "png"], max_filename_bytes=180, @@ -93,6 +81,20 @@ def config(isolated_dir: Path) -> Config: rename_source_files=False, ignore_release_directories=[], stored_metadata_rules=[], + vfs=VirtualFSConfig( + mount_dir=mount_dir, + artists_whitelist=None, + genres_whitelist=None, + descriptors_whitelist=None, + labels_whitelist=None, + artists_blacklist=None, + genres_blacklist=None, + descriptors_blacklist=None, + labels_blacklist=None, + hide_genres_with_only_new_releases=False, + hide_descriptors_with_only_new_releases=False, + hide_labels_with_only_new_releases=False, + ), ) diff --git a/docs/AVAILABLE_COMMANDS.md b/docs/AVAILABLE_COMMANDS.md index e992b10..089857d 100644 --- a/docs/AVAILABLE_COMMANDS.md +++ b/docs/AVAILABLE_COMMANDS.md @@ -6,7 +6,7 @@ First, a quick note on the structure: Rosé primarily organizes commands by the resource they effect. Most commands are of the structure `rose {resource} {action}`. - fs/ _(see [Browsing with the Virtual Filesystem](./VIRTUAL_FILESYSTEM.md))_ - - `fs mount`: Mount the virtual filesystem onto the configured `$fuse_mount_dir`. + - `fs mount`: Mount the virtual filesystem onto the configured `$vfs_mount_dir`. - `fs unmount`: Unmount the virtual filesystem by invoking `umount`. - cache/ _(see [Maintaining the Cache](./CACHE_MAINTENANCE.md))_ - `cache update`: Scan the source directory and update the read cache with any new metadata diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 1520f9c..62d14d5 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -26,7 +26,7 @@ The configuration parameters, with examples, are: music_source_dir = "~/.music-source" # The directory to mount the virtual filesystem on. -fuse_mount_dir = "~/music" +vfs.mount_dir = "~/music" # ======================= # === Optional values === @@ -64,31 +64,31 @@ artist_aliases = [ # and labels are shown. However, if this configuration parameter is specified, # the list can be restricted to a specific few values. This is useful if you # only care about a few specific genres and labels. -fuse_artists_whitelist = [ "xxx", "yyy" ] -fuse_genres_whitelist = [ "xxx", "yyy" ] -fuse_descriptors_whitelist = [ "xxx", "yyy" ] -fuse_labels_whitelist = [ "xxx", "yyy" ] +vfs.artists_whitelist = [ "xxx", "yyy" ] +vfs.genres_whitelist = [ "xxx", "yyy" ] +vfs.descriptors_whitelist = [ "xxx", "yyy" ] +vfs.labels_whitelist = [ "xxx", "yyy" ] # Artists, genres, descriptors, and labels to hide from the virtual filesystem # navigation. These options remove specific entities from their respective # top-level virtual filesystem directories. This is useful if there are a few # values you don't find useful, e.g. a random featuring artist or one super # niche genre. # -# These options are mutually exclusive with the fuse_*_whitelist options; if +# These options are mutually exclusive with the *_whitelist options; if # both are specified for a given entity type, the configuration will not # validate. -fuse_artists_blacklist = [ "xxx" ] -fuse_genres_blacklist = [ "xxx" ] -fuse_descriptors_blacklist = [ "xxx" ] -fuse_labels_blacklist = [ "xxx" ] +vfs.artists_blacklist = [ "xxx" ] +vfs.genres_blacklist = [ "xxx" ] +vfs.descriptors_blacklist = [ "xxx" ] +vfs.labels_blacklist = [ "xxx" ] # Whether to hide the genres, descriptors, and labels from new releases from # being returned in when listing genres/descriptors/labels. This is useful new # releases are improperly tagged, as those tags tend to be very incorrect by # default. -hide_genres_with_only_new_releases = true -hide_descriptors_with_only_new_releases = true -hide_labels_with_only_new_releases = true +vfs.hide_genres_with_only_new_releases = true +vfs.hide_descriptors_with_only_new_releases = true +vfs.hide_labels_with_only_new_releases = true # When Rosé scans a release directory, it looks for cover art that matches: # diff --git a/docs/METADATA_TOOLS.md b/docs/METADATA_TOOLS.md index ee4ac62..e3c63b3 100644 --- a/docs/METADATA_TOOLS.md +++ b/docs/METADATA_TOOLS.md @@ -19,7 +19,7 @@ command accepts a Release ID or a Release's virtual filesystem directory name. So for example: ```bash -$ rose releases edit "$fuse_mount_dir/1. Releases/LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP" +$ rose releases edit "$vfs_mount_dir/1. Releases/LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP" $ rose releases edit "018b4ff1-acdf-7ff1-bcd6-67757aea0fed" ``` diff --git a/docs/PLAYLISTS_COLLAGES.md b/docs/PLAYLISTS_COLLAGES.md index 22c25a6..7e1eb09 100644 --- a/docs/PLAYLISTS_COLLAGES.md +++ b/docs/PLAYLISTS_COLLAGES.md @@ -110,7 +110,7 @@ $ rose playlists create "Evening" Virtual filesystem: ```bash -$ cd $fuse_mount_dir +$ cd $vfs_mount_dir $ mkdir "7. Collages/Morning" @@ -135,7 +135,7 @@ _Releases and tracks can be added by UUID or path. Rosé accepts both source dir virtual filesystem paths._ ```bash -$ cd $fuse_mount_dir +$ cd $vfs_mount_dir $ rose collages add-release "Morning" "1. Releases/LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP" [17:59:38] INFO: Added release LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP to collage Morning @@ -157,7 +157,7 @@ $ rose playlists add-track "Evening" "018b6514-6fb7-7cc6-9d23-8eaf0b1beee8" Virtual filesystem: ```bash -$ cd $fuse_mount_dir +$ cd $vfs_mount_dir $ cp -r "1. Releases/LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP" "7. Collages/Morning/" @@ -181,7 +181,7 @@ _Releases and tracks can be removed by UUID or path. Rosé accepts both source d virtual filesystem paths._ ```bash -$ cd $fuse_mount_dir +$ cd $vfs_mount_dir $ rose collages remove-release "Morning" "7. Collages/Morning/LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP" [18:11:43] INFO: Removed release LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP from collage Morning [18:11:43] INFO: Updating cache for collage Morning @@ -202,7 +202,7 @@ $ rose playlists remove-track "Evening" "018b6514-6fb7-7cc6-9d23-8eaf0b1beee8" Virtual filesystem: ```bash -$ cd $fuse_mount_dir +$ cd $vfs_mount_dir $ rmdir "7. Collages/Morning/LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP" @@ -281,7 +281,7 @@ $ rose playlists create "Evening" Virtual filesystem: ```bash -$ cd $fuse_mount_dir +$ cd $vfs_mount_dir $ rmdir "7. Collages/Morning" @@ -327,7 +327,7 @@ $ tree "8. Playlists/" Virtual filesystem: ```bash -$ cd $fuse_mount_dir +$ cd $vfs_mount_dir $ mv "7. Collages/Road Trip/" "7. Collages/Long Flight" @@ -352,7 +352,7 @@ regardless of the cover art name in the source directory._ Command line: ```bash -$ cd $fuse_mount_dir +$ cd $vfs_mount_dir $ rose playlists set-cover "Shower" ./cover.jpg [20:51:59] INFO: Set the cover of playlist Shower to cover.jpg @@ -372,7 +372,7 @@ filenames. The valid cover art filenames are controlled by and documented in [Configuration](./CONFIGURATION.md)._ ```bash -$ cd $fuse_mount_dir +$ cd $vfsvfs_t_dir $ mv ~/downloads/cover.jpg "8. Playlists/Shower/cover.jpg" @@ -390,7 +390,7 @@ _This operation is playlist-only, as collages do not have their own cover art._ Command line: ```bash -$ cd $fuse_mount_dir +$ cd $vfs_mount_dir $ rose playlists delete-cover "Shower" [02:10:34] INFO: Deleted cover arts of playlist Lounge @@ -405,7 +405,7 @@ $ tree "8. Playlists/Shower/" Virtual filesystem: ```bash -$ cd $fuse_mount_dir +$ cd $vfs_mount_dir $ rm "8. Playlists/Shower/cover.jpg" diff --git a/docs/RELEASES.md b/docs/RELEASES.md index d9f0151..62f86f2 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -75,7 +75,7 @@ are supported as well. This operation is only supported on the command line. ```bash -$ cd $fuse_mount_dir +$ cd $vfs_mount_dir $ rose releases toggle-new "1. Releases/LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP" [21:47:52] INFO: Updating cache for release LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match @@ -105,7 +105,7 @@ _The filename of the cover art in the virtual filesystem will always appear as Command line: ```bash -$ cd $fuse_mount_dir +$ cd $vfs_mount_dir $ rose releases set-cover "1. Releases/LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP" ./cover.jpg [20:43:50] INFO: Set the cover of release LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match to cover.jpg @@ -128,7 +128,7 @@ filenames. The valid cover art filenames are controlled by and documented in [Configuration](./CONFIGURATION.md)._ ```bash -$ cd $fuse_mount_dir +$ cd $vfs_mount_dir $ mv ~/downloads/cover.jpg "1. Releases/LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP/cover.jpg" @@ -147,7 +147,7 @@ $ tree "1. Releases/LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP/" This operation is only supported on the command line. ```bash -$ cd $fuse_mount_dir +$ cd $vfs_mount_dir $ rose releases delete-cover "1. Releases/LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP" [02:13:17] INFO: Deleted cover arts of release LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match @@ -171,7 +171,7 @@ the deletion was accidental._ Command line: ```bash -$ cd $fuse_mount_dir +$ cd $vfs_mount_dir $ rose releases delete "1. Releases/LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP" [21:56:25] INFO: Trashed release LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP @@ -191,7 +191,7 @@ $ tree "1. Releases/" Virtual filesystem: ```bash -$ cd $fuse_mount_dir +$ cd $vfs_mount_dir $ rmdir "1. Releases/LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP" @@ -286,7 +286,7 @@ rid of the release while keeping the track(s) you liked. To demonstrate: ```bash -$ cd $fuse_mount_dir +$ cd $vfs_mount_dir $ rose releases create-single "1. Releases/ITZY - 2022. CHECKMATE/01.\ SNEAKERS.opus" [12:16:06] INFO: Created phony single release ITZY - 2022. SNEAKERS diff --git a/docs/VIRTUAL_FILESYSTEM.md b/docs/VIRTUAL_FILESYSTEM.md index 6824c99..0ac800b 100644 --- a/docs/VIRTUAL_FILESYSTEM.md +++ b/docs/VIRTUAL_FILESYSTEM.md @@ -46,7 +46,7 @@ source directory. Rosé also exposes the `.rose.{uuid}.toml` datafile in the vir # Hiding Artists, Genres, and Labels Rosé supports hiding individual artists, genres, and labels in their view directories (`4. Artists`, -`5. Genres`, and `6. Labels`) with the `fuse_x_blacklist` and `fuse_x_whitelist` configuration +`5. Genres`, and `6. Labels`) with the `vfs.x_blacklist` and `vfs.x_whitelist` configuration parameters. See [Configuration](./CONFIGURATION.md) for additional documentation on configuring the blacklist or whitelist. diff --git a/rose/config.py b/rose/config.py index 5dc2dd1..7e4e6b0 100644 --- a/rose/config.py +++ b/rose/config.py @@ -56,259 +56,168 @@ class InvalidConfigValueError(RoseExpectedError, ValueError): @dataclass(frozen=True) -class Config: - music_source_dir: Path - fuse_mount_dir: Path - cache_dir: Path - # Maximum parallel processes for cache updates. Defaults to nproc/2. - max_proc: int - ignore_release_directories: list[str] - - # A map from parent artist -> subartists. - artist_aliases_map: dict[str, list[str]] - # A map from subartist -> parent artists. - artist_aliases_parents_map: dict[str, list[str]] - - fuse_artists_whitelist: list[str] | None - fuse_genres_whitelist: list[str] | None - fuse_descriptors_whitelist: list[str] | None - fuse_labels_whitelist: list[str] | None - fuse_artists_blacklist: list[str] | None - fuse_genres_blacklist: list[str] | None - fuse_descriptors_blacklist: list[str] | None - fuse_labels_blacklist: list[str] | None +class VirtualFSConfig: + mount_dir: Path + + artists_whitelist: list[str] | None + genres_whitelist: list[str] | None + descriptors_whitelist: list[str] | None + labels_whitelist: list[str] | None + artists_blacklist: list[str] | None + genres_blacklist: list[str] | None + descriptors_blacklist: list[str] | None + labels_blacklist: list[str] | None hide_genres_with_only_new_releases: bool hide_descriptors_with_only_new_releases: bool hide_labels_with_only_new_releases: bool - cover_art_stems: list[str] - valid_art_exts: list[str] - - max_filename_bytes: int - - rename_source_files: bool - path_templates: PathTemplateConfig - - stored_metadata_rules: list[MetadataRule] - @classmethod - def parse(cls, config_path_override: Path | None = None) -> Config: - # As we parse, delete consumed values from the data dictionary. If any are left over at the - # end of the config, warn that unknown config keys were found. - cfgpath = config_path_override or CONFIG_PATH - cfgtext = "" + def parse(cls, cfgpath: Path, data: dict[str, Any]) -> VirtualFSConfig: + """Modifies `config` by deleting any keys read.""" try: - with cfgpath.open("r") as fp: - cfgtext = fp.read() - data = tomllib.loads(cfgtext) - except FileNotFoundError as e: - raise ConfigNotFoundError(f"Configuration file not found ({cfgpath})") from e - except tomllib.TOMLDecodeError as e: - raise ConfigDecodeError( - f"Failed to decode configuration file: invalid TOML: {e}" - ) from e - - try: - music_source_dir = Path(data["music_source_dir"]).expanduser() - del data["music_source_dir"] - except KeyError as e: - raise MissingConfigKeyError( - f"Missing key music_source_dir in configuration file ({cfgpath})" - ) from e - except (ValueError, TypeError) as e: - raise InvalidConfigValueError( - f"Invalid value for music_source_dir in configuration file ({cfgpath}): must be a path" - ) from e - - try: - fuse_mount_dir = Path(data["fuse_mount_dir"]).expanduser() - del data["fuse_mount_dir"] + mount_dir = Path(data["mount_dir"]).expanduser() + del data["mount_dir"] except KeyError as e: raise MissingConfigKeyError( - f"Missing key fuse_mount_dir in configuration file ({cfgpath})" + f"Missing key vfs.mount_dir in configuration file ({cfgpath})" ) from e except (ValueError, TypeError) as e: raise InvalidConfigValueError( - f"Invalid value for fuse_mount_dir in configuration file ({cfgpath}): must be a path" + f"Invalid value for vfs.mount_dir in configuration file ({cfgpath}): must be a path" ) from e try: - cache_dir = Path(data["cache_dir"]).expanduser() - del data["cache_dir"] - except KeyError: - cache_dir = XDG_CACHE_ROSE - except (TypeError, ValueError) as e: - raise InvalidConfigValueError( - f"Invalid value for cache_dir in configuration file ({cfgpath}): must be a path" - ) from e - cache_dir.mkdir(parents=True, exist_ok=True) - - try: - max_proc = int(data["max_proc"]) - del data["max_proc"] - if max_proc <= 0: - raise ValueError(f"must be a positive integer: got {max_proc}") - except KeyError: - max_proc = max(1, multiprocessing.cpu_count() // 2) - except ValueError as e: - raise InvalidConfigValueError( - f"Invalid value for max_proc in configuration file ({cfgpath}): must be a positive integer" - ) from e - - artist_aliases_map: dict[str, list[str]] = defaultdict(list) - artist_aliases_parents_map: dict[str, list[str]] = defaultdict(list) - try: - for entry in data.get("artist_aliases", []): - if not isinstance(entry["artist"], str): - raise ValueError(f"Artists must be of type str: got {type(entry['artist'])}") - artist_aliases_map[entry["artist"]] = entry["aliases"] - if not isinstance(entry["aliases"], list): - raise ValueError( - f"Aliases must be of type list[str]: got {type(entry['aliases'])}" - ) - for s in entry["aliases"]: - if not isinstance(s, str): - raise ValueError(f"Each alias must be of type str: got {type(s)}") - artist_aliases_parents_map[s].append(entry["artist"]) - with contextlib.suppress(KeyError): - del data["artist_aliases"] - except (ValueError, TypeError, KeyError) as e: - raise InvalidConfigValueError( - f"Invalid value for artist_aliases in configuration file ({cfgpath}): must be a list of {{ artist = str, aliases = list[str] }} records" - ) from e - - try: - fuse_artists_whitelist = data["fuse_artists_whitelist"] - del data["fuse_artists_whitelist"] - if not isinstance(fuse_artists_whitelist, list): - raise ValueError(f"Must be a list[str]: got {type(fuse_artists_whitelist)}") - for s in fuse_artists_whitelist: + artists_whitelist = data["artists_whitelist"] + del data["artists_whitelist"] + if not isinstance(artists_whitelist, list): + raise ValueError(f"Must be a list[str]: got {type(artists_whitelist)}") + for s in artists_whitelist: if not isinstance(s, str): raise ValueError(f"Each artist must be of type str: got {type(s)}") except KeyError: - fuse_artists_whitelist = None + artists_whitelist = None except ValueError as e: raise InvalidConfigValueError( - f"Invalid value for fuse_artists_whitelist in configuration file ({cfgpath}): {e}" + f"Invalid value for vfs.artists_whitelist in configuration file ({cfgpath}): {e}" ) from e try: - fuse_genres_whitelist = data["fuse_genres_whitelist"] - del data["fuse_genres_whitelist"] - if not isinstance(fuse_genres_whitelist, list): - raise ValueError(f"Must be a list[str]: got {type(fuse_genres_whitelist)}") - for s in fuse_genres_whitelist: + genres_whitelist = data["genres_whitelist"] + del data["genres_whitelist"] + if not isinstance(genres_whitelist, list): + raise ValueError(f"Must be a list[str]: got {type(genres_whitelist)}") + for s in genres_whitelist: if not isinstance(s, str): raise ValueError(f"Each genre must be of type str: got {type(s)}") except KeyError: - fuse_genres_whitelist = None + genres_whitelist = None except ValueError as e: raise InvalidConfigValueError( - f"Invalid value for fuse_genres_whitelist in configuration file ({cfgpath}): {e}" + f"Invalid value for vfs.genres_whitelist in configuration file ({cfgpath}): {e}" ) from e try: - fuse_descriptors_whitelist = data["fuse_descriptors_whitelist"] - del data["fuse_descriptors_whitelist"] - if not isinstance(fuse_descriptors_whitelist, list): - raise ValueError(f"Must be a list[str]: got {type(fuse_descriptors_whitelist)}") - for s in fuse_descriptors_whitelist: + descriptors_whitelist = data["descriptors_whitelist"] + del data["descriptors_whitelist"] + if not isinstance(descriptors_whitelist, list): + raise ValueError(f"Must be a list[str]: got {type(descriptors_whitelist)}") + for s in descriptors_whitelist: if not isinstance(s, str): raise ValueError(f"Each descriptor must be of type str: got {type(s)}") except KeyError: - fuse_descriptors_whitelist = None + descriptors_whitelist = None except ValueError as e: raise InvalidConfigValueError( - f"Invalid value for fuse_descriptors_whitelist in configuration file ({cfgpath}): {e}" + f"Invalid value for vfs.descriptors_whitelist in configuration file ({cfgpath}): {e}" ) from e try: - fuse_labels_whitelist = data["fuse_labels_whitelist"] - del data["fuse_labels_whitelist"] - if not isinstance(fuse_labels_whitelist, list): - raise ValueError(f"Must be a list[str]: got {type(fuse_labels_whitelist)}") - for s in fuse_labels_whitelist: + labels_whitelist = data["labels_whitelist"] + del data["labels_whitelist"] + if not isinstance(labels_whitelist, list): + raise ValueError(f"Must be a list[str]: got {type(labels_whitelist)}") + for s in labels_whitelist: if not isinstance(s, str): raise ValueError(f"Each label must be of type str: got {type(s)}") except KeyError: - fuse_labels_whitelist = None + labels_whitelist = None except ValueError as e: raise InvalidConfigValueError( - f"Invalid value for fuse_labels_whitelist in configuration file ({cfgpath}): {e}" + f"Invalid value for vfs.labels_whitelist in configuration file ({cfgpath}): {e}" ) from e try: - fuse_artists_blacklist = data["fuse_artists_blacklist"] - del data["fuse_artists_blacklist"] - if not isinstance(fuse_artists_blacklist, list): - raise ValueError(f"Must be a list[str]: got {type(fuse_artists_blacklist)}") - for s in fuse_artists_blacklist: + artists_blacklist = data["artists_blacklist"] + del data["artists_blacklist"] + if not isinstance(artists_blacklist, list): + raise ValueError(f"Must be a list[str]: got {type(artists_blacklist)}") + for s in artists_blacklist: if not isinstance(s, str): raise ValueError(f"Each artist must be of type str: got {type(s)}") except KeyError: - fuse_artists_blacklist = None + artists_blacklist = None except ValueError as e: raise InvalidConfigValueError( - f"Invalid value for fuse_artists_blacklist in configuration file ({cfgpath}): {e}" + f"Invalid value for vfs.artists_blacklist in configuration file ({cfgpath}): {e}" ) from e try: - fuse_genres_blacklist = data["fuse_genres_blacklist"] - del data["fuse_genres_blacklist"] - if not isinstance(fuse_genres_blacklist, list): - raise ValueError(f"Must be a list[str]: got {type(fuse_genres_blacklist)}") - for s in fuse_genres_blacklist: + genres_blacklist = data["genres_blacklist"] + del data["genres_blacklist"] + if not isinstance(genres_blacklist, list): + raise ValueError(f"Must be a list[str]: got {type(genres_blacklist)}") + for s in genres_blacklist: if not isinstance(s, str): raise ValueError(f"Each genre must be of type str: got {type(s)}") except KeyError: - fuse_genres_blacklist = None + genres_blacklist = None except ValueError as e: raise InvalidConfigValueError( - f"Invalid value for fuse_genres_blacklist in configuration file ({cfgpath}): {e}" + f"Invalid value for vfs.genres_blacklist in configuration file ({cfgpath}): {e}" ) from e try: - fuse_descriptors_blacklist = data["fuse_descriptors_blacklist"] - del data["fuse_descriptors_blacklist"] - if not isinstance(fuse_descriptors_blacklist, list): - raise ValueError(f"Must be a list[str]: got {type(fuse_descriptors_blacklist)}") - for s in fuse_descriptors_blacklist: + descriptors_blacklist = data["descriptors_blacklist"] + del data["descriptors_blacklist"] + if not isinstance(descriptors_blacklist, list): + raise ValueError(f"Must be a list[str]: got {type(descriptors_blacklist)}") + for s in descriptors_blacklist: if not isinstance(s, str): raise ValueError(f"Each descriptor must be of type str: got {type(s)}") except KeyError: - fuse_descriptors_blacklist = None + descriptors_blacklist = None except ValueError as e: raise InvalidConfigValueError( - f"Invalid value for fuse_descriptors_blacklist in configuration file ({cfgpath}): {e}" + f"Invalid value for vfs.descriptors_blacklist in configuration file ({cfgpath}): {e}" ) from e try: - fuse_labels_blacklist = data["fuse_labels_blacklist"] - del data["fuse_labels_blacklist"] - if not isinstance(fuse_labels_blacklist, list): - raise ValueError(f"Must be a list[str]: got {type(fuse_labels_blacklist)}") - for s in fuse_labels_blacklist: + labels_blacklist = data["labels_blacklist"] + del data["labels_blacklist"] + if not isinstance(labels_blacklist, list): + raise ValueError(f"Must be a list[str]: got {type(labels_blacklist)}") + for s in labels_blacklist: if not isinstance(s, str): raise ValueError(f"Each label must be of type str: got {type(s)}") except KeyError: - fuse_labels_blacklist = None + labels_blacklist = None except ValueError as e: raise InvalidConfigValueError( - f"Invalid value for fuse_labels_blacklist in configuration file ({cfgpath}): {e}" + f"Invalid value for vfs.labels_blacklist in configuration file ({cfgpath}): {e}" ) from e - if fuse_artists_whitelist and fuse_artists_blacklist: + if artists_whitelist and artists_blacklist: raise InvalidConfigValueError( - f"Cannot specify both fuse_artists_whitelist and fuse_artists_blacklist in configuration file ({cfgpath}): must specify only one or the other" + f"Cannot specify both vfs.artists_whitelist and vfs.artists_blacklist in configuration file ({cfgpath}): must specify only one or the other" ) - if fuse_genres_whitelist and fuse_genres_blacklist: + if genres_whitelist and genres_blacklist: raise InvalidConfigValueError( - f"Cannot specify both fuse_genres_whitelist and fuse_genres_blacklist in configuration file ({cfgpath}): must specify only one or the other" + f"Cannot specify both vfs.genres_whitelist and vfs.genres_blacklist in configuration file ({cfgpath}): must specify only one or the other" ) - if fuse_labels_whitelist and fuse_labels_blacklist: + if labels_whitelist and labels_blacklist: raise InvalidConfigValueError( - f"Cannot specify both fuse_labels_whitelist and fuse_labels_blacklist in configuration file ({cfgpath}): must specify only one or the other" + f"Cannot specify both vfs.labels_whitelist and vfs.labels_blacklist in configuration file ({cfgpath}): must specify only one or the other" ) try: @@ -320,7 +229,7 @@ def parse(cls, config_path_override: Path | None = None) -> Config: hide_genres_with_only_new_releases = False except ValueError as e: raise InvalidConfigValueError( - f"Invalid value for hide_genres_with_only_new_releases in configuration file ({cfgpath}): {e}" + f"Invalid value for vfs.hide_genres_with_only_new_releases in configuration file ({cfgpath}): {e}" ) from e try: @@ -336,7 +245,7 @@ def parse(cls, config_path_override: Path | None = None) -> Config: hide_descriptors_with_only_new_releases = False except ValueError as e: raise InvalidConfigValueError( - f"Invalid value for hide_descriptors_with_only_new_releases in configuration file ({cfgpath}): {e}" + f"Invalid value for vfs.hide_descriptors_with_only_new_releases in configuration file ({cfgpath}): {e}" ) from e try: @@ -348,7 +257,120 @@ def parse(cls, config_path_override: Path | None = None) -> Config: hide_labels_with_only_new_releases = False except ValueError as e: raise InvalidConfigValueError( - f"Invalid value for hide_labels_with_only_new_releases in configuration file ({cfgpath}): {e}" + f"Invalid value for vfs.hide_labels_with_only_new_releases in configuration file ({cfgpath}): {e}" + ) from e + + return VirtualFSConfig( + mount_dir=mount_dir, + artists_whitelist=artists_whitelist, + genres_whitelist=genres_whitelist, + descriptors_whitelist=descriptors_whitelist, + labels_whitelist=labels_whitelist, + artists_blacklist=artists_blacklist, + genres_blacklist=genres_blacklist, + descriptors_blacklist=descriptors_blacklist, + labels_blacklist=labels_blacklist, + hide_genres_with_only_new_releases=hide_genres_with_only_new_releases, + hide_descriptors_with_only_new_releases=hide_descriptors_with_only_new_releases, + hide_labels_with_only_new_releases=hide_labels_with_only_new_releases, + ) + + +@dataclass(frozen=True) +class Config: + music_source_dir: Path + cache_dir: Path + # Maximum parallel processes for cache updates. Defaults to nproc/2. + max_proc: int + ignore_release_directories: list[str] + + rename_source_files: bool + max_filename_bytes: int + cover_art_stems: list[str] + valid_art_exts: list[str] + + # A map from parent artist -> subartists. + artist_aliases_map: dict[str, list[str]] + # A map from subartist -> parent artists. + artist_aliases_parents_map: dict[str, list[str]] + + path_templates: PathTemplateConfig + stored_metadata_rules: list[MetadataRule] + + vfs: VirtualFSConfig + + @classmethod + def parse(cls, config_path_override: Path | None = None) -> Config: + # As we parse, delete consumed values from the data dictionary. If any are left over at the + # end of the config, warn that unknown config keys were found. + cfgpath = config_path_override or CONFIG_PATH + cfgtext = "" + try: + with cfgpath.open("r") as fp: + cfgtext = fp.read() + data = tomllib.loads(cfgtext) + except FileNotFoundError as e: + raise ConfigNotFoundError(f"Configuration file not found ({cfgpath})") from e + except tomllib.TOMLDecodeError as e: + raise ConfigDecodeError( + f"Failed to decode configuration file: invalid TOML: {e}" + ) from e + + try: + music_source_dir = Path(data["music_source_dir"]).expanduser() + del data["music_source_dir"] + except KeyError as e: + raise MissingConfigKeyError( + f"Missing key music_source_dir in configuration file ({cfgpath})" + ) from e + except (ValueError, TypeError) as e: + raise InvalidConfigValueError( + f"Invalid value for music_source_dir in configuration file ({cfgpath}): must be a path" + ) from e + + try: + cache_dir = Path(data["cache_dir"]).expanduser() + del data["cache_dir"] + except KeyError: + cache_dir = XDG_CACHE_ROSE + except (TypeError, ValueError) as e: + raise InvalidConfigValueError( + f"Invalid value for cache_dir in configuration file ({cfgpath}): must be a path" + ) from e + cache_dir.mkdir(parents=True, exist_ok=True) + + try: + max_proc = int(data["max_proc"]) + del data["max_proc"] + if max_proc <= 0: + raise ValueError(f"must be a positive integer: got {max_proc}") + except KeyError: + max_proc = max(1, multiprocessing.cpu_count() // 2) + except ValueError as e: + raise InvalidConfigValueError( + f"Invalid value for max_proc in configuration file ({cfgpath}): must be a positive integer" + ) from e + + artist_aliases_map: dict[str, list[str]] = defaultdict(list) + artist_aliases_parents_map: dict[str, list[str]] = defaultdict(list) + try: + for entry in data.get("artist_aliases", []): + if not isinstance(entry["artist"], str): + raise ValueError(f"Artists must be of type str: got {type(entry['artist'])}") + artist_aliases_map[entry["artist"]] = entry["aliases"] + if not isinstance(entry["aliases"], list): + raise ValueError( + f"Aliases must be of type list[str]: got {type(entry['aliases'])}" + ) + for s in entry["aliases"]: + if not isinstance(s, str): + raise ValueError(f"Each alias must be of type str: got {type(s)}") + artist_aliases_parents_map[s].append(entry["artist"]) + with contextlib.suppress(KeyError): + del data["artist_aliases"] + except (ValueError, TypeError, KeyError) as e: + raise InvalidConfigValueError( + f"Invalid value for artist_aliases in configuration file ({cfgpath}): must be a list of {{ artist = str, aliases = list[str] }} records" ) from e try: @@ -527,6 +549,8 @@ def parse(cls, config_path_override: Path | None = None) -> Config: f"Invalid path template in configuration file ({cfgpath}) for template {e.key}: {e}" ) from e + vfs_config = VirtualFSConfig.parse(cfgpath, data.get("vfs", {})) + if data: unrecognized_accessors: list[str] = [] # Do a DFS over the data keys to assemble the map of unknown keys. State is a tuple of @@ -546,22 +570,10 @@ def parse(cls, config_path_override: Path | None = None) -> Config: return Config( music_source_dir=music_source_dir, - fuse_mount_dir=fuse_mount_dir, cache_dir=cache_dir, max_proc=max_proc, artist_aliases_map=artist_aliases_map, artist_aliases_parents_map=artist_aliases_parents_map, - fuse_artists_whitelist=fuse_artists_whitelist, - fuse_genres_whitelist=fuse_genres_whitelist, - fuse_descriptors_whitelist=fuse_descriptors_whitelist, - fuse_labels_whitelist=fuse_labels_whitelist, - fuse_artists_blacklist=fuse_artists_blacklist, - fuse_genres_blacklist=fuse_genres_blacklist, - fuse_descriptors_blacklist=fuse_descriptors_blacklist, - fuse_labels_blacklist=fuse_labels_blacklist, - hide_genres_with_only_new_releases=hide_genres_with_only_new_releases, - hide_descriptors_with_only_new_releases=hide_descriptors_with_only_new_releases, - hide_labels_with_only_new_releases=hide_labels_with_only_new_releases, cover_art_stems=cover_art_stems, valid_art_exts=valid_art_exts, max_filename_bytes=max_filename_bytes, @@ -569,6 +581,7 @@ def parse(cls, config_path_override: Path | None = None) -> Config: rename_source_files=rename_source_files, ignore_release_directories=ignore_release_directories, stored_metadata_rules=stored_metadata_rules, + vfs=vfs_config, ) @functools.cached_property diff --git a/rose/config_test.py b/rose/config_test.py index f6c86ea..26b0143 100644 --- a/rose/config_test.py +++ b/rose/config_test.py @@ -9,6 +9,7 @@ ConfigNotFoundError, InvalidConfigValueError, MissingConfigKeyError, + VirtualFSConfig, ) from rose.rule_parser import ( MatcherPattern, @@ -28,13 +29,13 @@ def test_config_minimal() -> None: fp.write( """ music_source_dir = "~/.music-src" - fuse_mount_dir = "~/music" + vfs.mount_dir = "~/music" """ ) c = Config.parse(config_path_override=path) assert c.music_source_dir == Path.home() / ".music-src" - assert c.fuse_mount_dir == Path.home() / "music" + assert c.vfs.mount_dir == Path.home() / "music" def test_config_full() -> None: @@ -45,7 +46,6 @@ def test_config_full() -> None: fp.write( f""" music_source_dir = "~/.music-src" - fuse_mount_dir = "~/music" cache_dir = "{cache_dir}" max_proc = 8 artist_aliases = [ @@ -53,15 +53,6 @@ def test_config_full() -> None: {{ artist = "tripleS", aliases = ["EVOLution", "LOVElution", "+(KR)ystal Eyes", "Acid Angel From Asia", "Acid Eyes"] }}, ] - fuse_artists_blacklist = [ "www" ] - fuse_genres_blacklist = [ "xxx" ] - fuse_descriptors_blacklist = [ "yyy" ] - fuse_labels_blacklist = [ "zzz" ] - - hide_genres_with_only_new_releases = true - hide_descriptors_with_only_new_releases = true - hide_labels_with_only_new_releases = true - cover_art_stems = [ "aa", "bb" ] valid_art_exts = [ "tiff" ] max_filename_bytes = 255 @@ -97,13 +88,22 @@ def test_config_full() -> None: collages.release = "{{{{ title }}}}" collages.track = "{{{{ title }}}}" playlists = "{{{{ title }}}}" + + [vfs] + mount_dir = "~/music" + artists_blacklist = [ "www" ] + genres_blacklist = [ "xxx" ] + descriptors_blacklist = [ "yyy" ] + labels_blacklist = [ "zzz" ] + hide_genres_with_only_new_releases = true + hide_descriptors_with_only_new_releases = true + hide_labels_with_only_new_releases = true """ ) c = Config.parse(config_path_override=path) assert c == Config( music_source_dir=Path.home() / ".music-src", - fuse_mount_dir=Path.home() / "music", cache_dir=cache_dir, max_proc=8, artist_aliases_map={ @@ -124,17 +124,6 @@ def test_config_full() -> None: "Acid Angel From Asia": ["tripleS"], "Acid Eyes": ["tripleS"], }, - fuse_artists_whitelist=None, - fuse_genres_whitelist=None, - fuse_descriptors_whitelist=None, - fuse_labels_whitelist=None, - hide_genres_with_only_new_releases=True, - hide_descriptors_with_only_new_releases=True, - hide_labels_with_only_new_releases=True, - fuse_artists_blacklist=["www"], - fuse_genres_blacklist=["xxx"], - fuse_descriptors_blacklist=["yyy"], - fuse_labels_blacklist=["zzz"], cover_art_stems=["aa", "bb"], valid_art_exts=["tiff"], max_filename_bytes=255, @@ -206,6 +195,20 @@ def test_config_full() -> None: ], ), ], + vfs=VirtualFSConfig( + mount_dir=Path.home() / "music", + artists_whitelist=None, + genres_whitelist=None, + descriptors_whitelist=None, + labels_whitelist=None, + hide_genres_with_only_new_releases=True, + hide_descriptors_with_only_new_releases=True, + hide_labels_with_only_new_releases=True, + artists_blacklist=["www"], + genres_blacklist=["xxx"], + descriptors_blacklist=["yyy"], + labels_blacklist=["zzz"], + ), ) @@ -217,23 +220,23 @@ def test_config_whitelist() -> None: fp.write( """ music_source_dir = "~/.music-src" - fuse_mount_dir = "~/music" - fuse_artists_whitelist = [ "www" ] - fuse_genres_whitelist = [ "xxx" ] - fuse_descriptors_whitelist = [ "yyy" ] - fuse_labels_whitelist = [ "zzz" ] + vfs.mount_dir = "~/music" + vfs.artists_whitelist = [ "www" ] + vfs.genres_whitelist = [ "xxx" ] + vfs.descriptors_whitelist = [ "yyy" ] + vfs.labels_whitelist = [ "zzz" ] """ ) c = Config.parse(config_path_override=path) - assert c.fuse_artists_whitelist == ["www"] - assert c.fuse_genres_whitelist == ["xxx"] - assert c.fuse_descriptors_whitelist == ["yyy"] - assert c.fuse_labels_whitelist == ["zzz"] - assert c.fuse_artists_blacklist is None - assert c.fuse_genres_blacklist is None - assert c.fuse_descriptors_blacklist is None - assert c.fuse_labels_blacklist is None + assert c.vfs.artists_whitelist == ["www"] + assert c.vfs.genres_whitelist == ["xxx"] + assert c.vfs.descriptors_whitelist == ["yyy"] + assert c.vfs.labels_whitelist == ["zzz"] + assert c.vfs.artists_blacklist is None + assert c.vfs.genres_blacklist is None + assert c.vfs.descriptors_blacklist is None + assert c.vfs.labels_blacklist is None def test_config_not_found() -> None: @@ -255,11 +258,10 @@ def append(x: str) -> None: append('music_source_dir = "/"') with pytest.raises(MissingConfigKeyError) as excinfo: Config.parse(config_path_override=path) - assert str(excinfo.value) == f"Missing key fuse_mount_dir in configuration file ({path})" + assert str(excinfo.value) == f"Missing key vfs.mount_dir in configuration file ({path})" def test_config_value_validation() -> None: - config = "" with tempfile.TemporaryDirectory() as tmpdir: path = Path(tmpdir) / "config.toml" path.touch() @@ -268,6 +270,8 @@ def write(x: str) -> None: with path.open("w") as fp: fp.write(x) + config = "" + # music_source_dir write("music_source_dir = 123") with pytest.raises(InvalidConfigValueError) as excinfo: @@ -278,16 +282,6 @@ def write(x: str) -> None: ) config += '\nmusic_source_dir = "~/.music-src"' - # fuse_mount_dir - write(config + "\nfuse_mount_dir = 123") - with pytest.raises(InvalidConfigValueError) as excinfo: - Config.parse(config_path_override=path) - assert ( - str(excinfo.value) - == f"Invalid value for fuse_mount_dir in configuration file ({path}): must be a path" - ) - config += '\nfuse_mount_dir = "~/music"' - # cache_dir write(config + "\ncache_dir = 123") with pytest.raises(InvalidConfigValueError) as excinfo: @@ -346,263 +340,286 @@ def write(x: str) -> None: ) config += '\nartist_aliases = [{artist="tripleS", aliases=["EVOLution"]}]' - # fuse_artists_whitelist - write(config + '\nfuse_artists_whitelist = "lalala"') + # cover_art_stems + write(config + '\ncover_art_stems = "lalala"') with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for fuse_artists_whitelist in configuration file ({path}): Must be a list[str]: got " + == f"Invalid value for cover_art_stems in configuration file ({path}): Must be a list[str]: got " ) - write(config + "\nfuse_artists_whitelist = [123]") + write(config + "\ncover_art_stems = [123]") with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for fuse_artists_whitelist in configuration file ({path}): Each artist must be of type str: got " + == f"Invalid value for cover_art_stems in configuration file ({path}): Each cover art stem must be of type str: got " ) + config += '\ncover_art_stems = [ "cover" ]' - # fuse_genres_whitelist - write(config + '\nfuse_genres_whitelist = "lalala"') + # valid_art_exts + write(config + '\nvalid_art_exts = "lalala"') with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for fuse_genres_whitelist in configuration file ({path}): Must be a list[str]: got " + == f"Invalid value for valid_art_exts in configuration file ({path}): Must be a list[str]: got " ) - write(config + "\nfuse_genres_whitelist = [123]") + write(config + "\nvalid_art_exts = [123]") with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for fuse_genres_whitelist in configuration file ({path}): Each genre must be of type str: got " + == f"Invalid value for valid_art_exts in configuration file ({path}): Each art extension must be of type str: got " ) + config += '\nvalid_art_exts = [ "jpg" ]' - # fuse_labels_whitelist - write(config + '\nfuse_labels_whitelist = "lalala"') + # max_filename_bytes + write(config + '\nmax_filename_bytes = "lalala"') with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for fuse_labels_whitelist in configuration file ({path}): Must be a list[str]: got " + == f"Invalid value for max_filename_bytes in configuration file ({path}): Must be an int: got " ) - write(config + "\nfuse_labels_whitelist = [123]") + config += "\nmax_filename_bytes = 240" + + # ignore_release_directories + write(config + '\nignore_release_directories = "lalala"') with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for fuse_labels_whitelist in configuration file ({path}): Each label must be of type str: got " + == f"Invalid value for ignore_release_directories in configuration file ({path}): Must be a list[str]: got " ) - - # fuse_artists_blacklist - write(config + '\nfuse_artists_blacklist = "lalala"') + write(config + "\nignore_release_directories = [123]") with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for fuse_artists_blacklist in configuration file ({path}): Must be a list[str]: got " + == f"Invalid value for ignore_release_directories in configuration file ({path}): Each release directory must be of type str: got " ) - write(config + "\nfuse_artists_blacklist = [123]") + config += '\nignore_release_directories = [ ".stversions" ]' + + # stored_metadata_rules + write(config + '\nstored_metadata_rules = ["lalala"]') with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for fuse_artists_blacklist in configuration file ({path}): Each artist must be of type str: got " + == f"Invalid value in stored_metadata_rules in configuration file ({path}): list values must be a dict: got " + ) + write( + config + + '\nstored_metadata_rules = [{ matcher = "tracktitle:hi", actions = ["delete:hi"] }]' ) - - # fuse_genres_blacklist - write(config + '\nfuse_genres_blacklist = "lalala"') with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( - str(excinfo.value) - == f"Invalid value for fuse_genres_blacklist in configuration file ({path}): Must be a list[str]: got " + click.unstyle(str(excinfo.value)) + == f"""\ +Failed to parse stored_metadata_rules in configuration file ({path}): rule {{'matcher': 'tracktitle:hi', 'actions': ['delete:hi']}}: Failed to parse action 1, invalid syntax: + + delete:hi + ^ + Found another section after the action kind, but the delete action has no parameters. Please remove this section. +""" + ) + write( + config + + '\nstored_metadata_rules = [{ matcher = "tracktitle:hi", actions = ["delete"], ignore = ["tracktitle:bye:"] }]' ) - write(config + "\nfuse_genres_blacklist = [123]") with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( - str(excinfo.value) - == f"Invalid value for fuse_genres_blacklist in configuration file ({path}): Each genre must be of type str: got " + click.unstyle(str(excinfo.value)) + == f"""\ +Failed to parse stored_metadata_rules in configuration file ({path}): rule {{'matcher': 'tracktitle:hi', 'actions': ['delete'], 'ignore': ['tracktitle:bye:']}}: Failed to parse ignore, invalid syntax: + + tracktitle:bye: + ^ + No flags specified: Please remove this section (by deleting the colon) or specify one of the supported flags: `i` (case insensitive). +""" ) - # fuse_descriptors_blacklist - write(config + '\nfuse_descriptors_blacklist = "lalala"') + # path_templates + write(config + '\npath_templates.source.release = "{% if hi %}{{"') with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for fuse_descriptors_blacklist in configuration file ({path}): Must be a list[str]: got " + == f"Invalid path template in configuration file ({path}) for template source.release: Failed to compile template: unexpected 'end of template'" ) - write(config + "\nfuse_descriptors_blacklist = [123]") + + # rename_source_files + write(config + '\nrename_source_files = "lalala"') with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for fuse_descriptors_blacklist in configuration file ({path}): Each descriptor must be of type str: got " + == f"Invalid value for rename_source_files in configuration file ({path}): Must be a bool: got " ) - # fuse_labels_blacklist - write(config + '\nfuse_labels_blacklist = "lalala"') + +def test_vfs_config_value_validation() -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "config.toml" + path.touch() + + def write(x: str) -> None: + with path.open("w") as fp: + fp.write(x) + + config = 'music_source_dir = "~/.music-src"\n[vfs]\n' + write(config) + + # mount_dir + write(config + "\nmount_dir = 123") with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for fuse_labels_blacklist in configuration file ({path}): Must be a list[str]: got " + == f"Invalid value for vfs.mount_dir in configuration file ({path}): must be a path" ) - write(config + "\nfuse_labels_blacklist = [123]") + config += '\nmount_dir = "~/music"' + + # artists_whitelist + write(config + '\nartists_whitelist = "lalala"') with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for fuse_labels_blacklist in configuration file ({path}): Each label must be of type str: got " + == f"Invalid value for vfs.artists_whitelist in configuration file ({path}): Must be a list[str]: got " ) - - # fuse_artists_whitelist + fuse_artists_blacklist - write(config + '\nfuse_artists_whitelist = ["a"]\nfuse_artists_blacklist = ["b"]') + write(config + "\nartists_whitelist = [123]") with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Cannot specify both fuse_artists_whitelist and fuse_artists_blacklist in configuration file ({path}): must specify only one or the other" + == f"Invalid value for vfs.artists_whitelist in configuration file ({path}): Each artist must be of type str: got " ) - # fuse_genres_whitelist + fuse_genres_blacklist - write(config + '\nfuse_genres_whitelist = ["a"]\nfuse_genres_blacklist = ["b"]') + # genres_whitelist + write(config + '\ngenres_whitelist = "lalala"') with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Cannot specify both fuse_genres_whitelist and fuse_genres_blacklist in configuration file ({path}): must specify only one or the other" + == f"Invalid value for vfs.genres_whitelist in configuration file ({path}): Must be a list[str]: got " ) - - # fuse_labels_whitelist + fuse_labels_blacklist - write(config + '\nfuse_labels_whitelist = ["a"]\nfuse_labels_blacklist = ["b"]') + write(config + "\ngenres_whitelist = [123]") with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Cannot specify both fuse_labels_whitelist and fuse_labels_blacklist in configuration file ({path}): must specify only one or the other" + == f"Invalid value for vfs.genres_whitelist in configuration file ({path}): Each genre must be of type str: got " ) - # cover_art_stems - write(config + '\ncover_art_stems = "lalala"') + # labels_whitelist + write(config + '\nlabels_whitelist = "lalala"') with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for cover_art_stems in configuration file ({path}): Must be a list[str]: got " + == f"Invalid value for vfs.labels_whitelist in configuration file ({path}): Must be a list[str]: got " ) - write(config + "\ncover_art_stems = [123]") + write(config + "\nlabels_whitelist = [123]") with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for cover_art_stems in configuration file ({path}): Each cover art stem must be of type str: got " + == f"Invalid value for vfs.labels_whitelist in configuration file ({path}): Each label must be of type str: got " ) - config += '\ncover_art_stems = [ "cover" ]' - # valid_art_exts - write(config + '\nvalid_art_exts = "lalala"') + # artists_blacklist + write(config + '\nartists_blacklist = "lalala"') with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for valid_art_exts in configuration file ({path}): Must be a list[str]: got " + == f"Invalid value for vfs.artists_blacklist in configuration file ({path}): Must be a list[str]: got " ) - write(config + "\nvalid_art_exts = [123]") + write(config + "\nartists_blacklist = [123]") with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for valid_art_exts in configuration file ({path}): Each art extension must be of type str: got " + == f"Invalid value for vfs.artists_blacklist in configuration file ({path}): Each artist must be of type str: got " ) - config += '\nvalid_art_exts = [ "jpg" ]' - # max_filename_bytes - write(config + '\nmax_filename_bytes = "lalala"') + # genres_blacklist + write(config + '\ngenres_blacklist = "lalala"') with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for max_filename_bytes in configuration file ({path}): Must be an int: got " + == f"Invalid value for vfs.genres_blacklist in configuration file ({path}): Must be a list[str]: got " + ) + write(config + "\ngenres_blacklist = [123]") + with pytest.raises(InvalidConfigValueError) as excinfo: + Config.parse(config_path_override=path) + assert ( + str(excinfo.value) + == f"Invalid value for vfs.genres_blacklist in configuration file ({path}): Each genre must be of type str: got " ) - config += "\nmax_filename_bytes = 240" - # ignore_release_directories - write(config + '\nignore_release_directories = "lalala"') + # descriptors_blacklist + write(config + '\ndescriptors_blacklist = "lalala"') with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for ignore_release_directories in configuration file ({path}): Must be a list[str]: got " + == f"Invalid value for vfs.descriptors_blacklist in configuration file ({path}): Must be a list[str]: got " ) - write(config + "\nignore_release_directories = [123]") + write(config + "\ndescriptors_blacklist = [123]") with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for ignore_release_directories in configuration file ({path}): Each release directory must be of type str: got " + == f"Invalid value for vfs.descriptors_blacklist in configuration file ({path}): Each descriptor must be of type str: got " ) - config += '\nignore_release_directories = [ ".stversions" ]' - # stored_metadata_rules - write(config + '\nstored_metadata_rules = ["lalala"]') + # labels_blacklist + write(config + '\nlabels_blacklist = "lalala"') with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value in stored_metadata_rules in configuration file ({path}): list values must be a dict: got " - ) - write( - config - + '\nstored_metadata_rules = [{ matcher = "tracktitle:hi", actions = ["delete:hi"] }]' + == f"Invalid value for vfs.labels_blacklist in configuration file ({path}): Must be a list[str]: got " ) + write(config + "\nlabels_blacklist = [123]") with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( - click.unstyle(str(excinfo.value)) - == f"""\ -Failed to parse stored_metadata_rules in configuration file ({path}): rule {{'matcher': 'tracktitle:hi', 'actions': ['delete:hi']}}: Failed to parse action 1, invalid syntax: - - delete:hi - ^ - Found another section after the action kind, but the delete action has no parameters. Please remove this section. -""" - ) - write( - config - + '\nstored_metadata_rules = [{ matcher = "tracktitle:hi", actions = ["delete"], ignore = ["tracktitle:bye:"] }]' + str(excinfo.value) + == f"Invalid value for vfs.labels_blacklist in configuration file ({path}): Each label must be of type str: got " ) + + # artists_whitelist + artists_blacklist + write(config + '\nartists_whitelist = ["a"]\nartists_blacklist = ["b"]') with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( - click.unstyle(str(excinfo.value)) - == f"""\ -Failed to parse stored_metadata_rules in configuration file ({path}): rule {{'matcher': 'tracktitle:hi', 'actions': ['delete'], 'ignore': ['tracktitle:bye:']}}: Failed to parse ignore, invalid syntax: - - tracktitle:bye: - ^ - No flags specified: Please remove this section (by deleting the colon) or specify one of the supported flags: `i` (case insensitive). -""" + str(excinfo.value) + == f"Cannot specify both vfs.artists_whitelist and vfs.artists_blacklist in configuration file ({path}): must specify only one or the other" ) - # path_templates - write(config + '\npath_templates.source.release = "{% if hi %}{{"') + # genres_whitelist + genres_blacklist + write(config + '\ngenres_whitelist = ["a"]\ngenres_blacklist = ["b"]') with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid path template in configuration file ({path}) for template source.release: Failed to compile template: unexpected 'end of template'" + == f"Cannot specify both vfs.genres_whitelist and vfs.genres_blacklist in configuration file ({path}): must specify only one or the other" ) - # rename_source_files - write(config + '\nrename_source_files = "lalala"') + # labels_whitelist + labels_blacklist + write(config + '\nlabels_whitelist = ["a"]\nlabels_blacklist = ["b"]') with pytest.raises(InvalidConfigValueError) as excinfo: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for rename_source_files in configuration file ({path}): Must be a bool: got " + == f"Cannot specify both vfs.labels_whitelist and vfs.labels_blacklist in configuration file ({path}): must specify only one or the other" ) # hide_genres_with_only_new_releases @@ -611,7 +628,7 @@ def write(x: str) -> None: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for hide_genres_with_only_new_releases in configuration file ({path}): Must be a bool: got " + == f"Invalid value for vfs.hide_genres_with_only_new_releases in configuration file ({path}): Must be a bool: got " ) # hide_descriptors_with_only_new_releases @@ -620,7 +637,7 @@ def write(x: str) -> None: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for hide_descriptors_with_only_new_releases in configuration file ({path}): Must be a bool: got " + == f"Invalid value for vfs.hide_descriptors_with_only_new_releases in configuration file ({path}): Must be a bool: got " ) # hide_labels_with_only_new_releases @@ -629,5 +646,5 @@ def write(x: str) -> None: Config.parse(config_path_override=path) assert ( str(excinfo.value) - == f"Invalid value for hide_labels_with_only_new_releases in configuration file ({path}): Must be a bool: got " + == f"Invalid value for vfs.hide_labels_with_only_new_releases in configuration file ({path}): Must be a bool: got " ) diff --git a/rose_cli/cli_test.py b/rose_cli/cli_test.py index 0c3cebd..df49e7f 100644 --- a/rose_cli/cli_test.py +++ b/rose_cli/cli_test.py @@ -26,24 +26,22 @@ def test_parse_release_from_path(config: Config) -> None: with start_virtual_fs(config): # Directory is resolved. - path = str( - config.fuse_mount_dir / "1. Releases" / "Techno Man & Bass Man - 2023. Release 1" - ) + path = str(config.vfs.mount_dir / "1. Releases" / "Techno Man & Bass Man - 2023. Release 1") assert parse_release_argument(path) == "r1" # UUID is no-opped. uuid_value = str(uuid.uuid4()) assert parse_release_argument(uuid_value) == uuid_value # Non-existent path raises error. with pytest.raises(InvalidReleaseArgError): - assert parse_release_argument(str(config.fuse_mount_dir / "1. Releases" / "lalala")) + assert parse_release_argument(str(config.vfs.mount_dir / "1. Releases" / "lalala")) # Non-release directory raises error. with pytest.raises(InvalidReleaseArgError): - assert parse_release_argument(str(config.fuse_mount_dir / "1. Releases")) + assert parse_release_argument(str(config.vfs.mount_dir / "1. Releases")) # File raises error. with pytest.raises(InvalidReleaseArgError): assert parse_release_argument( str( - config.fuse_mount_dir + config.vfs.mount_dir / "1. Releases" / "Techno Man & Bass Man - 2023. Release 1" / "01 - Track 1.m4a" @@ -63,7 +61,7 @@ def test_parse_track_id_from_path(config: Config, source_dir: Path) -> None: assert parse_track_argument(track_id) == track_id # Non-existent path raises error. with pytest.raises(InvalidTrackArgError): - assert parse_track_argument(str(config.fuse_mount_dir / "1. Releases" / "lalala")) + assert parse_track_argument(str(config.vfs.mount_dir / "1. Releases" / "lalala")) # Directory raises error. with pytest.raises(InvalidTrackArgError): assert parse_track_argument(str(source_dir / "Test Release 1")) @@ -76,7 +74,7 @@ def test_parse_track_id_from_path(config: Config, source_dir: Path) -> None: def test_parse_collage_name_from_path(config: Config, source_dir: Path) -> None: with start_virtual_fs(config): # Directory path is resolved. - path = str(config.fuse_mount_dir / "6. Collages" / "Rose Gold") + path = str(config.vfs.mount_dir / "6. Collages" / "Rose Gold") assert parse_collage_argument(path) == "Rose Gold" # File path is resolved. path = str(source_dir / "!collages" / "Rose Gold.toml") @@ -88,7 +86,7 @@ def test_parse_collage_name_from_path(config: Config, source_dir: Path) -> None: def test_parse_playlist_name_from_path(config: Config, source_dir: Path) -> None: with start_virtual_fs(config): # Directory path is resolved. - path = str(config.fuse_mount_dir / "7. Playlists" / "Lala Lisa") + path = str(config.vfs.mount_dir / "7. Playlists" / "Lala Lisa") assert parse_playlist_argument(path) # File path is resolved. path = str(source_dir / "!playlists" / "Lala Lisa.toml") diff --git a/rose_vfs/virtualfs.py b/rose_vfs/virtualfs.py index 53cb917..eb0ded9 100644 --- a/rose_vfs/virtualfs.py +++ b/rose_vfs/virtualfs.py @@ -717,22 +717,22 @@ def __init__(self, config: Config): self._label_w = None self._label_b = None - if config.fuse_artists_whitelist: - self._artist_w = set(config.fuse_artists_whitelist) - if config.fuse_artists_blacklist: - self._artist_b = set(config.fuse_artists_blacklist) - if config.fuse_genres_whitelist: - self._genre_w = set(config.fuse_genres_whitelist) - if config.fuse_genres_blacklist: - self._genre_b = set(config.fuse_genres_blacklist) - if config.fuse_descriptors_whitelist: - self._descriptor_w = set(config.fuse_descriptors_whitelist) - if config.fuse_descriptors_blacklist: - self._descriptor_b = set(config.fuse_descriptors_blacklist) - if config.fuse_labels_whitelist: - self._label_w = set(config.fuse_labels_whitelist) - if config.fuse_labels_blacklist: - self._label_b = set(config.fuse_labels_blacklist) + if config.vfs.artists_whitelist: + self._artist_w = set(config.vfs.artists_whitelist) + if config.vfs.artists_blacklist: + self._artist_b = set(config.vfs.artists_blacklist) + if config.vfs.genres_whitelist: + self._genre_w = set(config.vfs.genres_whitelist) + if config.vfs.genres_blacklist: + self._genre_b = set(config.vfs.genres_blacklist) + if config.vfs.descriptors_whitelist: + self._descriptor_w = set(config.vfs.descriptors_whitelist) + if config.vfs.descriptors_blacklist: + self._descriptor_b = set(config.vfs.descriptors_blacklist) + if config.vfs.labels_whitelist: + self._label_w = set(config.vfs.labels_whitelist) + if config.vfs.labels_blacklist: + self._label_b = set(config.vfs.labels_blacklist) def artist(self, artist: str) -> bool: if self._artist_w: @@ -1069,7 +1069,7 @@ def readdir(self, p: VirtualPath) -> Iterator[tuple[str, dict[str, Any]]]: for e1 in list_genres(self.config): if not self.can_show.genre(e1.genre): continue - if self.config.hide_genres_with_only_new_releases and e1.only_new_releases: + if self.config.vfs.hide_genres_with_only_new_releases and e1.only_new_releases: continue yield self.sanitizer.sanitize(e1.genre), self.stat("dir") return @@ -1078,7 +1078,7 @@ def readdir(self, p: VirtualPath) -> Iterator[tuple[str, dict[str, Any]]]: for e2 in list_descriptors(self.config): if not self.can_show.descriptor(e2.descriptor): continue - if self.config.hide_descriptors_with_only_new_releases and e2.only_new_releases: + if self.config.vfs.hide_descriptors_with_only_new_releases and e2.only_new_releases: continue yield self.sanitizer.sanitize(e2.descriptor), self.stat("dir") return @@ -1087,7 +1087,7 @@ def readdir(self, p: VirtualPath) -> Iterator[tuple[str, dict[str, Any]]]: for e3 in list_labels(self.config): if not self.can_show.label(e3.label): continue - if self.config.hide_labels_with_only_new_releases and e3.only_new_releases: + if self.config.vfs.hide_labels_with_only_new_releases and e3.only_new_releases: continue yield self.sanitizer.sanitize(e3.label), self.stat("dir") return @@ -1922,7 +1922,7 @@ def mount_virtualfs(c: Config, debug: bool = False) -> None: options.add("fsname=rose") if debug: options.add("debug") - llfuse.init(VirtualFS(c), str(c.fuse_mount_dir), options) + llfuse.init(VirtualFS(c), str(c.vfs.mount_dir), options) try: llfuse.main(workers=c.max_proc) except: @@ -1932,4 +1932,4 @@ def mount_virtualfs(c: Config, debug: bool = False) -> None: def unmount_virtualfs(c: Config) -> None: - subprocess.run(["umount", str(c.fuse_mount_dir)]) + subprocess.run(["umount", str(c.vfs.mount_dir)]) diff --git a/rose_vfs/virtualfs_test.py b/rose_vfs/virtualfs_test.py index 3ccf435..d83ae35 100644 --- a/rose_vfs/virtualfs_test.py +++ b/rose_vfs/virtualfs_test.py @@ -39,7 +39,7 @@ def can_read(p: Path) -> bool: with p.open("rb") as fp: return fp.read(256) != b"\x00" * 256 - root = config.fuse_mount_dir + root = config.vfs.mount_dir with start_virtual_fs(config): # fmt: off assert not (root / "lalala").exists() @@ -140,7 +140,7 @@ def test_virtual_filesystem_write_files( source_dir: Path, # noqa: ARG001 ) -> None: """Assert that 1. we can write files and 2. cache updates in response.""" - root = config.fuse_mount_dir + root = config.vfs.mount_dir path = root / "1. Releases" / "BLACKPINK - 1990. I Love Blackpink [NEW]" / "01. Track 1.m4a" with start_virtual_fs(config): # Write! @@ -160,7 +160,7 @@ def test_virtual_filesystem_write_files( @pytest.mark.usefixtures("seeded_cache") def test_virtual_filesystem_collage_actions(config: Config) -> None: - root = config.fuse_mount_dir + root = config.vfs.mount_dir src = config.music_source_dir with start_virtual_fs(config): @@ -199,7 +199,7 @@ def test_virtual_filesystem_collage_actions(config: Config) -> None: @pytest.mark.usefixtures("seeded_cache") def test_virtual_filesystem_add_collage_release_with_any_dirname(config: Config) -> None: """Test that we can add a release from the esoteric views to a collage, regardless of directory name.""" - root = config.fuse_mount_dir + root = config.vfs.mount_dir with start_virtual_fs(config): shutil.copytree( @@ -216,7 +216,7 @@ def test_virtual_filesystem_playlist_actions( config: Config, source_dir: Path, # noqa: ARG001 ) -> None: - root = config.fuse_mount_dir + root = config.vfs.mount_dir src = config.music_source_dir release_dir = root / "1. Releases" / "BLACKPINK - 1990. I Love Blackpink [NEW]" @@ -263,7 +263,7 @@ def test_virtual_filesystem_release_cover_art_actions( config: Config, source_dir: Path, # noqa: ARG001 ) -> None: - root = config.fuse_mount_dir + root = config.vfs.mount_dir release_dir = root / "1. Releases" / "BLACKPINK - 1990. I Love Blackpink [NEW]" with start_virtual_fs(config): assert not (release_dir / "cover.jpg").is_file() @@ -305,7 +305,7 @@ def test_virtual_filesystem_playlist_cover_art_actions( config: Config, source_dir: Path, # noqa: ARG001 ) -> None: - root = config.fuse_mount_dir + root = config.vfs.mount_dir playlist_dir = root / "7. Playlists" / "Lala Lisa" with start_virtual_fs(config): assert (playlist_dir / "cover.jpg").is_file() @@ -351,7 +351,7 @@ def test_virtual_filesystem_playlist_cover_art_actions( def test_virtual_filesystem_delete_release(config: Config, source_dir: Path) -> None: dirname = "NewJeans - 1990. I Love NewJeans" - root = config.fuse_mount_dir + root = config.vfs.mount_dir with start_virtual_fs(config): # Fix: If we return EACCES from unlink, then `rm -r` fails despite `rmdir` succeeding. Thus # we no-op if we cannot unlink a file. And we test the real tool we want to use in @@ -370,7 +370,7 @@ def test_virtual_filesystem_read_from_deleted_file(config: Config, source_dir: P """ source_path = source_dir / "Test Release 1" / "01.m4a" fuse_path = ( - config.fuse_mount_dir + config.vfs.mount_dir / "1. Releases" / "BLACKPINK - 1990. I Love Blackpink [NEW]" / "01. Track 1.m4a" @@ -382,19 +382,22 @@ def test_virtual_filesystem_read_from_deleted_file(config: Config, source_dir: P fuse_path.open("rb") # Assert that the virtual fs did not crash. It needs some time to propagate the crash. time.sleep(0.05) - assert (config.fuse_mount_dir / "1. Releases").is_dir() + assert (config.vfs.mount_dir / "1. Releases").is_dir() @pytest.mark.usefixtures("seeded_cache") def test_virtual_filesystem_blacklist(config: Config) -> None: new_config = dataclasses.replace( config, - fuse_artists_blacklist=["Bass Man"], - fuse_genres_blacklist=["Techno"], - fuse_descriptors_blacklist=["Warm"], - fuse_labels_blacklist=["Silk Music"], + vfs=dataclasses.replace( + config.vfs, + artists_blacklist=["Bass Man"], + genres_blacklist=["Techno"], + descriptors_blacklist=["Warm"], + labels_blacklist=["Silk Music"], + ), ) - root = config.fuse_mount_dir + root = config.vfs.mount_dir with start_virtual_fs(new_config): assert (root / "2. Artists" / "Techno Man").is_dir() assert (root / "3. Genres" / "Deep House").is_dir() @@ -410,12 +413,15 @@ def test_virtual_filesystem_blacklist(config: Config) -> None: def test_virtual_filesystem_whitelist(config: Config) -> None: new_config = dataclasses.replace( config, - fuse_artists_whitelist=["Bass Man"], - fuse_genres_whitelist=["Techno"], - fuse_descriptors_whitelist=["Warm"], - fuse_labels_whitelist=["Silk Music"], + vfs=dataclasses.replace( + config.vfs, + artists_whitelist=["Bass Man"], + genres_whitelist=["Techno"], + descriptors_whitelist=["Warm"], + labels_whitelist=["Silk Music"], + ), ) - root = config.fuse_mount_dir + root = config.vfs.mount_dir with start_virtual_fs(new_config): assert not (root / "2. Artists" / "Techno Man").exists() assert not (root / "3. Genres" / "Deep House").exists() @@ -431,11 +437,14 @@ def test_virtual_filesystem_whitelist(config: Config) -> None: def test_virtual_filesystem_hide_new_release_classifiers(config: Config) -> None: new_config = dataclasses.replace( config, - hide_genres_with_only_new_releases=True, - hide_descriptors_with_only_new_releases=True, - hide_labels_with_only_new_releases=True, + vfs=dataclasses.replace( + config.vfs, + hide_genres_with_only_new_releases=True, + hide_descriptors_with_only_new_releases=True, + hide_labels_with_only_new_releases=True, + ), ) - root = config.fuse_mount_dir + root = config.vfs.mount_dir with start_virtual_fs(new_config): assert not (root / "3. Genres" / "Modern Classical").exists() assert not (root / "4. Descriptors" / "Wet").exists()