diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..bfd4689 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,51 @@ +version: 2.1 + +orbs: + codecov: codecov/codecov@1.0.5 + +workflows: + version: 2 + test: + jobs: + - py38 + - py37 + - black + - check-manifest + - flake8 + +jobs: + py38: &test-template + docker: + - image: mopidy/ci-python:3.8 + steps: + - checkout + - restore_cache: + name: Restoring tox cache + key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }} + - run: + name: Run tests + command: | + tox -e $CIRCLE_JOB -- \ + --junit-xml=test-results/pytest/results.xml \ + --cov-report=xml + - save_cache: + name: Saving tox cache + key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }} + paths: + - ./.tox + - ~/.cache/pip + - codecov/upload: + file: coverage.xml + - store_test_results: + path: test-results + + py37: + <<: *test-template + docker: + - image: mopidy/ci-python:3.7 + + black: *test-template + + check-manifest: *test-template + + flake8: *test-template diff --git a/.gitignore b/.gitignore index c40b50b..03640c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ -*.egg-info *.pyc -.coverage -.pytest_cache/ -.tox/ -MANIFEST -build/ -dist/ +/.coverage +/.mypy_cache/ +/.pytest_cache/ +/.tox/ +/*.egg-info +/build/ +/dist/ +/MANIFEST diff --git a/MANIFEST.in b/MANIFEST.in index 5489be9..b1ef89d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,3 +7,5 @@ include mopidy_pidi/ext.conf include tox.ini recursive-include tests *.py + +recursive-include .circleci * diff --git a/README.rst b/README.rst index 5ff0728..3c77e0a 100644 --- a/README.rst +++ b/README.rst @@ -2,16 +2,16 @@ Mopidy-PiDi **************************** -.. image:: https://img.shields.io/pypi/v/Mopidy-PiDi.svg?style=flat +.. image:: https://img.shields.io/pypi/v/Mopidy-PiDi.svg :target: https://pypi.org/project/Mopidy-PiDi/ :alt: Latest PyPI version -.. image:: https://img.shields.io/travis/pimoroni/mopidy-pidi/master.svg?style=flat - :target: https://travis-ci.org/pimoroni/mopidy-pidi +.. image:: https://img.shields.io/circleci/build/gh/pimoroni/mopidy-pidi/master.svg + :target: https://circleci.com/gh/pimoroni/mopidy-pidi :alt: Travis CI build status -.. image:: https://img.shields.io/coveralls/pimoroni/mopidy-pidi/master.svg?style=flat - :target: https://coveralls.io/r/pimoroni/mopidy-pidi +.. image:: https://img.shields.io/codecov/gh/pimoroni/mopidy-pidi/master.svg + :target: https://codecov.io/gh/pimoroni/mopidy-pidi :alt: Test coverage Mopidy extension for displaying song info and album art using pidi display plugins. diff --git a/mopidy_pidi/__init__.py b/mopidy_pidi/__init__.py index 084edba..91a3918 100644 --- a/mopidy_pidi/__init__.py +++ b/mopidy_pidi/__init__.py @@ -1,13 +1,11 @@ -from __future__ import unicode_literals - import logging -import os +import pathlib -from pkg_resources import iter_entry_points +import pkg_resources from mopidy import config, ext -__version__ = "0.2.0" +__version__ = pkg_resources.get_distribution("mopidy_pidi").version logger = logging.getLogger(__name__) @@ -21,26 +19,26 @@ class Extension(ext.Extension): @classmethod def get_display_types(self): display_types = {} - for entry_point in iter_entry_points("pidi.plugin.display"): + for entry_point in pkg_resources.iter_entry_points("pidi.plugin.display"): try: plugin = entry_point.load() display_types[plugin.option_name] = plugin except (ImportError) as err: - logger.log(logging.WARN, "Error loading display plugin {entry_point}: {err}".format( - entry_point=entry_point, - err=err)) + logger.log( + logging.WARN, f"Error loading display plugin {entry_point}: {err}" + ) return display_types def get_default_config(self): - conf_file = os.path.join(os.path.dirname(__file__), "ext.conf") - return config.read(conf_file) + return config.read(pathlib.Path(__file__).parent / "ext.conf") def get_config_schema(self): - schema = super(Extension, self).get_config_schema() + schema = super().get_config_schema() schema["display"] = config.String(choices=self.get_display_types().keys()) return schema def setup(self, registry): from .frontend import PiDiFrontend + registry.add("frontend", PiDiFrontend) diff --git a/mopidy_pidi/brainz.py b/mopidy_pidi/brainz.py index df9db58..c08a262 100644 --- a/mopidy_pidi/brainz.py +++ b/mopidy_pidi/brainz.py @@ -2,6 +2,7 @@ Musicbrainz related functions. """ import base64 +import logging import os import time from threading import Thread @@ -10,29 +11,30 @@ from .__init__ import __version__ +logger = logging.getLogger(__name__) + class Brainz: def __init__(self, cache_dir): """Initialize musicbrainz.""" - mus.set_useragent("python-pidi: A cover art daemon.", - __version__, - "https://github.com/pimoroni/mopidy-pidi") + mus.set_useragent( + "python-pidi: A cover art daemon.", + __version__, + "https://github.com/pimoroni/mopidy-pidi", + ) self._cache_dir = cache_dir self._default_filename = os.path.join(self._cache_dir, "__default.jpg") self.save_album_art(self.get_default_album_art(), self._default_filename) - def get_album_art(self, artist, album, callback=None): + def get_album_art(self, artist, album, callback=None): if artist is None or album is None or artist == "" or album == "": if callback is not None: return callback(self._default_filename) return self._default_filename - file_name = self.get_cache_file_name("{artist}_{album}".format( - artist=artist, - album=album - )) + file_name = self.get_cache_file_name(f"{artist}_{album}") if os.path.isfile(file_name): # If a cached file already exists, use it! @@ -41,6 +43,7 @@ def get_album_art(self, artist, album, callback=None): return file_name if callback is not None: + def async_request_album_art(self, artist, album, file_name, callback): album_art = self.request_album_art(artist, album) @@ -56,12 +59,8 @@ def async_request_album_art(self, artist, album, file_name, callback): t_album_art = Thread( target=async_request_album_art, - args=( - self, - artist, - album, - file_name, - callback)) + args=(self, artist, album, file_name, callback), + ) t_album_art.start() return t_album_art @@ -85,11 +84,9 @@ def save_album_art(self, data, output_file): def request_album_art(self, artist, album, size=500, retry_delay=5, retries=5): """Download the cover art.""" try: - data = mus.search_releases(artist=artist, - release=album, - limit=1) + data = mus.search_releases(artist=artist, release=album, limit=1) release_id = data["release-list"][0]["release-group"]["id"] - print("mopidy-pidi: musicbrainz using release-id: {}".format(data['release-list'][0]['id'])) + logger.info("mopidy-pidi: musicbrainz using release-id: {release_id}") return mus.get_release_group_image_front(release_id, size=size) @@ -97,23 +94,28 @@ def request_album_art(self, artist, album, size=500, retry_delay=5, retries=5): if retries == 0: # raise mus.NetworkError("Failure connecting to MusicBrainz.org") return None - print("mopidy-pidi: musicbrainz retrying download. {retries} retries left!".format(retries=retries)) + logger.info( + f"mopidy-pidi: musicbrainz retrying download. {retries} retries left!" + ) time.sleep(retry_delay) - self.request_album_art(song, artist, album, size=size, retries=retries - 1) + self.request_album_art(artist, album, size=size, retries=retries - 1) except mus.ResponseError: - print("mopidy-pidi: musicbrainz couldn't find album art for {artist} - {album}".format(artist=artist, album=album)) + logger.info( + f"mopidy-pidi: musicbrainz couldn't find album art for {artist} - {album}" + ) return None def get_cache_file_name(self, file_name): file_name = file_name.encode("utf-8") - file_name = "{}.jpg".format(base64.b64encode(file_name)) + file_name = f"{base64.b64encode(file_name)}.jpg" return os.path.join(self._cache_dir, file_name) def get_default_album_art(self): """Return binary version of default album art.""" - return base64.b64decode(""" + return base64.b64decode( + """ iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAMAAAAM7l6QAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFn ZVJlYWR5ccllPAAAAMBQTFRFBHwvBSl8d04DCQ99egJLfAMzejQGcGoAAGZ6AHN3N3wBSHwBKXwDAHlp NQF9AHtXAFV7VwB7HgN9B30aG30FXncAAXtERwB8fQMbZQB5AUF8fRsHQ04rfQgLFlZTVzgteABiZ14F @@ -125,4 +127,5 @@ def get_default_album_art(self): f2YsZWl6WK8nk+VSOTBN05iGemO73e5w+JnNZpVlRQYIKTcM+g/xtiq1BloR5Dy/3++r7ba6rWLkmmLd LCvP8zfqCp0zNYgtepZlmu93kiCfTifP87iDNK5OkiSBbpyEe1WPs0DTdJxeEAQr3TCUgyXUQnR6ySgI dJy7rjclV8y3PdS5jm647nRKDVBIOjoSG4KpAOpfB3V0nM/LjmyapXHBriscylrwx0FpiQ11Hf6PyXX5 -ORWAoxqr44Y4/ifAAPd/TAMIg8r1AAAAAElFTkSuQmCC""") +ORWAoxqr44Y4/ifAAPd/TAMIg8r1AAAAAElFTkSuQmCC""" + ) diff --git a/mopidy_pidi/frontend.py b/mopidy_pidi/frontend.py index 506063e..db1da10 100644 --- a/mopidy_pidi/frontend.py +++ b/mopidy_pidi/frontend.py @@ -1,15 +1,13 @@ -from __future__ import unicode_literals - import logging import os import threading import time +import pykka import requests +from mopidy import core import netifaces -import pykka -from mopidy import core from . import Extension from .brainz import Brainz @@ -17,7 +15,7 @@ logger = logging.getLogger(__name__) -class PiDiConfig(): +class PiDiConfig: def __init__(self, config=None): self.rotation = 90 self.spi_port = 0 @@ -31,7 +29,7 @@ def __init__(self, config=None): class PiDiFrontend(pykka.ThreadingActor, core.CoreListener): def __init__(self, config, core): - super(PiDiFrontend, self).__init__() + super().__init__() self.core = core self.config = config self.current_track = None @@ -39,28 +37,28 @@ def __init__(self, config, core): def on_start(self): self.display = PiDi(self.config) self.display.start() - self.display.update( - volume=self.core.mixer.get_volume().get() - ) - if 'http' in self.config: + self.display.update(volume=self.core.mixer.get_volume().get()) + if "http" in self.config: ifaces = netifaces.interfaces() - ifaces.remove(u'lo') + ifaces.remove("lo") - http = self.config['http'] - if http.get('enabled', False): - hostname = http.get('hostname', '127.0.0.1') - port = http.get('port', 6680) + http = self.config["http"] + if http.get("enabled", False): + hostname = http.get("hostname", "127.0.0.1") + port = http.get("port", 6680) if hostname in ["::", "0.0.0.0"]: - family = netifaces.AF_INET6 if hostname == "::" else netifaces.AF_INET + family = ( + netifaces.AF_INET6 if hostname == "::" else netifaces.AF_INET + ) for iface in ifaces: hostname = self.get_ifaddress(iface, family) if hostname is not None: break if hostname is not None: self.display.update( - title="Visit http://{hostname}:{port} to select content.".format(hostname=hostname, port=port) + title=f"Visit http://{hostname}:{port} to select content." ) - self.display.update_album_art(art='') + self.display.update_album_art(art="") def on_stop(self): self.display.stop() @@ -68,7 +66,7 @@ def on_stop(self): def get_ifaddress(self, iface, family): try: - return netifaces.ifaddresses(iface)[family][0]['addr'] + return netifaces.ifaddresses(iface)[family][0]["addr"] except (IndexError, KeyError): return None @@ -78,7 +76,7 @@ def mute_changed(self, mute): def options_changed(self): self.display.update( shuffle=self.core.tracklist.get_random(), - repeat=self.core.tracklist.get_repeat() + repeat=self.core.tracklist.get_repeat(), ) def playlist_changed(self, playlist): @@ -98,32 +96,30 @@ def stream_title_changed(self, title): def track_playback_ended(self, tl_track, time_position): self.update_elapsed(time_position) - self.display.update(state='pause') + self.display.update(state="pause") def track_playback_paused(self, tl_track, time_position): self.update_elapsed(time_position) - self.display.update(state='pause') + self.display.update(state="pause") def track_playback_resumed(self, tl_track, time_position): self.update_elapsed(time_position) - self.display.update(state='play') + self.display.update(state="play") def track_playback_started(self, tl_track): self.update_track(tl_track.track, 0) - self.display.update(state='play') + self.display.update(state="play") def update_elapsed(self, time_position): - self.display.update( - elapsed=float(time_position), - ) + self.display.update(elapsed=float(time_position)) def update_track(self, track, time_position=None): if track is None: track = self.core.playback.get_current_track().get() - title = '' - album = '' - artist = '' + title = "" + album = "" + artist = "" if track.name is not None: title = track.name @@ -134,16 +130,11 @@ def update_track(self, track, time_position=None): if track.artists is not None: artist = ", ".join([artist.name for artist in track.artists]) - self.display.update( - title=title, - album=album, - artist=artist - ) + self.display.update(title=title, album=album, artist=artist) if time_position is not None: self.display.update( - elapsed=float(time_position), - length=float(track.length) + elapsed=float(time_position), length=float(track.length) ) art = None @@ -166,17 +157,17 @@ def volume_changed(self, volume): if volume is None: return - self.display.update( - volume=volume - ) + self.display.update(volume=volume) -class PiDi(): +class PiDi: def __init__(self, config): self.config = config self.cache_dir = Extension.get_data_dir(config) self.display_config = PiDiConfig(config["pidi"]) - self.display_class = Extension.get_display_types()[self.config["pidi"]["display"]] + self.display_class = Extension.get_display_types()[ + self.config["pidi"]["display"] + ] self._brainz = Brainz(cache_dir=self.cache_dir) self._display = self.display_class(self.display_config) @@ -218,7 +209,7 @@ def _handle_album_art(self, art): self._last_art = art def update_album_art(self, art=None): - _album = self.title if self.album is None or self.album == '' else self.album + _album = self.title if self.album is None or self.album == "" else self.album if art is not None: if os.path.isfile(art): @@ -245,26 +236,26 @@ def update_album_art(self, art=None): art = self._brainz.get_album_art(self.artist, _album, self._handle_album_art) def update(self, **kwargs): - self.shuffle = kwargs.get('shuffle', self.shuffle) - self.repeat = kwargs.get('repeat', self.repeat) - self.state = kwargs.get('state', self.state) - self.volume = kwargs.get('volume', self.volume) + self.shuffle = kwargs.get("shuffle", self.shuffle) + self.repeat = kwargs.get("repeat", self.repeat) + self.state = kwargs.get("state", self.state) + self.volume = kwargs.get("volume", self.volume) # self.progress = kwargs.get('progress', self.progress) - self.elapsed = kwargs.get('elapsed', self.elapsed) - self.length = kwargs.get('length', self.length) - self.title = kwargs.get('title', self.title) - self.album = kwargs.get('album', self.album) - self.artist = kwargs.get('artist', self.artist) - - if 'elapsed' in kwargs: - if 'length' in kwargs: + self.elapsed = kwargs.get("elapsed", self.elapsed) + self.length = kwargs.get("length", self.length) + self.title = kwargs.get("title", self.title) + self.album = kwargs.get("album", self.album) + self.artist = kwargs.get("artist", self.artist) + + if "elapsed" in kwargs: + if "length" in kwargs: self.progress = float(self.elapsed) / float(self.length) self._last_elapsed_update = time.time() - self._last_elapsed_value = kwargs['elapsed'] + self._last_elapsed_value = kwargs["elapsed"] def _loop(self): while self._running.is_set(): - if self.state == 'play': + if self.state == "play": t_elapsed_ms = (time.time() - self._last_elapsed_update) * 1000 self.elapsed = float(self._last_elapsed_value + t_elapsed_ms) self.progress = self.elapsed / self.length @@ -277,7 +268,8 @@ def _loop(self): self.elapsed, self.title, self.album, - self.artist) + self.artist, + ) self._display.redraw() time.sleep(self._delay) diff --git a/mopidy_pidi/plugin.py b/mopidy_pidi/plugin.py index d1f94fc..aabc8da 100644 --- a/mopidy_pidi/plugin.py +++ b/mopidy_pidi/plugin.py @@ -1,26 +1,28 @@ -class Display(): +class Display: """Base class to represent a Pirate Display display output.""" + def __init__(self, args=None): """Initialise a new display.""" self._size = args.size - self._title = '' + self._title = "" self._shuffle = False self._repeat = False - self._state = '' + self._state = "" self._volume = 0 self._progress = 0 self._elapsed = 0 - self._title = '' - self._album = '' - self._artist = '' + self._title = "" + self._album = "" + self._artist = "" def update_album_art(self, input_file): """Update the display album art.""" raise NotImplementedError - def update_overlay(self, shuffle, repeat, state, volume, - progress, elapsed, title, album, artist): + def update_overlay( + self, shuffle, repeat, state, volume, progress, elapsed, title, album, artist + ): """Update the display transport information.""" self._shuffle = shuffle self._repeat = repeat @@ -38,3 +40,15 @@ def redraw(self): def add_args(argparse): """Expand argparse instance with display-specific args.""" + + +class DisplayDummy(Display): + """Dummy display for use in texting.""" + + option_name = "dummy" + + def update_album_art(self, input_file): + pass + + def redraw(self): + pass diff --git a/setup.cfg b/setup.cfg index f87992e..f096da9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,91 @@ +[metadata] +name = mopidy-pidi +version = 0.2.0 +url = https://github.com/{{ cookiecutter.github_username }}/{{ cookiecutter.dist_name|lower }} +author = Phil Howard +author_email = phil@pimoroni.com +license = Apache License, Version 2.0 +license_file = LICENSE +description = Mopidy extension for displaying song info and album art using pidi plugins +long_description = file: README.rst +classifiers = + Environment :: No Input/Output (Daemon) + Intended Audience :: End Users/Desktop + License :: OSI Approved :: Apache Software License + Operating System :: OS Independent + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Topic :: Multimedia :: Sound/Audio :: Players + + +[options] +zip_safe = False +include_package_data = True +packages = find: +python_requires = >= 3.7 +install_requires = + Mopidy >= 3.0 + Pykka >= 2.0.1 + setuptools + musicbrainzngs >= 0.6 + netifaces + + +[options.extras_require] +lint = + black + check-manifest + flake8 + flake8-bugbear + flake8-import-order + isort[pyproject] +release = + twine + wheel +test = + pytest + pytest-cov +dev = + %(lint)s + %(release)s + %(test)s + + +[options.packages.find] +exclude = + tests + tests.* + + +[options.entry_points] +mopidy.ext = + pidi = mopidy_pidi:Extension + + [flake8] -application-import-names = mopidy_pidi,tests -exclude = .git,.tox +application-import-names = mopidy_pidi, tests +max-line-length = 80 +exclude = .git, .tox, build +select = + # Regular flake8 rules + C, E, F, W + # flake8-bugbear rules + B + # B950: line too long (soft speed limit) + B950 + # pep8-naming rules + N +ignore = + # E203: whitespace before ':' (not PEP8 compliant) + E203 + # E501: line too long (replaced by B950) + E501 + # W503: line break before binary operator (not PEP8 compliant) + W503 + # B305: .next() is not a thing on Python 3 (used by playback controller) + B305 + [wheel] universal = 1 diff --git a/setup.py b/setup.py index dab95d4..6068493 100644 --- a/setup.py +++ b/setup.py @@ -1,47 +1,3 @@ -from __future__ import unicode_literals +from setuptools import setup -import re - -from setuptools import find_packages, setup - - -def get_version(filename): - with open(filename) as fh: - metadata = dict(re.findall('__([a-z]+)__ = "([^"]+)"', fh.read())) - return metadata["version"] - - -setup( - name="Mopidy-PiDi", - version=get_version("mopidy_pidi/__init__.py"), - url="https://github.com/pimoroni/mopidy-pidi", - license="Apache License, Version 2.0", - author="Phil Howard", - author_email="phil@pimoroni.com", - description="Mopidy extension for displaying song info and album art using pidi plugins", - long_description=open("README.rst").read(), - packages=find_packages(exclude=["tests", "tests.*"]), - zip_safe=False, - include_package_data=True, - python_requires="> 2.7, < 3", - install_requires=[ - "setuptools", - "Mopidy >= 2.2", - "Pykka >= 2.0", - "musicbrainzngs >= 0.6", - "netifaces" - ], - entry_points={ - "mopidy.ext": [ - "pidi = mopidy_pidi:Extension", - ] - }, - classifiers=[ - "Environment :: No Input/Output (Daemon)", - "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: Apache Software License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 2.7", - "Topic :: Multimedia :: Sound/Audio :: Players", - ], -) +setup() diff --git a/tests/dummy_audio.py b/tests/dummy_audio.py new file mode 100644 index 0000000..8e85d0a --- /dev/null +++ b/tests/dummy_audio.py @@ -0,0 +1,137 @@ +"""A dummy audio actor for use in tests. + +This class implements the audio API in the simplest way possible. It is used in +tests of the core and backends. +""" + + +import pykka +from mopidy import audio + + +def create_proxy(config=None, mixer=None): + return DummyAudio.start(config, mixer).proxy() + + +# TODO: reset position on track change? +class DummyAudio(pykka.ThreadingActor): + def __init__(self, config=None, mixer=None): + super().__init__() + self.state = audio.PlaybackState.STOPPED + self._volume = 0 + self._position = 0 + self._callback = None + self._uri = None + self._stream_changed = False + self._live_stream = False + self._tags = {} + self._bad_uris = set() + + def set_uri(self, uri, live_stream=False): + assert self._uri is None, "prepare change not called before set" + self._position = 0 + self._uri = uri + self._stream_changed = True + self._live_stream = live_stream + self._tags = {} + + def set_appsrc(self, *args, **kwargs): + pass + + def emit_data(self, buffer_): + pass + + def get_position(self): + return self._position + + def set_position(self, position): + self._position = position + audio.AudioListener.send("position_changed", position=position) + return True + + def start_playback(self): + return self._change_state(audio.PlaybackState.PLAYING) + + def pause_playback(self): + return self._change_state(audio.PlaybackState.PAUSED) + + def prepare_change(self): + self._uri = None + return True + + def stop_playback(self): + return self._change_state(audio.PlaybackState.STOPPED) + + def get_volume(self): + return self._volume + + def set_volume(self, volume): + self._volume = volume + return True + + def set_metadata(self, track): + pass + + def get_current_tags(self): + return self._tags + + def set_about_to_finish_callback(self, callback): + self._callback = callback + + def enable_sync_handler(self): + pass + + def wait_for_state_change(self): + pass + + def _change_state(self, new_state): + if not self._uri: + return False + + if new_state == audio.PlaybackState.STOPPED and self._uri: + self._stream_changed = True + self._uri = None + + if self._uri is not None: + audio.AudioListener.send("position_changed", position=0) + + if self._stream_changed: + self._stream_changed = False + audio.AudioListener.send("stream_changed", uri=self._uri) + + old_state, self.state = self.state, new_state + audio.AudioListener.send( + "state_changed", + old_state=old_state, + new_state=new_state, + target_state=None, + ) + + if new_state == audio.PlaybackState.PLAYING: + self._tags["audio-codec"] = ["fake info..."] + audio.AudioListener.send("tags_changed", tags=["audio-codec"]) + + return self._uri not in self._bad_uris + + def trigger_fake_playback_failure(self, uri): + self._bad_uris.add(uri) + + def trigger_fake_tags_changed(self, tags): + self._tags.update(tags) + audio.AudioListener.send("tags_changed", tags=self._tags.keys()) + + def get_about_to_finish_callback(self): + # This needs to be called from outside the actor or we lock up. + def wrapper(): + if self._callback: + self.prepare_change() + self._callback() + + if not self._uri or not self._callback: + self._tags = {} + audio.AudioListener.send("reached_end_of_stream") + else: + audio.AudioListener.send("position_changed", position=0) + audio.AudioListener.send("stream_changed", uri=self._uri) + + return wrapper diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py new file mode 100644 index 0000000..8c087bf --- /dev/null +++ b/tests/dummy_backend.py @@ -0,0 +1,150 @@ +"""A dummy backend for use in tests. + +This backend implements the backend API in the simplest way possible. It is +used in tests of the frontends. +""" + + +import pykka +from mopidy import backend +from mopidy.models import Playlist, Ref, SearchResult + + +def create_proxy(config=None, audio=None): + return DummyBackend.start(config=config, audio=audio).proxy() + + +class DummyBackend(pykka.ThreadingActor, backend.Backend): + def __init__(self, config, audio): + super().__init__() + + self.library = DummyLibraryProvider(backend=self) + if audio: + self.playback = backend.PlaybackProvider(audio=audio, backend=self) + else: + self.playback = DummyPlaybackProvider(audio=audio, backend=self) + self.playlists = DummyPlaylistsProvider(backend=self) + + self.uri_schemes = ["dummy"] + + +class DummyLibraryProvider(backend.LibraryProvider): + root_directory = Ref.directory(uri="dummy:/", name="dummy") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.dummy_library = [] + self.dummy_get_distinct_result = {} + self.dummy_browse_result = {} + self.dummy_find_exact_result = SearchResult() + self.dummy_search_result = SearchResult() + + def browse(self, path): + return self.dummy_browse_result.get(path, []) + + def get_distinct(self, field, query=None): + return self.dummy_get_distinct_result.get(field, set()) + + def lookup(self, uri): + uri = Ref.track(uri=uri).uri + return [t for t in self.dummy_library if uri == t.uri] + + def refresh(self, uri=None): + pass + + def search(self, query=None, uris=None, exact=False): + if exact: # TODO: remove uses of dummy_find_exact_result + return self.dummy_find_exact_result + return self.dummy_search_result + + +class DummyPlaybackProvider(backend.PlaybackProvider): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._uri = None + self._time_position = 0 + + def pause(self): + return True + + def play(self): + return self._uri and self._uri != "dummy:error" + + def change_track(self, track): + """Pass a track with URI 'dummy:error' to force failure""" + self._uri = track.uri + self._time_position = 0 + return True + + def prepare_change(self): + pass + + def resume(self): + return True + + def seek(self, time_position): + self._time_position = time_position + return True + + def stop(self): + self._uri = None + return True + + def get_time_position(self): + return self._time_position + + +class DummyPlaylistsProvider(backend.PlaylistsProvider): + def __init__(self, backend): + super().__init__(backend) + self._playlists = [] + self._allow_save = True + + def set_dummy_playlists(self, playlists): + """For tests using the dummy provider through an actor proxy.""" + self._playlists = playlists + + def set_allow_save(self, enabled): + self._allow_save = enabled + + def as_list(self): + return [Ref.playlist(uri=pl.uri, name=pl.name) for pl in self._playlists] + + def get_items(self, uri): + playlist = self.lookup(uri) + if playlist is None: + return + return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] + + def lookup(self, uri): + uri = Ref.playlist(uri=uri).uri + for playlist in self._playlists: + if playlist.uri == uri: + return playlist + + def refresh(self): + pass + + def create(self, name): + playlist = Playlist(name=name, uri=f"dummy:{name}") + self._playlists.append(playlist) + return playlist + + def delete(self, uri): + playlist = self.lookup(uri) + if playlist: + self._playlists.remove(playlist) + + def save(self, playlist): + if not self._allow_save: + return None + + old_playlist = self.lookup(playlist.uri) + + if old_playlist is not None: + index = self._playlists.index(old_playlist) + self._playlists[index] = playlist + else: + self._playlists.append(playlist) + + return playlist diff --git a/tests/dummy_mixer.py b/tests/dummy_mixer.py new file mode 100644 index 0000000..b80e681 --- /dev/null +++ b/tests/dummy_mixer.py @@ -0,0 +1,30 @@ +import pykka +from mopidy import mixer + + +def create_proxy(config=None): + return DummyMixer.start(config=None).proxy() + + +class DummyMixer(pykka.ThreadingActor, mixer.Mixer): + def __init__(self, config): + super().__init__() + # These must be initialised to avoid none type error in tests + self._volume = 50 + self._mute = False + + def get_volume(self): + return self._volume + + def set_volume(self, volume): + self._volume = volume + self.trigger_volume_changed(volume=volume) + return True + + def get_mute(self): + return self._mute + + def set_mute(self, mute): + self._mute = mute + self.trigger_mute_changed(mute=mute) + return True diff --git a/tests/test_extension.py b/tests/test_extension.py index 058c583..86157e4 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -1,6 +1,7 @@ -from __future__ import unicode_literals +from unittest import mock -from mopidy_pidi import Extension, frontend as frontend_lib +from mopidy_pidi import Extension +from mopidy_pidi import frontend as frontend_lib def test_get_default_config(): @@ -17,9 +18,13 @@ def test_get_config_schema(): schema = ext.get_config_schema() - # TODO Test the content of your config schema - #assert "username" in schema - #assert "password" in schema + assert "display" in schema -# TODO Write more tests +def test_setup(): + ext = Extension() + registry = mock.Mock() + + ext.setup(registry) + + registry.add.assert_called_once_with("frontend", frontend_lib.PiDiFrontend) diff --git a/tests/test_frontend.py b/tests/test_frontend.py new file mode 100644 index 0000000..2d073d8 --- /dev/null +++ b/tests/test_frontend.py @@ -0,0 +1,47 @@ +import pkg_resources +import pykka +from mopidy import core + +import pytest +from mopidy_pidi import frontend as frontend_lib + +from . import dummy_audio, dummy_backend, dummy_mixer + + +def stop_mopidy_core(): + pykka.ActorRegistry.stop_all() + + +@pytest.fixture(scope="session", autouse=True) +def cleanup(request): + request.addfinalizer(stop_mopidy_core) + + +@pytest.fixture +def frontend(): + mixer = dummy_mixer.create_proxy() + audio = dummy_audio.create_proxy() + backend = dummy_backend.create_proxy(audio=audio) + dummy_core = core.Core.start(audio=audio, mixer=mixer, backends=[backend]).proxy() + + distribution = pkg_resources.Distribution(__file__) + endpoint = pkg_resources.EntryPoint.parse( + "dummy = mopidy_pidi.plugin:DisplayDummy", dist=distribution + ) + distribution._ep_map = {"pidi.plugin.display": {"dummy": endpoint}} + pkg_resources.working_set.add(distribution, "dummy") + + config = {"pidi": {"display": "dummy"}, "core": {"data_dir": "/tmp"}} + + return frontend_lib.PiDiFrontend(config, dummy_core) + + +def test_on_start(frontend): + frontend.on_start() + frontend.on_stop() + + +def test_options_changed(frontend): + frontend.on_start() + frontend.options_changed() + frontend.on_stop() diff --git a/tox.ini b/tox.ini index d7f38ed..02fed24 100644 --- a/tox.ini +++ b/tox.ini @@ -1,26 +1,23 @@ [tox] -envlist = py27, flake8, check-manifest +envlist = py37, py38, black, check-manifest, flake8 [testenv] sitepackages = true -deps = - mock - pytest - pytest-cov - pytest-xdist +deps = .[test] commands = python -m pytest \ --basetemp={envtmpdir} \ - --cov=mopidy_pidi --cov-report=term-missing \ + --cov=mopidy_pidi--cov-report=term-missing \ {posargs} -[testenv:flake8] -deps = - flake8 - flake8-import-order -skip_install = true -commands = python -m flake8 +[testenv:black] +deps = .[lint] +commands = python -m black --check . [testenv:check-manifest] -deps = check-manifest +deps = .[lint] commands = python -m check_manifest + +[testenv:flake8] +deps = .[lint] +commands = python -m flake8 --ignore B950,E501 --show-source --statistics