Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Replace custom models with msgspec #2157

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e24b859
Add msgspec as dependency
jodal Mar 4, 2024
01f97e7
types: Reorder
jodal Mar 9, 2024
50596fd
types: Move PlaybackState to mopidy.types
jodal Mar 9, 2024
f17f00a
types: Add DateOrYear type
jodal Mar 9, 2024
b65836e
types: Add validation pattern for Uri and UriSchema
jodal Mar 9, 2024
5993458
types: Add NonNegativeInt
jodal Mar 9, 2024
95f62fa
types: Use Uri type in more places
jodal Mar 9, 2024
2d8a194
models: Reimplement with msgspec
jodal Mar 9, 2024
4805a3f
models: Fix type of collections passed into models
jodal Mar 9, 2024
c12397e
models: Split tests
jodal Mar 9, 2024
7baddef
core: Fix types for state persistence
jodal Mar 9, 2024
bb4e654
core: Make TlTrack unpacking typesafe
jodal Mar 9, 2024
bfad1e3
core: Use TracklistId type instead of int
jodal Mar 9, 2024
7fce673
core: Use msgspec for persisted state
jodal Mar 9, 2024
63f5bdf
core: Update tests
jodal Mar 9, 2024
27c7828
jsonrpc: Use msgspec for JSON encoding/decoding
jodal Mar 9, 2024
f9ca597
m3u: Update tests
jodal Mar 9, 2024
53e6e08
Return early if model's URI field is unset
jodal Mar 9, 2024
de0b4b7
Update changelog
jodal Mar 10, 2024
c0d47a4
models: Convert from a module to a package again
jodal Mar 10, 2024
bbaa084
models: Register models in a registry
jodal Mar 10, 2024
9040ab7
models: Reintroduce JSON encoder/decoder helpers
jodal Mar 10, 2024
30ecfa8
models: Remove empty collections from repr/serialization
jodal Mar 10, 2024
e3a4323
jsonrpc: Handle lists as params
jodal Mar 10, 2024
0fde81e
jsonrpc: Do not omit None results from serialization
jodal Mar 12, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/changelog.rst
Expand Up @@ -22,6 +22,8 @@ Dependencies

- GStreamer >= 1.22.0 is now required.

- msgspec >= 0.18.6 is now required.

- PyGObject >= 3.42 is now an explicit Python dependency, and not something we
assume you'll install together with GStreamer.

Expand Down Expand Up @@ -95,7 +97,11 @@ Models

Changes to the data models may affect any Mopidy extension or client.

- No changes so far.
- Our models are now based on the `msgspec <https://jcristharif.com/msgspec/>`_
library, which means that they are now fully typed. Memory usage should be
similar, but JSON encodding/decoding a lot faster. This change might cause
issues in extensions that have not been tested and updated to work with Mopidy
4.0.

Audio API
---------
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Expand Up @@ -24,6 +24,7 @@ classifiers = [
]
dynamic = ["version"]
dependencies = [
"msgspec >= 0.18.6",
"pygobject >= 3.42",
"pykka >= 4.0",
"requests >= 2.28",
Expand Down
4 changes: 3 additions & 1 deletion src/mopidy/audio/__init__.py
@@ -1,11 +1,13 @@
from mopidy.types import PlaybackState

from .actor import Audio, AudioProxy
from .constants import PlaybackState
from .listener import AudioListener
from .utils import supported_uri_schemes

__all__ = [
"Audio",
"AudioProxy",
# Re-exported from mopidy.types for backwards compatabiiity:
"PlaybackState",
"AudioListener",
"supported_uri_schemes",
Expand Down
3 changes: 1 addition & 2 deletions src/mopidy/audio/actor.py
Expand Up @@ -12,11 +12,10 @@
from mopidy import exceptions
from mopidy.audio import tags as tags_lib
from mopidy.audio import utils
from mopidy.audio.constants import PlaybackState
from mopidy.audio.listener import AudioListener
from mopidy.internal import process
from mopidy.internal.gi import GLib, Gst, GstPbutils
from mopidy.types import DurationMs, Percentage
from mopidy.types import DurationMs, Percentage, PlaybackState

if TYPE_CHECKING:
from mopidy.config import Config
Expand Down
14 changes: 0 additions & 14 deletions src/mopidy/audio/constants.py

This file was deleted.

4 changes: 3 additions & 1 deletion src/mopidy/backend.py
Expand Up @@ -282,10 +282,12 @@

:param track: the track to play
"""
if track.uri is None:
return False

Check warning on line 286 in src/mopidy/backend.py

View check run for this annotation

Codecov / codecov/patch

src/mopidy/backend.py#L286

Added line #L286 was not covered by tests
uri = self.translate_uri(track.uri)
if uri != track.uri:
logger.debug("Backend translated URI from %s to %s", track.uri, uri)
if not uri:
if uri is None:
return False
self.audio.set_source_setup_callback(self.on_source_setup).get()
self.audio.set_uri(
Expand Down
31 changes: 15 additions & 16 deletions src/mopidy/core/actor.py
Expand Up @@ -21,8 +21,8 @@
from mopidy.core.playback import PlaybackController
from mopidy.core.playlists import PlaylistsController
from mopidy.core.tracklist import TracklistController
from mopidy.internal import path, storage, validation
from mopidy.internal.models import CoreState
from mopidy.internal import path, storage
from mopidy.internal.models import CoreState, StoredState

if TYPE_CHECKING:
from mopidy.config import Config
Expand Down Expand Up @@ -210,13 +210,14 @@ def _save_state(self) -> None:
state_file = self._get_state_file()
logger.info("Saving state to %s", state_file)

data = {}
data["version"] = mopidy.__version__
data["state"] = CoreState(
tracklist=self.tracklist._save_state(),
history=self.history._save_state(),
playback=self.playback._save_state(),
mixer=self.mixer._save_state(),
data = StoredState(
version=mopidy.__version__,
state=CoreState(
tracklist=self.tracklist._save_state(),
history=self.history._save_state(),
playback=self.playback._save_state(),
mixer=self.mixer._save_state(),
),
)
storage.dump(state_file, data)
logger.debug("Saving state done")
Expand Down Expand Up @@ -248,14 +249,12 @@ def _load_state(self, coverage: Iterable[str]) -> None:
except OSError:
logger.info("Failed to delete %s", state_file)

if "state" in data:
core_state = data["state"]
validation.check_instance(core_state, CoreState)
self.history._load_state(core_state.history, coverage)
self.tracklist._load_state(core_state.tracklist, coverage)
self.mixer._load_state(core_state.mixer, coverage)
if data is not None:
self.history._load_state(data.state.history, coverage)
self.tracklist._load_state(data.state.tracklist, coverage)
self.mixer._load_state(data.state.mixer, coverage)
# playback after tracklist
self.playback._load_state(core_state.playback, coverage)
self.playback._load_state(data.state.playback, coverage)
logger.debug("Loading state done")


Expand Down
7 changes: 4 additions & 3 deletions src/mopidy/core/history.py
Expand Up @@ -43,8 +43,9 @@
if track.name is not None:
name_parts.append(track.name)
name = " - ".join(name_parts)
if track.uri is None:
return

Check warning on line 47 in src/mopidy/core/history.py

View check run for this annotation

Codecov / codecov/patch

src/mopidy/core/history.py#L47

Added line #L47 was not covered by tests
ref = Ref.track(uri=track.uri, name=name)

self._history.insert(0, (timestamp, ref))

def get_length(self) -> int:
Expand All @@ -63,14 +64,14 @@
# 500 tracks a 3 minutes -> 24 hours history
count_max = 500
count = 1
history_list = []
history_list: list[HistoryTrack] = []
for timestamp, track in self._history:
history_list.append(HistoryTrack(timestamp=timestamp, track=track))
count += 1
if count_max < count:
logger.info("Limiting history to %s tracks", count_max)
break
return HistoryState(history=history_list)
return HistoryState(history=tuple(history_list))

def _load_state(self, state: HistoryState, coverage: Iterable[str]) -> None:
if state and "history" in coverage:
Expand Down
14 changes: 9 additions & 5 deletions src/mopidy/core/mixer.py
Expand Up @@ -10,10 +10,10 @@
from mopidy import exceptions
from mopidy.internal import validation
from mopidy.internal.models import MixerState
from mopidy.types import Percentage

if TYPE_CHECKING:
from mopidy.mixer import MixerProxy
from mopidy.types import Percentage

Check warning on line 16 in src/mopidy/core/mixer.py

View check run for this annotation

Codecov / codecov/patch

src/mopidy/core/mixer.py#L16

Added line #L16 was not covered by tests

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -114,13 +114,17 @@
return False

def _save_state(self) -> MixerState:
return MixerState(volume=self.get_volume(), mute=self.get_mute())
return MixerState(
volume=self.get_volume(),
mute=self.get_mute(),
)

def _load_state(self, state: MixerState, coverage: Iterable[str]) -> None:
if state and "mixer" in coverage:
self.set_mute(state.mute)
if state.volume:
self.set_volume(Percentage(state.volume))
if state.mute is not None:
self.set_mute(state.mute)
if state.volume is not None:
self.set_volume(state.volume)


class MixerControllerProxy:
Expand Down
27 changes: 17 additions & 10 deletions src/mopidy/core/playback.py
Expand Up @@ -12,13 +12,14 @@
from mopidy.core import listener
from mopidy.exceptions import CoreError
from mopidy.internal import models, validation
from mopidy.types import DurationMs, UriScheme
from mopidy.models import TlTrack
from mopidy.types import DurationMs, TracklistId, UriScheme

if TYPE_CHECKING:
from mopidy.audio.actor import AudioProxy
from mopidy.backend import BackendProxy
from mopidy.core.actor import Backends, Core
from mopidy.models import TlTrack, Track
from mopidy.models import Track

Check warning on line 22 in src/mopidy/core/playback.py

View check run for this annotation

Codecov / codecov/patch

src/mopidy/core/playback.py#L22

Added line #L22 was not covered by tests
from mopidy.types import Uri

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -53,7 +54,7 @@
self._audio.set_about_to_finish_callback(self._on_about_to_finish_callback)

def _get_backend(self, tl_track: TlTrack | None) -> BackendProxy | None:
if tl_track is None:
if tl_track is None or tl_track.track.uri is None:
return None
uri_scheme = UriScheme(urllib.parse.urlparse(tl_track.track.uri).scheme)
return self.backends.with_playback.get(uri_scheme, None)
Expand All @@ -79,18 +80,24 @@

Returns a :class:`mopidy.models.Track` or :class:`None`.
"""
return getattr(self.get_current_tl_track(), "track", None)
match self.get_current_tl_track():
case TlTrack(_tlid, track):
return track
case None:
return None

Check warning on line 87 in src/mopidy/core/playback.py

View check run for this annotation

Codecov / codecov/patch

src/mopidy/core/playback.py#L86-L87

Added lines #L86 - L87 were not covered by tests

def get_current_tlid(self) -> int | None:
"""Get the currently playing or selected TLID.
def get_current_tlid(self) -> TracklistId | None:
"""Get the currently playing or selected tracklist ID.

Extracted from :meth:`get_current_tl_track` for convenience.

Returns a :class:`int` or :class:`None`.

.. versionadded:: 1.1
"""
return getattr(self.get_current_tl_track(), "tlid", None)
match self.get_current_tl_track():
case TlTrack(tlid, _track):
return tlid
case None:
return None

def get_stream_title(self) -> str | None:
"""Get the current stream title or :class:`None`."""
Expand Down Expand Up @@ -282,7 +289,7 @@

def play(
self,
tlid: int | None = None,
tlid: TracklistId | None = None,
) -> None:
"""Play a track from the tracklist, specified by the tracklist ID.

Expand Down
7 changes: 4 additions & 3 deletions src/mopidy/core/tracklist.py
Expand Up @@ -12,6 +12,7 @@
from mopidy.internal import deprecation, validation
from mopidy.internal.models import TracklistState
from mopidy.models import TlTrack, Track
from mopidy.types import TracklistId

logger = logging.getLogger(__name__)

Expand All @@ -23,7 +24,7 @@
class TracklistController:
def __init__(self, core: Core) -> None:
self.core = core
self._next_tlid: int = 1
self._next_tlid: TracklistId = TracklistId(1)
self._tl_tracks: list[TlTrack] = []
self._version: int = 0

Expand Down Expand Up @@ -398,7 +399,7 @@ def add( # noqa: C901
)

tl_track = TlTrack(self._next_tlid, track)
self._next_tlid += 1
self._next_tlid = TracklistId(self._next_tlid + 1)
if at_position is not None:
self._tl_tracks.insert(at_position, tl_track)
at_position += 1
Expand Down Expand Up @@ -581,7 +582,7 @@ def _trigger_options_changed(self) -> None:

def _save_state(self) -> TracklistState:
return TracklistState(
tl_tracks=self._tl_tracks,
tl_tracks=tuple(self._tl_tracks),
next_tlid=self._next_tlid,
consume=self.get_consume(),
random=self.get_random(),
Expand Down
11 changes: 7 additions & 4 deletions src/mopidy/file/library.py
Expand Up @@ -43,7 +43,7 @@ def __init__(self, backend: backend.Backend, config: config_lib.Config) -> None:

self.root_directory = self._get_root_directory()

def browse(self, uri) -> list[Ref]: # noqa: C901
def browse(self, uri: Uri) -> list[Ref]: # noqa: C901
logger.debug("Browsing files at: %s", uri)
result = []
local_path = path.uri_to_path(uri)
Expand Down Expand Up @@ -102,14 +102,17 @@ def lookup(self, uri: Uri) -> list[Track]:
try:
result = self._scanner.scan(uri)
track = tags.convert_tags_to_track(result.tags).replace(
uri=uri, length=result.duration
uri=uri,
length=result.duration,
)
except exceptions.ScannerError as e:
logger.warning("Failed looking up %s: %s", uri, e)
track = Track(uri=uri)

if not track.name:
track = track.replace(name=local_path.name)
track = track.replace(
name=local_path.name,
)

return [track]

Expand All @@ -119,7 +122,7 @@ def _get_root_directory(self) -> Ref | None:
if len(self._media_dirs) == 1:
uri = path.path_to_uri(self._media_dirs[0]["path"])
else:
uri = "file:root"
uri = Uri("file:root")
return Ref.directory(name="Files", uri=uri)

def _get_media_dirs(self, config) -> Generator[MediaDir, Any, None]:
Expand Down
6 changes: 3 additions & 3 deletions src/mopidy/http/actor.py
@@ -1,21 +1,21 @@
from __future__ import annotations

import asyncio
import json
import logging
import secrets
import socket
import threading
from typing import TYPE_CHECKING, Any, ClassVar

import msgspec
import pykka
import tornado.httpserver
import tornado.ioloop
import tornado.netutil
import tornado.web
import tornado.websocket

from mopidy import exceptions, models, zeroconf
from mopidy import exceptions, zeroconf
from mopidy.core import CoreListener
from mopidy.http import Extension, handlers
from mopidy.internal import formatting, network
Expand Down Expand Up @@ -91,7 +91,7 @@ def on_event(self, event: str, **data: Any) -> None:
def on_event(name: str, io_loop: tornado.ioloop.IOLoop, **data: Any) -> None:
event = data
event["event"] = name
message = json.dumps(event, cls=models.ModelJSONEncoder)
message = msgspec.json.encode(event)
handlers.WebSocketHandler.broadcast(message, io_loop)


Expand Down