From 60617aa4d3efa6eb173569ce74d91394dae15848 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 5 May 2026 17:51:41 +0200 Subject: [PATCH 1/4] Added service abstraction layer with proper registry. --- plistsync/__main__.py | 6 +- plistsync/services/__init__.py | 77 ++++++++++++++++++++++---- plistsync/services/beets/__init__.py | 13 ++++- plistsync/services/local/__init__.py | 12 +++- plistsync/services/plex/__init__.py | 11 +++- plistsync/services/spotify/__init__.py | 11 +++- plistsync/services/tidal/__init__.py | 15 ++++- plistsync/services/traktor/__init__.py | 11 +++- 8 files changed, 133 insertions(+), 23 deletions(-) diff --git a/plistsync/__main__.py b/plistsync/__main__.py index b40dd028..06cbc08e 100644 --- a/plistsync/__main__.py +++ b/plistsync/__main__.py @@ -7,6 +7,8 @@ from eyconf.cli import create_config_cli from rich.logging import RichHandler +from plistsync.services import ServiceRegistry + from .config import Config from .errors import DependencyError from .logger import log, set_log_level @@ -107,10 +109,8 @@ def version_callback(value: bool) -> None: from importlib.metadata import version - from .services import available_services - ver = version("plistsync") - services = [service.name.split(".")[-1] for service in available_services()] + services = [service for service in ServiceRegistry.dict().keys()] svc_str = ", ".join(services) if services else "none" typer.echo(f"plistsync: {ver} ({svc_str})") diff --git a/plistsync/services/__init__.py b/plistsync/services/__init__.py index ce5ba820..87179869 100644 --- a/plistsync/services/__init__.py +++ b/plistsync/services/__init__.py @@ -2,26 +2,79 @@ # might raise with check_dependencies. import importlib +import inspect import pkgutil +from abc import ABC from functools import cache +from typing import ClassVar +from plistsync.core import Library, Playlist, Track from plistsync.errors import DependencyError -@cache -def available_services() -> list[pkgutil.ModuleInfo]: - """Get the available services. +class Service(ABC): + """Abstraction for discoverable service plugins. - Skips modules where dependencies are missing. + Subclasses in `services//` modules are automatically + discovered by the ServiceRegistry. + + Each concrete Service exposes the key types that belong to its + service: the library/collection class and the track class. + Optionally a playlist class for playlist-capable services. + """ + + track_cls: ClassVar[type[Track]] + library_cls: ClassVar[type[Library] | None] = None + playlist_cls: ClassVar[type[Playlist] | None] = None + + @property + def name(self) -> str: + """Service name, inferred from the module (e.g. 'spotify', 'plex').""" + return self.__module__.split(".")[-1] + + +class ServiceRegistry: + """Central registry for dynamic service discovery and instantiation. + + Automatically discovers Service subclasses in services/* modules + using pkgutil + importlib. Lazy-loaded with @cache for performance. + Handles missing dependencies gracefully. + + Usage: + >>> service = ServiceRegistry.get_service('spotify') + >>> services = ServiceRegistry.list() # {'spotify': SpotifyService(), ...} """ - valid_services = [] - for module_info in pkgutil.iter_modules(__path__, __name__ + "."): + + @classmethod + @cache + def get(cls, name: str) -> Service | None: + """Dynamically import services.NAME module, find Service ABC, instantiate.""" try: - # Test import - catches missing deps (beets, plex, etc.) - importlib.import_module(module_info.name) - valid_services.append(module_info) + module = importlib.import_module(f"{__name__}.{name}") except DependencyError: - # Skip services with missing dependencies - pass + return None + + # Find first Service subclass (assumes 1/module) + service_cls: None | type[Service] = None + for _, obj in inspect.getmembers(module, inspect.isclass): + if issubclass(obj, Service) and obj != Service: + service_cls = obj + break + + return service_cls() if service_cls else None + + @classmethod + @cache + def dict(cls) -> dict[str, Service]: + """All importable services {name: instance}. - return valid_services + Scans services/* modules via pkgutil, filters valid ones. + """ + services: dict[str, Service] = {} + # REQUIRES: ServiceRegistry in package with __path__ (services/__init__.py) + for module_info in pkgutil.iter_modules(__path__, __name__ + "."): + short_name = module_info.name.rsplit(".", 1)[-1] + service = cls.get(short_name) + if service is not None: + services[short_name] = service + return services diff --git a/plistsync/services/beets/__init__.py b/plistsync/services/beets/__init__.py index 57d113f0..c84b9156 100644 --- a/plistsync/services/beets/__init__.py +++ b/plistsync/services/beets/__init__.py @@ -1,4 +1,5 @@ from plistsync.errors import check_imports +from plistsync.services import Service check_imports( service="beets", @@ -9,4 +10,14 @@ from .database import BeetsDatabase from .track import BeetsTrack -__all__ = ["BeetsCollection", "BeetsDatabase", "BeetsTrack"] + +class BeetsService(Service): + track_cls = BeetsTrack + + +__all__ = [ + "BeetsCollection", + "BeetsDatabase", + "BeetsService", + "BeetsTrack", +] diff --git a/plistsync/services/local/__init__.py b/plistsync/services/local/__init__.py index 1e05efed..a73321b9 100644 --- a/plistsync/services/local/__init__.py +++ b/plistsync/services/local/__init__.py @@ -1,4 +1,5 @@ from plistsync.errors import check_imports +from plistsync.services import Service check_imports( service="local", @@ -8,4 +9,13 @@ from .collection import LocalCollection from .track import LocalTrack -__all__ = ["LocalCollection", "LocalTrack"] + +class LocalService(Service): + track_cls = LocalTrack + + +__all__ = [ + "LocalCollection", + "LocalService", + "LocalTrack", +] diff --git a/plistsync/services/plex/__init__.py b/plistsync/services/plex/__init__.py index 71653b68..0a3fb6cb 100644 --- a/plistsync/services/plex/__init__.py +++ b/plistsync/services/plex/__init__.py @@ -1,4 +1,5 @@ from plistsync.errors import check_imports +from plistsync.services import Service check_imports( service="plex", @@ -10,9 +11,17 @@ from .playlist import PlexPlaylist from .track import PlexTrack + +class PlexService(Service): + library_cls = PlexLibrary + track_cls = PlexTrack + playlist_cls = PlexPlaylist + + __all__ = [ - "api", "PlexLibrary", "PlexPlaylist", + "PlexService", "PlexTrack", + "api", ] diff --git a/plistsync/services/spotify/__init__.py b/plistsync/services/spotify/__init__.py index 56dbe8b8..9fb108b4 100644 --- a/plistsync/services/spotify/__init__.py +++ b/plistsync/services/spotify/__init__.py @@ -1,4 +1,5 @@ from plistsync.errors import check_imports +from plistsync.services import Service check_imports( service="spotify", @@ -10,10 +11,18 @@ from .playlist import SpotifyPlaylist from .track import SpotifyPlaylistTrack, SpotifyTrack + +class SpotifyService(Service): + library_cls = SpotifyLibrary + track_cls = SpotifyTrack + playlist_cls = SpotifyPlaylist + + __all__ = [ - "api", "SpotifyLibrary", "SpotifyPlaylist", "SpotifyPlaylistTrack", + "SpotifyService", "SpotifyTrack", + "api", ] diff --git a/plistsync/services/tidal/__init__.py b/plistsync/services/tidal/__init__.py index b65423be..3e1fdb68 100644 --- a/plistsync/services/tidal/__init__.py +++ b/plistsync/services/tidal/__init__.py @@ -1,4 +1,5 @@ from plistsync.errors import check_imports +from plistsync.services import Service check_imports( service="tidal", @@ -10,10 +11,18 @@ from .playlist import TidalPlaylist from .track import TidalPlaylistTrack, TidalTrack + +class TidalService(Service): + library_cls = TidalLibrary + track_cls = TidalTrack + playlist_cls = TidalPlaylist + + __all__ = [ - "api", - "TidalTrack", - "TidalPlaylistTrack", "TidalLibrary", "TidalPlaylist", + "TidalPlaylistTrack", + "TidalService", + "TidalTrack", + "api", ] diff --git a/plistsync/services/traktor/__init__.py b/plistsync/services/traktor/__init__.py index 7ac33cba..74c50290 100644 --- a/plistsync/services/traktor/__init__.py +++ b/plistsync/services/traktor/__init__.py @@ -1,4 +1,5 @@ from plistsync.errors import check_imports +from plistsync.services import Service check_imports( service="traktor", @@ -10,10 +11,18 @@ from .playlist import NMLPlaylist from .track import NMLPlaylistTrack, NMLTrack + +class TraktorService(Service): + library_cls = NMLLibrary + playlist_cls = NMLPlaylist + track_cls = NMLTrack + + __all__ = [ - "NMLPath", "NMLLibrary", + "NMLPath", "NMLPlaylist", "NMLPlaylistTrack", "NMLTrack", + "TraktorService", ] From 208a9ebbd24bd0679ebe3d06e89b247930a8c3a6 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 5 May 2026 18:02:18 +0200 Subject: [PATCH 2/4] Adjusted migrate example for service abstraction (does not depend on spotify and tidal anymore!) --- examples/community/migrate.py | 45 +++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/examples/community/migrate.py b/examples/community/migrate.py index 718dcbf3..0a2c96c5 100644 --- a/examples/community/migrate.py +++ b/examples/community/migrate.py @@ -12,8 +12,7 @@ from plistsync.core import Library, Matches, ServicePlaylist, Track from plistsync.logger import log -from plistsync.services.spotify import SpotifyLibrary -from plistsync.services.tidal import TidalLibrary +from plistsync.services import ServiceRegistry class MigrationContext(NamedTuple): @@ -113,12 +112,6 @@ def migrate_library( ) -service_mapping: dict[str, type[SpotifyLibrary] | type[TidalLibrary]] = { - "spotify": SpotifyLibrary, - "tidal": TidalLibrary, -} - - def main( from_service: Annotated[ str, @@ -145,25 +138,41 @@ def main( ), ] = True, ): - if not (from_library := service_mapping.get(from_service.lower())): + if not (_from_service := ServiceRegistry.get(from_service.lower())): + log.error( + f"Invalid from_service {from_service!r}.\n" + f"Pick one of {list(ServiceRegistry.dict().keys())}" + ) + sys.exit(1) + + if _from_service.library_cls is None: log.error( - f"Invalid from_service {from_service!r}." - f"Pick one of {service_mapping.keys()}" + f"Invalid from_service {_from_service}.\n" + "Does not support library based operations." ) sys.exit(1) - if not (to_library := service_mapping.get(to_service.lower())): + + if not (_to_service := ServiceRegistry.get(to_service.lower())): + log.error( + f"Invalid to_service {to_service!r}.\n" + f"Pick one of {list(ServiceRegistry.dict().keys())}." + ) + sys.exit(1) + + if _to_service.library_cls is None: log.error( - f"Invalid to_service {to_service!r}. " - f"Pick one of {list(service_mapping.keys())}." + f"Invalid to_service {_to_service}.\n" + "Does not support library based operations." ) sys.exit(1) - if from_library == to_library: - raise ValueError("from_service and to_service must be different!") + if _from_service == _to_service: + log.error("from_service and to_service must be different!") + sys.exit(1) migrate_library( - from_library(), - to_library(), + _from_service.library_cls(), + _to_service.library_cls(), MigrationContext(overwrite=overwrite, skip_empty=skip_empty), ) From 7918cca5b5512b24ad5b95b7b53bcf38711b9c8e Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 5 May 2026 18:10:59 +0200 Subject: [PATCH 3/4] Changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f25b9603..ddc81318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.6.1] - Upcoming +## [0.7.0] - Upcoming ### Added +- Added typed `Service` marker class with `track_cls`, `library_cls`, `playlist_cls` attributes and module-inferred `name` property, enabling single-handle access to all a service's core types. (#95) - Added a migration example to fully copy a playlist from an arbitrary service to another. (#60) ### Fixed From 53067a437ca08c8bb01a38647c4f0e646f4a05bb Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Wed, 6 May 2026 16:50:32 +0200 Subject: [PATCH 4/4] Added tests --- plistsync/services/__init__.py | 2 +- tests/services/test_registry.py | 69 +++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/services/test_registry.py diff --git a/plistsync/services/__init__.py b/plistsync/services/__init__.py index 87179869..72d9a0f9 100644 --- a/plistsync/services/__init__.py +++ b/plistsync/services/__init__.py @@ -51,7 +51,7 @@ def get(cls, name: str) -> Service | None: """Dynamically import services.NAME module, find Service ABC, instantiate.""" try: module = importlib.import_module(f"{__name__}.{name}") - except DependencyError: + except (DependencyError, ModuleNotFoundError): return None # Find first Service subclass (assumes 1/module) diff --git a/tests/services/test_registry.py b/tests/services/test_registry.py new file mode 100644 index 00000000..a98f376a --- /dev/null +++ b/tests/services/test_registry.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import sys +from types import ModuleType +from unittest.mock import MagicMock, patch + +import pytest + +from plistsync.errors import DependencyError +from plistsync.services import Service, ServiceRegistry + + +class TestDiscovery: + """ServiceRegistry discovers Service subclasses and infers names.""" + + @pytest.fixture(autouse=True) + def clear_cache(self): + ServiceRegistry.get.cache_clear() + yield + ServiceRegistry.get.cache_clear() + + def test_get_service_returns_instance_with_correct_name(self): + """Discovered service has name matching its module.""" + fake_module = ModuleType("plistsync.services._test_fake") + + class FakeService(Service): + track_cls = int # type: ignore[assignment] + + FakeService.__module__ = "plistsync.services._test_fake" + fake_module.FakeService = FakeService # type: ignore[assignment] + + with patch.object( + sys, "modules", {**sys.modules, fake_module.__name__: fake_module} + ): + with patch("importlib.import_module", return_value=fake_module): + service = ServiceRegistry.get("_test_fake") + + assert service is not None + assert service.name == "_test_fake" + + def test_get_service_returns_none_for_missing_dependency(self): + with patch( + "importlib.import_module", + side_effect=DependencyError("svc", ["pkg"]), + ): + assert ServiceRegistry.get("svc") is None + + def test_get_service_returns_none_when_no_service_subclass(self): + empty = ModuleType("plistsync.services._test_empty") + with patch.object(sys, "modules", {**sys.modules, empty.__name__: empty}): + with patch("importlib.import_module", return_value=empty): + assert ServiceRegistry.get("_test_empty") is None + + def test_dict_keys_are_module_names(self): + result = ServiceRegistry.dict() + for name in result: + assert result[name].name == name + + def test_dict_filters_broken_modules(self): + with patch( + "pkgutil.iter_modules", + return_value=[MagicMock(name="_test_broken")], + ): + with patch( + "importlib.import_module", + side_effect=DependencyError("_test_broken", ["pkg"]), + ): + result = ServiceRegistry.dict() + assert "_test_broken" not in result