diff --git a/poetry.lock b/poetry.lock index e045b18..87620e1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1101,7 +1101,7 @@ test = ["pytest"] name = "typeguard" version = "2.12.1" description = "Run-time type checker for Python" -category = "dev" +category = "main" optional = false python-versions = ">=3.5.3" @@ -1204,7 +1204,7 @@ notebook = ">=4.4.1" [metadata] lock-version = "1.1" python-versions = ">=3.9,<3.10" -content-hash = "1f2c2176ef71a0bf6bcab39041763c91bc92735c595f0eb6d572f8a0f702b41b" +content-hash = "8f079695c990660e3e3b7927c1480cca934a905ce52ee416fcbf6a2438b299ad" [metadata.files] appdirs = [ diff --git a/pyproject.toml b/pyproject.toml index a610275..2d7b0d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ uvloop = "^0.15.2" desert = "^2020.11.18" numpy = "^1.21.2" SQLAlchemy = "^1.4.25" +typeguard = "^2.12.1" [tool.poetry.dev-dependencies] typeguard = "^2.12.0" diff --git a/src/playlist/data/base.py b/src/playlist/data/base.py index 6bca14a..cf424bc 100644 --- a/src/playlist/data/base.py +++ b/src/playlist/data/base.py @@ -10,8 +10,8 @@ import desert import marshmallow -DataSub = typing.TypeVar("DataSub") -DataDict = typing.NewType("DataDict", dict[str, typing.Any]) +DataSub = typing.TypeVar("DataSub", bound="BaseData") +DataDict = typing.TypeVar("DataDict") class DataMeta(type): @@ -38,11 +38,12 @@ class DataMeta(type): data class in dictionary form. Returns: - DataDict: The TypedDict for this object. + dict[str, object]: The TypedDict for this object. """ fields = { - name: type_ if type_ is not datetime.datetime else str + name: str if type_ is datetime.datetime else type_ for name, type_ in cls.__annotations__.items() + if name != "id" } return typing.TypedDict( # type: ignore [no-any-return,operator] f"{cls.__name__}Dict", @@ -51,17 +52,17 @@ class DataMeta(type): @dataclasses.dataclass -class BaseData(typing.Generic[DataSub], metaclass=DataMeta): +class BaseData(metaclass=DataMeta): """Base class for data classes in the application.""" __sa_dataclass_metadata_key__ = "sa" @classmethod - def load(cls: type[BaseData[DataSub]], data: DataDict) -> DataSub: + def load(cls: type[BaseData], data: DataDict) -> DataSub: """Load the given data dictionary into a class instance.""" return typing.cast(DataSub, cls.Schema.load(data)) - def dump(self: BaseData[DataSub]) -> DataDict: + def dump(self: BaseData) -> DataDict: """Dump the class instance into a data dictionary.""" cls = type(self) return typing.cast(DataDict, cls.Schema.dump(self)) diff --git a/src/playlist/data/models.py b/src/playlist/data/models.py index 3ebd215..5952f89 100644 --- a/src/playlist/data/models.py +++ b/src/playlist/data/models.py @@ -12,7 +12,7 @@ mapper_registry = sqlalchemy.orm.registry() @mapper_registry.mapped @dataclasses.dataclass -class Track(base.BaseData["Track"]): +class Track(base.BaseData): """Model defining a Track object.""" __tablename__ = "tracks" diff --git a/src/playlist/data/settings.py b/src/playlist/data/settings.py index 5d39a3f..9253134 100644 --- a/src/playlist/data/settings.py +++ b/src/playlist/data/settings.py @@ -7,17 +7,19 @@ import dataclasses import datetime import functools import pathlib +import typing import yaml from playlist.data import base from playlist.data import const + __all__ = ["get", "modify"] @dataclasses.dataclass -class CredentialSettings(base.BaseData["CredentialSettings"]): +class CredentialSettings(base.BaseData): """Credentials component for Settings object.""" baseurl: str @@ -28,11 +30,11 @@ class CredentialSettings(base.BaseData["CredentialSettings"]): from playlist.plex import server data = server.get_creds() - return cls.load(data) + return typing.cast(CredentialSettings, cls.load(data)) @dataclasses.dataclass -class DownloadSettings(base.BaseData["DownloadSettings"]): +class DownloadSettings(base.BaseData): """DownloadSettingser options for Settings object.""" batch_size: int @@ -47,7 +49,7 @@ class DownloadSettings(base.BaseData["DownloadSettings"]): @dataclasses.dataclass -class TrackSettings(base.BaseData["TrackSettings"]): +class TrackSettings(base.BaseData): """Track options for Settings object.""" duration: float @@ -58,7 +60,7 @@ class TrackSettings(base.BaseData["TrackSettings"]): @dataclasses.dataclass -class PlaylistSettings(base.BaseData["PlaylistSettings"]): +class PlaylistSettings(base.BaseData): """Playlist options for Settings object.""" playtime: datetime.timedelta @@ -73,7 +75,7 @@ class PlaylistSettings(base.BaseData["PlaylistSettings"]): @dataclasses.dataclass -class Settings(base.BaseData["Settings"]): +class Settings(base.BaseData): """Settings object, loaded from settings.yaml file.""" creds: CredentialSettings = dataclasses.field( @@ -95,7 +97,7 @@ class Settings(base.BaseData["Settings"]): """Read the given YAML file and convert it into an object.""" with filepath.open() as fp: data = yaml.safe_load(fp) - return cls.load(data) + return typing.cast(Settings, cls.load(data)) def yaml_write(self: Settings, filepath: pathlib.Path) -> None: """Write this object as the given YAML file.""" @@ -117,8 +119,8 @@ class Settings(base.BaseData["Settings"]): @functools.cache def get() -> Settings: # pragma: no cover """Get the Settings object instance.""" - instance: Settings filepath = const.PATHS.CONFIG / const.SETTINGS_FILENAME + instance: Settings try: instance = Settings.yaml_read(filepath) except FileNotFoundError: diff --git a/src/playlist/plex/server.py b/src/playlist/plex/server.py index 108272a..bf00cea 100644 --- a/src/playlist/plex/server.py +++ b/src/playlist/plex/server.py @@ -33,10 +33,10 @@ def calc_delay(times: list[float], weights: list[float]) -> None: with settings.modify() as s: avg_time_per_batch = utils.harmonic_mean(times, weights=weights) delay = avg_time_per_batch / const.MAX_PROCESSES - s.download.process_delay = round(delay, ndigits=6) + s.download.process_delay = round(delay, ndigits=3) -def calc_duration(durations: list[int]) -> None: +def calc_duration(durations: list[float]) -> None: """Calculate the average duration and max tracks to play each day. This is based on the geometric mean of the durations of every track loaded from @@ -48,7 +48,7 @@ def calc_duration(durations: list[int]) -> None: """ with settings.modify() as s: avg_track_duration = statistics.geometric_mean(durations) - s.track.duration = round(avg_track_duration, ndigits=6) + s.track.duration = round(avg_track_duration / 1000, ndigits=3) playtime_seconds = s.playlist.playtime.total_seconds() s.playlist.max_tracks = int(playtime_seconds / avg_track_duration) @@ -120,7 +120,7 @@ def get_creds() -> settings.CredentialSettings.Dict: # type: ignore [name-defin account = plexapi.myplex.MyPlexAccount(username, password) plex = account.resource(server).connect() - return {"baseurl": plex._baseurl, "token": plex._token} + return settings.CredentialSettings.Dict(baseurl=plex._baseurl, token=plex._token) async def _downloader( @@ -192,7 +192,7 @@ def _track_dump(track: plexapi.audio.Track) -> models.Track.Dict: # type: ignor album_num=track.parentIndex, album=track.parentTitle, album_artist=track.grandparentTitle, - duration=round(track.duration / 1000, ndigits=6), + duration=track.duration, rating=track.userRating, comments=track.summary, added=str(track.addedAt), diff --git a/tests/data/test_base.py b/tests/data/test_base.py index 27b30a3..20673cb 100644 --- a/tests/data/test_base.py +++ b/tests/data/test_base.py @@ -10,7 +10,7 @@ from playlist.data import base @dataclasses.dataclass -class Dummy(base.BaseData["Dummy"]): +class Dummy(base.BaseData): """Dummy class for tests.""" name: str diff --git a/tests/plex/test_server.py b/tests/plex/test_server.py index f81af54..cd55c39 100644 --- a/tests/plex/test_server.py +++ b/tests/plex/test_server.py @@ -1,9 +1,11 @@ """Tests for the playlist.plex.server module.""" import asyncio +import datetime import unittest.mock import pytest +from playlist.data import models from playlist.data import settings from playlist.plex import server @@ -117,14 +119,36 @@ async def test_downloader(mocker): # type: ignore [no-untyped-def] assert process_time >= 0.1 -def test_get_track_batch(mocker): # type: ignore [no-untyped-def] +@pytest.fixture +def fake_trackdict(fake_plextrack): # type: ignore [no-untyped-def] + """Make a fake track dict object.""" + return models.Track.Dict( + plex_id=fake_plextrack.ratingKey, + track_num=fake_plextrack.index, + title=fake_plextrack.title, + artist=fake_plextrack.artist().title, + album_num=fake_plextrack.parentIndex, + album=fake_plextrack.parentTitle, + album_artist=fake_plextrack.grandparentTitle, + duration=fake_plextrack.duration, + rating=fake_plextrack.userRating, + comments=fake_plextrack.summary, + added=str(fake_plextrack.addedAt), + play_count=fake_plextrack.viewCount, + played=str(fake_plextrack.lastViewedAt) + if fake_plextrack.lastViewedAt is not None + else None, + ) + + +def test_get_track_batch(mocker, fake_trackdict): # type: ignore [no-untyped-def] """Test _get_track_batch function.""" mock_plexapi_server = mocker.patch("playlist.plex.server.plexapi.server") mock_server = mock_plexapi_server.PlexServer.return_value mock_search_tracks = mock_server.library.section.return_value.searchTracks mock_search_tracks.return_value = ["Not a track."] mock_track_dump = mocker.patch("playlist.plex.server._track_dump") - mock_track_dump.return_value = "Also not a track." + mock_track_dump.return_value = fake_trackdict result = server._get_track_batch(1, 1) @@ -132,8 +156,9 @@ def test_get_track_batch(mocker): # type: ignore [no-untyped-def] assert mock_track_dump.called -def test_track_dump(mocker): # type: ignore [no-untyped-def] - """Test the _track_dump function.""" +@pytest.fixture +def fake_plextrack(): # type: ignore [no-untyped-def] + """Mock up a fake Plex track for testing.""" mock_track = unittest.mock.MagicMock() mock_track.configure_mock( ratingKey=1, @@ -145,15 +170,20 @@ def test_track_dump(mocker): # type: ignore [no-untyped-def] duration=1000, userRating=1, summary="Whatever", - addedAt="Some time", + addedAt=datetime.datetime.now(), viewCount=1, lastViewedAt=None, ) mock_track.artist.return_value.title = "Nothing" + return mock_track - result = server._track_dump(mock_track) - assert isinstance(result, dict) +def test_track_dump(fake_plextrack, fake_trackdict): # type: ignore [no-untyped-def] + """Test the _track_dump function.""" + print(models.Track.Dict.__annotations__) + result = server._track_dump(fake_plextrack) + + assert result == fake_trackdict @pytest.mark.asyncio