From 5ec451edd648523d8c92d5808dd883feb8ec8c54 Mon Sep 17 00:00:00 2001 From: Lucas Bickel <116588+hairmare@users.noreply.github.com> Date: Mon, 11 Mar 2024 02:10:52 +0100 Subject: [PATCH] chore(ci): configure ruff --- .pre-commit-config.yaml | 23 +- catalog-info.yaml | 2 +- conftest.py | 63 ---- docs/gen_ref_pages.py | 15 +- nowplaying/__main__.py | 3 +- nowplaying/api.py | 80 +++-- nowplaying/daemon.py | 142 ++++---- nowplaying/input/handler.py | 36 +- nowplaying/input/observer.py | 232 ++++++++----- nowplaying/main.py | 15 +- nowplaying/misc/saemubox.py | 19 +- nowplaying/options.py | 62 +++- nowplaying/otel.py | 20 +- nowplaying/show/client.py | 101 +++--- nowplaying/show/show.py | 30 +- nowplaying/track/handler.py | 40 ++- nowplaying/track/observers/base.py | 34 +- .../track/observers/dab_audio_companion.py | 15 +- nowplaying/track/observers/icecast.py | 82 +++-- nowplaying/track/observers/scrobbler.py | 3 +- nowplaying/track/observers/smc_ftp.py | 68 ++-- nowplaying/track/observers/ticker.py | 55 ++- nowplaying/track/track.py | 76 +++-- nowplaying/util.py | 12 +- poetry.lock | 312 +++++++++++------- pyproject.toml | 32 +- ruff.toml | 78 +++++ tests/conftest.py | 58 +++- tests/test_api.py | 64 ++-- tests/test_daemon.py | 6 +- tests/test_input_handler.py | 22 +- tests/test_input_observer.py | 5 +- tests/test_input_observer_base.py | 21 +- tests/test_input_observer_klangbecken.py | 10 +- tests/test_main.py | 4 +- tests/test_show_client.py | 57 ++-- tests/test_track.py | 10 +- .../test_track_observer_dabaudiocompanion.py | 22 +- tests/test_track_observer_icecast.py | 30 +- tests/test_track_observer_smc_ftp.py | 20 +- tests/test_track_observer_tickertrack.py | 18 +- 41 files changed, 1226 insertions(+), 771 deletions(-) delete mode 100644 conftest.py create mode 100644 ruff.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 24c22b2f..70161138 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,29 +1,12 @@ repos: - - repo: https://github.com/asottile/pyupgrade - rev: v2.31.1 - hooks: - - id: pyupgrade - args: - - --py311-plus - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.8.0' + rev: 'v0.8.1' hooks: - id: ruff args: [--fix] - - repo: local - hooks: - - id: isort - name: isort - language: system - entry: isort - types: [python] - - id: black - name: black - language: system - entry: black - types: [python] + - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.4.0 + rev: v4.5.0 hooks: - id: trailing-whitespace exclude: ^src/api/client.js$ diff --git a/catalog-info.yaml b/catalog-info.yaml index fae90d86..3ffeff90 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -3,7 +3,7 @@ apiVersion: backstage.io/v1alpha1 kind: Component metadata: name: nowplaying - title: Nowplaging Service + title: Nowplaying Service description: | The nowplaying daemon is the python server central to our songticker. annotations: diff --git a/conftest.py b/conftest.py deleted file mode 100644 index 9e4f6f16..00000000 --- a/conftest.py +++ /dev/null @@ -1,63 +0,0 @@ -import os -import sys - -import pytest - -from nowplaying.show.show import Show -from nowplaying.track.observers.base import TrackObserver -from nowplaying.track.track import Track - -PACKAGE_PARENT = "nowplaying" -SCRIPT_DIR = os.path.dirname( - os.path.realpath(os.path.join(os.getcwd(), os.path.expanduser(__file__))) -) -sys.path.append(os.path.normpath(os.path.join(SCRIPT_DIR, PACKAGE_PARENT))) - - -def new_show(name="Hairmare Traveling Medicine Show"): - s = Show() - s.set_name("Hairmare Traveling Medicine Show") - return s - - -@pytest.fixture() -def show_factory(): - """Return a method to help creating new show objects for tests.""" - return new_show - - -def new_track( - artist="Hairmare and the Band", - title="An Ode to legacy Python Code", - album="Live at the Refactoring Club", - duration=128, -): - t = Track() - t.set_artist(artist) - t.set_title(title) - t.set_album(album) - t.set_duration(duration) - return t - - -@pytest.fixture() -def track_factory(): - """Return a method to help creating new track objects for tests.""" - return new_track - - -class DummyObserver(TrackObserver): - """Shunt class for testing the abstract TrackObserver.""" - - pass - - def track_started(self, track): - pass - - def track_finished(self, track): - pass - - -@pytest.fixture() -def dummy_observer(): - return DummyObserver() diff --git a/docs/gen_ref_pages.py b/docs/gen_ref_pages.py index b1b8882a..bbf82d70 100644 --- a/docs/gen_ref_pages.py +++ b/docs/gen_ref_pages.py @@ -16,20 +16,23 @@ parts = list(module_path.parts) - if parts[-1] == "__init__": - continue - elif parts[-1] == "__main__": + if parts[-1] in ["__init__", "__main__"]: continue with mkdocs_gen_files.open(full_doc_path, "w") as fd: identifier = ".".join(parts) print("::: " + identifier, file=fd) - mkdocs_gen_files.set_edit_path(full_doc_path, path) # + mkdocs_gen_files.set_edit_path(full_doc_path, path) with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: nav_file.writelines(nav.build_literate_nav()) -readme = Path("README.md").open("r") -with mkdocs_gen_files.open("index.md", "w") as index_file: +with ( + Path("README.md").open("r") as readme, + mkdocs_gen_files.open( + "index.md", + "w", + ) as index_file, +): index_file.writelines(readme.read()) diff --git a/nowplaying/__main__.py b/nowplaying/__main__.py index e1062634..d77152ab 100644 --- a/nowplaying/__main__.py +++ b/nowplaying/__main__.py @@ -9,7 +9,8 @@ from .main import NowPlaying -def main(): +def main() -> None: + """Run nowplaying.""" NowPlaying().run() diff --git a/nowplaying/api.py b/nowplaying/api.py index 58c85d9f..4a16c1d5 100644 --- a/nowplaying/api.py +++ b/nowplaying/api.py @@ -1,8 +1,12 @@ +"""Nowplaying ApiServer.""" + +from __future__ import annotations + import json import logging -from queue import Queue +from typing import TYPE_CHECKING, Self -import cherrypy +import cherrypy # type: ignore[import-untyped] import cridlib from cloudevents.exceptions import GenericException as CloudEventException from cloudevents.http import from_http @@ -10,6 +14,14 @@ from werkzeug.routing import Map, Rule from werkzeug.wrappers import Request, Response +if TYPE_CHECKING: # pragma: no cover + from collections.abc import Iterable + from queue import Queue + from wsgiref.types import StartResponse, WSGIEnvironment + + from nowplaying.options import Options + + logger = logging.getLogger(__name__) _RABE_CLOUD_EVENTS_SUBS = ( @@ -25,21 +37,27 @@ class ApiServer: """The API server.""" - def __init__(self, options, event_queue: Queue, realm: str = "nowplaying"): + def __init__( + self: Self, + options: Options, + event_queue: Queue, + realm: str = "nowplaying", + ) -> None: + """Create ApiServer.""" self.options = options self.event_queue = event_queue self.realm = realm self.url_map = Map([Rule("/webhook", endpoint="webhook")]) - def run_server(self): + def run_server(self: Self) -> None: """Run the API server.""" if self.options.debug: from werkzeug.serving import run_simple - self._server = run_simple( - self.options.apiBindAddress, - self.options.apiPort, + run_simple( + self.options.api_bind_address, + self.options.api_port, self, use_debugger=True, use_reloader=True, @@ -48,25 +66,35 @@ def run_server(self): cherrypy.tree.graft(self, "/") cherrypy.server.unsubscribe() - self._server = cherrypy._cpserver.Server() + self._server = cherrypy._cpserver.Server() # noqa: SLF001 - self._server.socket_host = self.options.apiBindAddress - self._server.socket_port = self.options.apiPort + self._server.socket_host = self.options.api_bind_address + self._server.socket_port = self.options.api_port self._server.subscribe() cherrypy.engine.start() cherrypy.engine.block() - def stop_server(self): + def stop_server(self: Self) -> None: """Stop the server.""" self._server.stop() cherrypy.engine.exit() - def __call__(self, environ, start_response): + def __call__( + self: Self, + environ: WSGIEnvironment, + start_response: StartResponse, + ) -> Iterable[bytes]: + """Forward calls to wsgi_app.""" return self.wsgi_app(environ, start_response) - def wsgi_app(self, environ, start_response): + def wsgi_app( + self: Self, + environ: WSGIEnvironment, + start_response: StartResponse, + ) -> Iterable[bytes]: + """Return a wsgi app.""" request = Request(environ) auth = request.authorization if auth and self.check_auth(auth.username, auth.password): @@ -75,13 +103,18 @@ def wsgi_app(self, environ, start_response): response = self.auth_required(request) return response(environ, start_response) - def check_auth(self, username, password): - return ( - username in self.options.apiAuthUsers - and self.options.apiAuthUsers[username] == password + def check_auth(self: Self, username: str | None, password: str | None) -> bool: + """Check if auth is valid.""" + return str( + username, + ) in self.options.api_auth_users and self.options.api_auth_users[ + str(username) + ] == str( + password, ) - def auth_required(self, request): + def auth_required(self: Self, _: Request) -> Response: + """Check if auth is required.""" return Response( "Could not verify your access level for that URL.\n" "You have to login with proper credentials", @@ -89,7 +122,8 @@ def auth_required(self, request): {"WWW-Authenticate": f'Basic realm="{self.realm}"'}, ) - def dispatch_request(self, request): + def dispatch_request(self: Self, request: Request) -> Response | HTTPException: + """Dispatch requests to handlers.""" adapter = self.url_map.bind_to_environ(request.environ) try: endpoint, values = adapter.match() @@ -101,25 +135,25 @@ def dispatch_request(self, request): {"Content-Type": "application/json"}, ) - def on_webhook(self, request): + def on_webhook(self: Self, request: Request) -> Response: """Receive a CloudEvent and put it into the event queue.""" logger.warning("Received a webhook") if ( request.headers.get("Content-Type") not in _RABE_CLOUD_EVENTS_SUPPORTED_MEDIA_TYPES ): - raise UnsupportedMediaType() + raise UnsupportedMediaType try: event = from_http(request.headers, request.data) except CloudEventException as error: - raise BadRequest(description=f"{error}") + raise BadRequest(description=str(error)) from error try: crid = cridlib.parse(event["id"]) logger.debug("Detected CRID: %s", crid) except cridlib.CRIDError as error: raise BadRequest( - description=f"CRID '{event['id']}' is not a RaBe CRID" + description=f"CRID '{event['id']}' is not a RaBe CRID", ) from error logger.info("Received event: %s", event) diff --git a/nowplaying/daemon.py b/nowplaying/daemon.py index 2f17333c..c2283d9d 100644 --- a/nowplaying/daemon.py +++ b/nowplaying/daemon.py @@ -1,3 +1,5 @@ +"""Nowplaying Daemon.""" + import logging import os import signal @@ -5,19 +7,24 @@ import time from queue import Queue from threading import Thread - -from cloudevents.http.event import CloudEvent +from typing import TYPE_CHECKING, Any, Self from .api import ApiServer -from .input import observer as inputObservers +from .input import observer as input_observers from .input.handler import InputHandler from .misc.saemubox import SaemuBox +from .options import Options from .track.handler import TrackEventHandler from .track.observers.dab_audio_companion import DabAudioCompanionTrackObserver from .track.observers.icecast import IcecastTrackObserver from .track.observers.smc_ftp import SmcFtpTrackObserver from .track.observers.ticker import TickerTrackObserver +if TYPE_CHECKING: # pragma: no cover + from cloudevents.http.event import CloudEvent + +_EXCEPTION_NOWPLAYING_MAIN = "Error in main" + logger = logging.getLogger(__name__) @@ -27,16 +34,20 @@ class NowPlayingDaemon: """initialize last_input to a know value.""" last_input = 1 - def __init__(self, options): + def __init__(self: Self, options: Options) -> None: + """Create NowPlayingDaemon.""" self.options = options - self.event_queue = Queue() + self.event_queue: Queue = Queue() self.saemubox = SaemuBox( - self.options.saemubox_ip, self.options.check_saemubox_sender + self.options.saemubox_ip, + self.options.check_saemubox_sender, ) - def main(self): # pragma: no cover - # TODO test once there is not saemubox in the loop + def main(self: Self) -> None: # pragma: no cover + """Run Daemon.""" + # TODO(hairmare): test once there is not saemubox in the loop + # https://github.com/radiorabe/nowplaying/issues/179 logger.info("Starting up now-playing daemon") self.saemubox.run() @@ -44,8 +55,8 @@ def main(self): # pragma: no cover self.register_signal_handlers() input_handler = self.get_input_handler() - except Exception as e: - logger.exception("Error: %s", e) + except Exception: + logger.exception(_EXCEPTION_NOWPLAYING_MAIN) sys.exit(-1) _thread = Thread(target=self._main_loop, args=(input_handler,)) @@ -54,19 +65,18 @@ def main(self): # pragma: no cover self._start_apiserver() # blocking - def _start_apiserver(self): + def _start_apiserver(self: Self) -> None: """Start the API server.""" self._api = ApiServer(self.options, self.event_queue) self._api.run_server() # blocking - def _stop_apiserver(self): + def _stop_apiserver(self: Self) -> None: """Stop the API server.""" logger.info("Stopping API server") self._api.stop_server() - def _main_loop(self, input_handler: InputHandler): # pragma: no cover - """ - Run main loop of the daemon. + def _main_loop(self: Self, input_handler: InputHandler) -> None: # pragma: no cover + """Run main loop of the daemon. Should be run in a thread. """ @@ -76,66 +86,68 @@ def _main_loop(self, input_handler: InputHandler): # pragma: no cover saemubox_id = self.poll_saemubox() while not self.event_queue.empty(): - logger.debug("Queue size: %i" % self.event_queue.qsize()) + logger.debug("Queue size: %i", self.event_queue.qsize()) event: CloudEvent = self.event_queue.get() logger.info( - "Handling update from event: %s, source: %s" - % (event["type"], event["source"]) + "Handling update from event: %s, source: %s", + event["type"], + event["source"], ) input_handler.update(saemubox_id, event) input_handler.update(saemubox_id) - except Exception as e: - logger.exception("Error: %s", e) + except Exception: + logger.exception(_EXCEPTION_NOWPLAYING_MAIN) - time.sleep(self.options.sleepSeconds) + time.sleep(self.options.sleep_seconds) - def register_signal_handlers(self): + def register_signal_handlers(self: Self) -> None: + """Register signal handler.""" logger.debug("Registering signal handler") signal.signal(signal.SIGINT, self.signal_handler) - # signal.signal(signal.SIGKIL, self.signal_handler) - def signal_handler(self, signum, frame): - logger.debug("Signal %i caught" % signum) + def signal_handler(self: Self, signum: int, *_: Any) -> None: # noqa: ANN401 + """Handle signals.""" + logger.debug("Signal %i caught", signum) - if signum == signal.SIGINT or signum == signal.SIGKILL: - logger.info("Signal %i caught, terminating." % signum) + if signum in [signal.SIGINT, signal.SIGKILL]: + logger.info("Signal %i caught, terminating.", signum) self._stop_apiserver() sys.exit(os.EX_OK) - def get_track_handler(self): # pragma: no cover - # TODO test once options have been refactored with v3 + def get_track_handler(self: Self) -> TrackEventHandler: # pragma: no cover + """Get TrackEventHandler.""" + # TODO(hairmare): test once options have been refactored with v3 + # https://github.com/radiorabe/nowplaying/issues/179 handler = TrackEventHandler() - [ + for url in self.options.icecast: handler.register_observer( IcecastTrackObserver( - # TODO v3 remove uername and password - # because we mandate specifying via url + # TODO(hairmare): v3 remove uername and password + # because we mandate specifying via url + # https://github.com/radiorabe/nowplaying/issues/179 options=IcecastTrackObserver.Options( url=url, username="source", - password=self.options.icecastPassword, - ) - ) + password=self.options.icecast_password, + ), + ), ) - for url in self.options.icecast - ] - [ + for url in self.options.dab: handler.register_observer( DabAudioCompanionTrackObserver( options=DabAudioCompanionTrackObserver.Options( - url=url, dl_plus=self.options.dab_send_dls - ) - ) + url=url, + dl_plus=self.options.dab_send_dls, + ), + ), ) - for url in self.options.dab - ] handler.register_observer( TickerTrackObserver( options=TickerTrackObserver.Options( - file_path=self.options.tickerOutputFile - ) - ) + file_path=self.options.ticker_output_file, + ), + ), ) if self.options.dab_smc: handler.register_observer( @@ -144,50 +156,50 @@ def get_track_handler(self): # pragma: no cover hostname=self.options.dab_smc_ftp_hostname, username=self.options.dab_smc_ftp_username, password=self.options.dab_smc_ftp_password, - ) - ) + ), + ), ) return handler - def get_input_handler(self): # pragma: no cover - # TODO test once options have been refactored with v3 + def get_input_handler(self: Self) -> InputHandler: # pragma: no cover + """Get InputHandler.""" + # TODO(hairmare): test once options have been refactored with v3 + # https://github.com/radiorabe/nowplaying/issues/179 handler = InputHandler() track_handler = self.get_track_handler() - klangbecken = inputObservers.KlangbeckenInputObserver( - self.options.currentShowUrl, self.options.inputFile + klangbecken = input_observers.KlangbeckenInputObserver( + self.options.current_show_url, + self.options.input_file, ) klangbecken.add_track_handler(track_handler) handler.register_observer(klangbecken) - nonklangbecken = inputObservers.NonKlangbeckenInputObserver( - self.options.currentShowUrl + nonklangbecken = input_observers.NonKlangbeckenInputObserver( + self.options.current_show_url, ) nonklangbecken.add_track_handler(track_handler) handler.register_observer(nonklangbecken) return handler - def poll_saemubox(self) -> int: # pragma: no cover - """ - Poll Saemubox for new data. + def poll_saemubox(self: Self) -> int: # pragma: no cover + """Poll Saemubox for new data. Should be run once per main loop. - TODO v3 remove once replaced with pathfinder + TODO(hairmare) v3 remove once replaced with pathfinder + https://github.com/radiorabe/nowplaying/issues/179 """ - saemubox_id = self.saemubox.get_active_output_id() - logger.debug("Sämubox id: %i" % saemubox_id) + logger.debug("Sämubox id: %i", saemubox_id) if self.last_input != saemubox_id: logger.info( - 'Sämubox changed from "%s" to "%s"' - % ( - self.saemubox.get_id_as_name(self.last_input), - self.saemubox.get_id_as_name(saemubox_id), - ) + 'Sämubox changed from "%s" to "%s"', + self.saemubox.get_id_as_name(self.last_input), + self.saemubox.get_id_as_name(saemubox_id), ) self.last_input = saemubox_id diff --git a/nowplaying/input/handler.py b/nowplaying/input/handler.py index abf51579..fdd3c4ff 100644 --- a/nowplaying/input/handler.py +++ b/nowplaying/input/handler.py @@ -1,12 +1,20 @@ +"""Observe all input.""" + +from __future__ import annotations + import logging import logging.handlers +from typing import TYPE_CHECKING, Self -from cloudevents.http.event import CloudEvent +if TYPE_CHECKING: # pragma: no cover + from cloudevents.http.event import CloudEvent -from .observer import InputObserver + from nowplaying.input.observer import InputObserver logger = logging.getLogger(__name__) +_EXCEPTION_INPUT_UPDATE_FAIL = "Failed to update observer." + class InputHandler: """Inform all registered input-event observers about an input status. @@ -14,23 +22,27 @@ class InputHandler: This is the subject of the classical observer pattern. """ - def __init__(self): + def __init__(self: Self) -> None: + """Create InputHandler.""" self._observers: list[InputObserver] = [] - def register_observer(self, observer: InputObserver): - logger.info("Registering InputObserver '%s'" % observer.__class__.__name__) + def register_observer(self: Self, observer: InputObserver) -> None: + """Register an observer.""" + logger.info("Registering InputObserver '%s'", observer.__class__.__name__) self._observers.append(observer) - def remove_observer(self, observer: InputObserver): + def remove_observer(self: Self, observer: InputObserver) -> None: + """Remove an observer.""" self._observers.remove(observer) - def update(self, saemubox_id: int, event: CloudEvent = None): + def update(self: Self, saemubox_id: int, event: CloudEvent | None = None) -> None: + """Update all observers.""" for observer in self._observers: - logger.debug("Sending update event to observer %s" % observer.__class__) + logger.debug("Sending update event to observer %s", observer.__class__) try: observer.update(saemubox_id, event) - except Exception as e: # pragma: no cover - # TODO test once replaced with non generic exception - logger.error(f"InputObserver ({observer.__class__}): {e}") - logger.exception(e) + except Exception: # pragma: no cover + # TODO(hairmare): test once replaced with non generic exception + # https://github.com/radiorabe/nowplaying/issues/180 + logger.exception(_EXCEPTION_INPUT_UPDATE_FAIL) diff --git a/nowplaying/input/observer.py b/nowplaying/input/observer.py index 881eafe8..6eaf988a 100644 --- a/nowplaying/input/observer.py +++ b/nowplaying/input/observer.py @@ -1,22 +1,33 @@ +"""Nowplaying input observer.""" + +from __future__ import annotations + import logging import logging.handlers -import os import time import warnings import xml.dom.minidom from abc import ABC, abstractmethod +from pathlib import Path +from typing import TYPE_CHECKING, Self -import isodate +import isodate # type: ignore[import-untyped] import pytz -from cloudevents.http.event import CloudEvent -from ..show import client -from ..show.show import Show -from ..track.handler import TrackEventHandler -from ..track.track import DEFAULT_ARTIST, DEFAULT_TITLE, Track +from nowplaying.show import client +from nowplaying.show.show import Show +from nowplaying.track.track import DEFAULT_ARTIST, DEFAULT_TITLE, Track + +if TYPE_CHECKING: # pragma: no cover + from cloudevents.http.event import CloudEvent + + from nowplaying.track.handler import TrackEventHandler logger = logging.getLogger(__name__) +_EXCEPTION_INPUT_MISSING_SONG_TAG = "No tag found" +_EXCEPTION_INPUT_MISSING_TIMESTAMP = "Song timestamp attribute is missing" + class InputObserver(ABC): """Abstract base for all InputObservers.""" @@ -24,7 +35,8 @@ class InputObserver(ABC): _SHOW_NAME_KLANGBECKEN = "Klangbecken" _SHOW_URL_KLANGBECKEN = "http://www.rabe.ch/sendungen/musik/klangbecken.html" - def __init__(self, current_show_url: str): + def __init__(self: Self, current_show_url: str) -> None: + """Create InputObserver.""" self.show: Show self.track_handler: TrackEventHandler self.previous_saemubox_id: int = -1 @@ -36,95 +48,129 @@ def __init__(self, current_show_url: str): self.showclient = client.ShowClient(current_show_url) self.show = self.showclient.get_show_info() - def add_track_handler(self, track_handler: TrackEventHandler): + def add_track_handler(self: Self, track_handler: TrackEventHandler) -> None: + """Add Track handler.""" self.track_handler = track_handler - def update(self, saemubox_id: int, event: CloudEvent = None): - # TODO v3-prep refactor to use :meth:`handles` instead of :meth:`handle_id` + def update(self: Self, saemubox_id: int, event: CloudEvent | None = None) -> None: + """Handle update.""" + # TODO(hairmare): v3-prep refactor to use :meth:`handles` + # instead of :meth:`handle_id` + # https://github.com/radiorabe/nowplaying/issues/180 if self.handle_id(saemubox_id, event): self.handle(event) @abstractmethod - # TODO v3 remove this method + # TODO(hairmare): v3 remove this method + # https://github.com/radiorabe/nowplaying/issues/179 def handle_id( - self, saemubox_id: int, event: CloudEvent = None - ): # pragma: no coverage - pass + self: Self, + saemubox_id: int, + event: CloudEvent | None = None, + ) -> bool: # pragma: no coverage + """Handle ID.""" @abstractmethod - # TODO v3 remove this method - def handle(self, event: CloudEvent = None): # pragma: no coverage - pass + # TODO(hairmare): v3 remove this method + # https://github.com/radiorabe/nowplaying/issues/179 + def handle( + self: Self, + event: CloudEvent | None = None, + ) -> None: # pragma: no coverage + """Handle event.""" @abstractmethod - def handles(self, event: CloudEvent) -> bool: # pragma: no coverage - pass + def handles(self: Self, event: CloudEvent) -> bool: # pragma: no coverage + """Handle event.""" @abstractmethod - def event(self, event: CloudEvent): # pragma: no coverage - pass + def event(self: Self, event: CloudEvent) -> None: # pragma: no coverage + """Handle event.""" class KlangbeckenInputObserver(InputObserver): """Observe when Sämu Box says Klangbecken we have now-playing.xml input.""" def __init__( - self, current_show_url: str, input_file: str = None - ): # pragma: no coverage - # TODO test once input file is replaced with api + self: Self, + current_show_url: str, + input_file: str | None = None, + ) -> None: # pragma: no coverage + """Create KlangbeckenInputObserver.""" + # TODO(hairmare): test once input file is replaced with api + # https://github.com/radiorabe/nowplaying/issues/180 if input_file: warnings.warn( "The now-playing.xml format from Loopy/Klangbecken " "will be replaced in the future", PendingDeprecationWarning, + stacklevel=2, ) self.input_file = input_file - self.last_modify_time = os.stat(self.input_file).st_mtime + self.last_modify_time = Path(self.input_file).stat().st_mtime self.track: Track super().__init__(current_show_url) - def handles(self, event: CloudEvent) -> bool: - # TODO v3-prep call :meth:`handle_id` from here - # needs saemubox_id compat workaround - # TODO v3 remove call to :meth:`handle_id` - # TODO make magic string configurable - # TODO check if source is currently on-air + def handles(self: Self, event: CloudEvent | None) -> bool: + """Check if we need to handle the event.""" + # TODO(hairmare): v3-prep call :meth:`handle_id` from here + # needs saemubox_id compat workaround + # https://github.com/radiorabe/nowplaying/issues/180 + # TODO(hairmare): v3 remove call to :meth:`handle_id` + # https://github.com/radiorabe/nowplaying/issues/179 + # TODO(hairmare): make magic string configurable + # https://github.com/radiorabe/nowplaying/issues/179 + # TODO(hairmare): check if source is currently on-air + # https://github.com/radiorabe/nowplaying/issues/179 if not event: # pragma: no coverage - # TODO remove checking for None once only events exist + # TODO(hairmare): remove checking for None once only events exist + # https://github.com/radiorabe/nowplaying/issues/179 return False return event["source"] == "https://github/radiorabe/klangbecken" - def event(self, event: CloudEvent): + def event(self: Self, event: CloudEvent) -> None: + """Handle event.""" self._handle(event) - def handle_id(self, saemubox_id: int, event: CloudEvent = None): + def handle_id( + self: Self, + saemubox_id: int, + event: CloudEvent | None = None, + ) -> bool: + """Handle ID.""" # only handle Klangbecken output if saemubox_id == 1: return True - # TODO v3-prep make this get called from :meth:`handles` + # TODO(hairmare): v3-prep make this get called from :meth:`handles` + # https://github.com/radiorabe/nowplaying/issues/180 return self.handles(event) - def handle(self, event: CloudEvent = None): + def handle(self: Self, event: CloudEvent | None = None) -> None: + """Handle RaBe CloudEevent.""" self._handle(event) - def _handle(self, event: CloudEvent = None): + def _handle(self: Self, event: CloudEvent | None = None) -> None: """Handle actual RaBe CloudEevent. - TODO v3: move into :meth:`event` - once :meth:`handle` and :meth:`handle_id` have been yeeted - TODO v3: remove all refs to input_file and it's modify time - once we use event handlers + TODO(hairmare): v3: move into :meth:`event` + once :meth:`handle` and :meth:`handle_id` have been yeeted + https://github.com/radiorabe/nowplaying/issues/179 + TODO(hairmare): v3: remove all refs to input_file and it's modify time + once we use event handlers + https://github.com/radiorabe/nowplaying/issues/179 """ if not event: - # @TODO: replace the stat method with inotify - modify_time = os.stat(self.input_file).st_mtime + # @TODO(hairmare): replace the stat method with inotify + # https://github.com/radiorabe/nowplaying/issues/179 + modify_time = Path(self.input_file).stat().st_mtime - # @TODO: Need to check if we have a stale file and send default - # track infos in this case. This might happend if loopy - # went out for lunch... - # pseudo code: now > modify_time + self.track.get_duration() + # @TODO(hairmare): Need to check if we have a stale file and send default + # track infos in this case. This might happend if loopy + # went out for lunch... + # pseudo code: now > modify_time + self.track.get_duration() + # https://github.com/radiorabe/nowplaying/issues/179 if self.first_run or event or modify_time > self.last_modify_time: logger.info("Now playing file changed") @@ -137,16 +183,18 @@ def _handle(self, event: CloudEvent = None): self.track = self.parse_event(event) self.first_run = False - logger.info("First run: %s" % self.first_run) + logger.info("First run: %s", self.first_run) if not self.first_run: # pragma: no coverage - # TODO test once we don't have to care about - # mtime/inotify because it's an api + # TODO(hairmare): test once we don't have to care about + # mtime/inotify because it's an api + # https://github.com/radiorabe/nowplaying/issues/179 logger.info("calling track_finished") self.track_handler.track_finished(self.track) if not event: - # TODO remove once legacy xml is gone + # TODO(hairmare): remove once legacy xml is gone + # https://github.com/radiorabe/nowplaying/issues/179 self.track = self.get_track_info() # Klangbecken acts as a failover and last resort input, if other @@ -155,8 +203,9 @@ def _handle(self, event: CloudEvent = None): # of what loopy thinks. if self.show.name != self._SHOW_NAME_KLANGBECKEN: logger.info( - "Klangbecken Input active, overriding current show '%s' with '%s'" - % (self.show.name, self._SHOW_NAME_KLANGBECKEN) + "Klangbecken Input active, overriding current show '%s' with '%s'", + self.show.name, + self._SHOW_NAME_KLANGBECKEN, ) self.show = Show() @@ -170,14 +219,17 @@ def _handle(self, event: CloudEvent = None): self.track.set_show(self.show) - # TODO: or finished? + # TODO(hairmare): or finished? + # https://github.com/radiorabe/nowplaying/issues/179 self.track_handler.track_started(self.track) self.first_run = False - def get_track_info(self): - # TODO v3 remove method once legacy xml is gone - dom = xml.dom.minidom.parse(self.input_file) + def get_track_info(self: Self) -> Track: + """Get Track info.""" + # TODO(hairmare): v3 remove method once legacy xml is gone + # https://github.com/radiorabe/nowplaying/issues/179 + dom = xml.dom.minidom.parse(self.input_file) # noqa: S318 # default track info track_info = { @@ -188,38 +240,41 @@ def get_track_info(self): "time": "", } - song = dom.getElementsByTagName("song") + songs = dom.getElementsByTagName("song") - if len(song) == 0 or song[0].hasChildNodes() is False: # pragma: no coverage - # TODO replace with non generic exception and test - raise Exception("No tag found") + if len(songs) == 0 or songs[0].hasChildNodes() is False: # pragma: no coverage + # TODO(hairmare): replace with non generic exception and test + # https://github.com/radiorabe/nowplaying/issues/179 + raise Exception(_EXCEPTION_INPUT_MISSING_SONG_TAG) # noqa: TRY002 - song = song[0] + song = songs[0] for name in list(track_info.keys()): elements = song.getElementsByTagName(name) if len(elements) == 0: # pragma: no coverage - # TODO replace with non generic exception and test - raise Exception("No <%s> tag found" % name) - elif elements[0].hasChildNodes(): - element_data = elements[0].firstChild.data.strip() + # TODO(hairmare): replace with non generic exception and test + # https://github.com/radiorabe/nowplaying/issues/179 + raise Exception("No <%s> tag found" % name) # noqa: TRY002, UP031 + if elements[0].hasChildNodes(): + element_data = elements[0].firstChild.data.strip() # type: ignore[attr-defined,union-attr] if element_data != "": track_info[name] = element_data else: # pragma: no coverage - logger.info("Element %s has empty value, ignoring" % name) + logger.info("Element %s has empty value, ignoring", name) if not song.hasAttribute("timestamp"): # pragma: no coverage - # TODO replace with non generic exception and test - raise Exception("Song timestamp attribute is missing") + # TODO(hairmare): replace with non generic exception and test + # https://github.com/radiorabe/nowplaying/issues/179 + raise Exception(_EXCEPTION_INPUT_MISSING_TIMESTAMP) # noqa: TRY002 # set the start time and append the missing UTC offset # @TODO: The UTC offset should be provided by the now playing XML # generated by Thomas # ex.: 2012-05-15T09:47:07+02:00 track_info["start_timestamp"] = song.getAttribute("timestamp") + time.strftime( - "%z" + "%z", ) current_track = Track() @@ -231,17 +286,18 @@ def get_track_info(self): # Store as UTC datetime object current_track.set_starttime( isodate.parse_datetime(track_info["start_timestamp"]).astimezone( - pytz.timezone("UTC") - ) + pytz.timezone("UTC"), + ), ) - current_track.set_duration(track_info["time"]) + current_track.set_duration(int(track_info["time"])) return current_track - def parse_event(self, event: CloudEvent) -> Track: + def parse_event(self: Self, event: CloudEvent) -> Track: + """Parse event.""" track = Track() - logger.info("Parsing event: %s" % event) + logger.info("Parsing event: %s", event) track.set_artist(event.data["item.artist"]) track.set_title(event.data["item.title"]) @@ -250,13 +306,12 @@ def parse_event(self, event: CloudEvent) -> Track: if event["type"] == "ch.rabe.api.events.track.v1.trackStarted": track.set_starttime(event_time) elif event["type"] == "ch.rabe.api.events.track.v1.trackFinished": - # TODO consider using now() instead of event['time'] track.set_endtime(event_time) if "item.length" in event.data: track.set_duration(event.data["item.length"]) - logger.info("Track: %s" % track) + logger.info("Track: %s", track) return track @@ -266,7 +321,7 @@ class NonKlangbeckenInputObserver(InputObserver): Uses the show's name instead of the actual track infos """ - def handles(self, event: CloudEvent) -> bool: # pragma: no coverage + def handles(self: Self, _: CloudEvent) -> bool: # pragma: no coverage """Do not handle events yet. TODO implement this method @@ -276,29 +331,27 @@ def handles(self, event: CloudEvent) -> bool: # pragma: no coverage """ return False - def event(self, event: CloudEvent): # pragma: no coverage + def event(self: Self, event: CloudEvent) -> None: # pragma: no coverage """Do not handle events yet. TODO implement this method """ - super().event(event) - def handle_id(self, saemubox_id: int, event: CloudEvent = None): + def handle_id(self: Self, saemubox_id: int, _: CloudEvent | None = None) -> bool: + """Handle new ID from Saemubox.""" if saemubox_id != self.previous_saemubox_id: # If sämubox changes, force a show update, this acts as # a self-healing measurement in case the show web service provides # nonsense ;) - self.show = self.showclient.get_show_info(True) + self.show = self.showclient.get_show_info(force_update=True) self.previous_saemubox_id = saemubox_id # only handle non-Klangbecken - if saemubox_id != 1: - return True - - return False + return saemubox_id != 1 - def handle(self, event: CloudEvent = None): + def handle(self: Self, _: CloudEvent | None = None) -> None: + """Handle Track.""" self.show = self.showclient.get_show_info() # only handle if a new show has started @@ -307,7 +360,8 @@ def handle(self, event: CloudEvent = None): self.track_handler.track_started(self.get_track_info()) self.previous_show_uuid = self.show.uuid - def get_track_info(self): + def get_track_info(self: Self) -> Track: + """Get Track info.""" current_track = Track() current_track.set_artist(DEFAULT_ARTIST) diff --git a/nowplaying/main.py b/nowplaying/main.py index 821758c9..b211b8d3 100644 --- a/nowplaying/main.py +++ b/nowplaying/main.py @@ -1,4 +1,7 @@ +"""Nowplaying entrypoint.""" + import socket +from typing import Self from .daemon import NowPlayingDaemon from .options import Options @@ -6,20 +9,22 @@ class NowPlaying: - def run(self): + """Nowplaying main class.""" + + def run(self: Self) -> None: """Load configuration, initialize environment and start nowplaying daemon.""" self.options = Options() self.options.parse_known_args() self._setup_otel() - socket.setdefaulttimeout(self.options.socketDefaultTimeout) + socket.setdefaulttimeout(self.options.socket_default_timeout) self._run_daemon() - def _setup_otel(self): # pragma: no cover + def _setup_otel(self: Self) -> None: # pragma: no cover if not self.options.debug: - setup_otel(self.options.otlp_enable) + setup_otel(otlp_enable=self.options.otlp_enable) - def _run_daemon(self): + def _run_daemon(self: Self) -> None: """Start nowplaying daemon.""" NowPlayingDaemon(self.options).main() diff --git a/nowplaying/misc/saemubox.py b/nowplaying/misc/saemubox.py index 61a481e6..fc500ebd 100644 --- a/nowplaying/misc/saemubox.py +++ b/nowplaying/misc/saemubox.py @@ -20,8 +20,6 @@ class SaemuBoxError(Exception): """SaemuBox related exception.""" - pass - class SaemuBox: """Receive and validate info from Sämu Box for nowplaying.""" @@ -37,7 +35,8 @@ class SaemuBox: def __init__(self, saemubox_ip, check_sender=True): warnings.warn( - "Saemubox will be replaced with Pathfinder", PendingDeprecationWarning + "Saemubox will be replaced with Pathfinder", + PendingDeprecationWarning, ) self.output = "" @@ -70,11 +69,11 @@ def _setup_socket(self): # pragma: no cover except OSError as e: # pragma: no cover self.sock = None logger.error("SaemuBox: cannot bind to %s:%i." % (self.bind_ip, self.port)) - raise SaemuBoxError() from e + raise SaemuBoxError from e def __update(self): # pragma: no cover if self.sock is None or (hasattr(self.sock, "_closed") and self.sock._closed): - logger.warn("SaemuBox: socket closed unexpectedly, retrying...") + logger.warning("SaemuBox: socket closed unexpectedly, retrying...") self._setup_socket() output = None @@ -84,7 +83,9 @@ def __update(self): # pragma: no cover while select.select([self.sock], [], [], 0)[0]: data, addr = self.sock.recvfrom(1024) if self.check_sender and addr[0] not in self.senders: - logger.warn("SaemuBox: receiving data from invalid host: %s " % addr[0]) + logger.warning( + "SaemuBox: receiving data from invalid host: %s " % addr[0], + ) continue ids = data.split() # several saemubox ids might come in one packet @@ -94,7 +95,7 @@ def __update(self): # pragma: no cover seen_senders.add(addr[0]) output = id else: - logger.warn("SaemuBox: received invalid data: %s" % data) + logger.warning("SaemuBox: received invalid data: %s" % data) if output is None: logger.error("SaemuBox: could not read current status.") @@ -102,7 +103,7 @@ def __update(self): # pragma: no cover raise SaemuBoxError("Cannot read data from SaemuBox") elif seen_senders != self.senders: for missing_sender in self.senders - seen_senders: - logger.warn("SaemuBox: missing sender: %s" % missing_sender) + logger.warning("SaemuBox: missing sender: %s" % missing_sender) self.output = int(output) @@ -126,7 +127,7 @@ def get_id_as_name(self, number): # pragma: no cover logger.addHandler(logging.StreamHandler(sys.stdout)) logger.setLevel(logging.INFO) - sb = SaemuBox() + sb = SaemuBox(saemubox_ip="127.0.0.1") localhost = socket.gethostbyname(socket.gethostname()) sb.senders.add(localhost) diff --git a/nowplaying/options.py b/nowplaying/options.py index da212c94..c206d257 100644 --- a/nowplaying/options.py +++ b/nowplaying/options.py @@ -1,4 +1,10 @@ -import configargparse +"""Options for Nowplaying.""" + +from __future__ import annotations + +from typing import Self + +import configargparse # type: ignore[import-untyped] from nowplaying.track.observers.dab_audio_companion import ( DabAudioCompanionTrackObserver, @@ -12,17 +18,19 @@ class Options: """Contain all hardcoded and loaded from configargparse options.""" """How many seconds the main daemon loop sleeps.""" - sleepSeconds = 1 + sleep_seconds = 1 """Default socket of 2 minutes, to prevent endless hangs on HTTP requests.""" - socketDefaultTimeout = 120 + socket_default_timeout = 120 - def __init__(self): + def __init__(self: Self) -> None: """Configure configargparse.""" self.__args = configargparse.ArgParser( - default_config_files=["/etc/nowplaying/conf.d/*.conf", "~/.nowplayingrc"] + default_config_files=["/etc/nowplaying/conf.d/*.conf", "~/.nowplayingrc"], ) - # TODO v3 remove this option + # TODO(hairmare): v3 remove this option + # https://github.com/radiorabe/nowplaying/issues/179 + self.saemubox_ip: str | None = None self.__args.add_argument( "-b", "--saemubox-ip", @@ -30,52 +38,75 @@ def __init__(self): help="IP address of SAEMUBOX", default="", ) - # TODO v3 remove this option + # TODO(hairmare): v3 remove this option + # https://github.com/radiorabe/nowplaying/issues/179 + self.check_saemubox_sender: bool = True self.__args.add_argument( "--check-saemubox-sender", dest="check_saemubox_sender", help="Check SRC SAEMUBOX IP", default=True, ) + + self.icecast: list[str] = [] + self.icecast_password: str = "" IcecastTrackObserver.Options.args(self.__args) + + self.dab: list[str] = [] + self.dab_send_dls: bool = False DabAudioCompanionTrackObserver.Options.args(self.__args) + + self.dab_smc: bool = False + self.dab_smc_ftp_hostname: str = "" + self.dab_smc_ftp_username: str = "" + self.dab_smc_ftp_password: str = "" SmcFtpTrackObserver.Options.args(self.__args) + + self.ticker_output_file: str = "" TickerTrackObserver.Options.args(self.__args) + + self.current_show_url: str = "" self.__args.add_argument( "-s", "--show", - dest="currentShowUrl", + dest="current_show_url", help="Current Show URL e.g. 'https://libretime.int.example.org/api/live-info-v2/format/json'", ) - # TODO v3 remove this option + # TODO(hairmare): v3 remove this option + # https://github.com/radiorabe/nowplaying/issues/179 + self.input_file: str = "/home/endlosplayer/Eingang/now-playing.xml" self.__args.add_argument( "--input-file", - dest="inputFile", + dest="input_file", help=( "XML 'now-playing' input file location, " "disable input by passing empty string, ie. --input-file=''" ), default="/home/endlosplayer/Eingang/now-playing.xml", ) + self.api_bind_address: str = "127.0.0.1" self.__args.add_argument( "--api-bind-address", - dest="apiBindAddress", + dest="api_bind_address", help="Bind address for the API server", - default="0.0.0.0", + default="127.0.0.1", ) + self.api_port: int = 8080 self.__args.add_argument( "--api-port", type=int, - dest="apiPort", + dest="api_port", help="Bind port for the API server", default=8080, ) + self.api_auth_users: dict[str, str] = {} self.__args.add_argument( "--api-auth-users", - dest="apiAuthUsers", + dest="api_auth_users", help="API Auth Users", default={"rabe": "rabe"}, ) + self.otlp_enable: bool = False self.__args.add_argument( "--instrumentation-otlp-enable", type=bool, @@ -86,6 +117,7 @@ def __init__(self): default=False, env_var="NOWPLAYING_INSTRUMENTATION_OTLP_ENABLE", ) + self.debug: bool = False self.__args.add_argument( "--debug", type=bool, @@ -96,6 +128,6 @@ def __init__(self): default=False, ) - def parse_known_args(self): + def parse_known_args(self: Self) -> None: """Parse known args with configargparse.""" self.__args.parse_known_args(namespace=self) diff --git a/nowplaying/otel.py b/nowplaying/otel.py index 0c5429bd..3c191a9c 100644 --- a/nowplaying/otel.py +++ b/nowplaying/otel.py @@ -6,6 +6,8 @@ import logging import os from datetime import datetime +from pathlib import Path +from typing import no_type_check from opentelemetry._logs import set_logger_provider from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter @@ -18,9 +20,10 @@ from opentelemetry.sdk.resources import Resource -def _log_formatter(record): # pragma: no cover +@no_type_check +def _log_formatter(record: LogRecord) -> str: # pragma: no cover return ( - f"{datetime.fromtimestamp(record.timestamp/1000000000)} " + f"{datetime.fromtimestamp(record.timestamp/1000000000)} " # noqa: DTZ006 f"- {record.severity_text[:4]:4} " f"- {record.attributes['source_name'][11:]:14} " f"- {record.body} " @@ -32,16 +35,19 @@ def _log_formatter(record): # pragma: no cover class SourceAttributeFilter(logging.Filter): # pragma: no cover """Used on the handler to ensure that some attributes are carried over to otel.""" - def filter(self, record) -> bool: + @no_type_check + def filter(self: Self, record: LogRecord) -> bool: + """Carry over attributes to otel.""" record.source_name = record.name record.source_pathname = os.path.relpath( - record.pathname, os.path.dirname(os.path.dirname(__file__)) + record.pathname, + Path(__file__).parent.parent, ) record.source_lineno = record.lineno return True -def setup_otel(otlp_enable=False): # pragma: no cover +def setup_otel(*, otlp_enable: bool = False) -> None: # pragma: no cover """Configure opentelemetry logging to stdout and collector.""" root = logging.getLogger() root.setLevel(logging.INFO) @@ -51,12 +57,12 @@ def setup_otel(otlp_enable=False): # pragma: no cover { "service.name": "nowplaying", }, - ) + ), ) set_logger_provider(logger_provider) console_exporter = ConsoleLogExporter( - formatter=lambda record: _log_formatter(record) + formatter=lambda record: _log_formatter(record), ) logger_provider.add_log_record_processor(SimpleLogRecordProcessor(console_exporter)) diff --git a/nowplaying/show/client.py b/nowplaying/show/client.py index 5f22f57d..067d9a0d 100644 --- a/nowplaying/show/client.py +++ b/nowplaying/show/client.py @@ -1,8 +1,14 @@ +"""Show client interacts with LibreTime.""" + +from __future__ import annotations + import datetime import logging import logging.handlers import re from html.entities import entitydefs +from re import Match +from typing import Self import pytz import requests @@ -11,12 +17,15 @@ logger = logging.getLogger(__name__) +_EXCEPTION_SHOWCLIENT_NO_SHOW = "Unable to get current show information" +_EXCEPTION_SHOWCLIENT_NO_NAME = "Missing show name" +_EXCEPTION_SHOWCLIENT_NO_START = "Missing show start time" +_EXCEPTION_SHOWCLIENT_NO_END = "Missing show end time" + class ShowClientError(Exception): """ShowClient related exception.""" - pass - class ShowClient: """Fetches the show info from LibreTime now-playing v2 endpoint. @@ -28,15 +37,15 @@ class ShowClient: __cleanup_show_name_regexp = re.compile(r"&(\w+?);") __show_datetime_format = "%Y-%m-%d %H:%M:%S" - def __init__(self, current_show_url): + def __init__(self: Self, current_show_url: str) -> None: + """Create Show.""" self.current_show_url = current_show_url self.show = Show() - self.showtz = None + self.showtz = pytz.timezone(zone="UTC") - def get_show_info(self, force_update=False): + def get_show_info(self: Self, *, force_update: bool = False) -> Show: """Return a Show object.""" - if force_update: self.update() else: @@ -44,8 +53,8 @@ def get_show_info(self, force_update=False): return self.show - def lazy_update(self): - # only update the info if we expect that a new show has started + def lazy_update(self: Self) -> None: + """Only update the info if we expect that a new show has started.""" if datetime.datetime.now(pytz.timezone("UTC")) > self.show.endtime: logger.info("Show expired, going to update show info") self.update() @@ -53,7 +62,8 @@ def lazy_update(self): else: logger.debug("Show still running, won't update show info") - def update(self): + def update(self: Self) -> None: + """Update state.""" self.show = Show() # Create a new show object # Set the show's default end time to now + 30 seconds to prevent updates @@ -61,25 +71,22 @@ def update(self): # goes wrong later. self.show.set_endtime( datetime.datetime.now(pytz.timezone("UTC")) - + datetime.timedelta(seconds=self.__DEFAULT_SHOW_DURATION) + + datetime.timedelta(seconds=self.__DEFAULT_SHOW_DURATION), ) try: # try to get the current show informations from loopy's cast web # service - data = requests.get(self.current_show_url).json() - - logger.debug("Got show info: %s" % data) + data = requests.get(self.current_show_url, timeout=60).json() - except Exception as e: - logger.error("Unable to get current show informations") + logger.debug("Got show info: %s", data) - logger.exception(e) - # LSB 2017: ignoring missing show update - # raise ShowClientError('Unable to get show informations: %s' % e) + except Exception: + logger.exception(_EXCEPTION_SHOWCLIENT_NO_SHOW) + # ignoring missing show update return - self.showtz = pytz.timezone(data["station"]["timezone"]) + self.showtz = pytz.timezone(zone=data["station"]["timezone"]) # pick the current show show_data = self.__pick_current_show(data) @@ -94,8 +101,7 @@ def update(self): if len(real_name) == 0: # keep the default show information - logger.error("No show name found") - raise ShowClientError("Missing show name") + raise ShowClientError(_EXCEPTION_SHOWCLIENT_NO_NAME) real_name = self.__cleanup_show_name(real_name) self.show.set_name(real_name) @@ -105,11 +111,13 @@ def update(self): end_time = show_data["ends"] if len(end_time) == 0: - logger.error("No end found") - raise ShowClientError("Missing show end time") + raise ShowClientError(_EXCEPTION_SHOWCLIENT_NO_END) endtime = self.showtz.localize( - datetime.datetime.strptime(end_time, self.__show_datetime_format) + datetime.datetime.strptime( # noqa: DTZ007 + end_time, + self.__show_datetime_format, + ), ) # store as UTC datetime object @@ -121,10 +129,13 @@ def update(self): if len(start_time) == 0: logger.error("No start found") - raise ShowClientError("Missing show start time") + raise ShowClientError(_EXCEPTION_SHOWCLIENT_NO_START) starttime = self.showtz.localize( - datetime.datetime.strptime(start_time, self.__show_datetime_format) + datetime.datetime.strptime( # noqa: DTZ007 + start_time, + self.__show_datetime_format, + ), ) # store as UTC datetime object @@ -134,10 +145,10 @@ def update(self): # This prevents stale (wrong) show informations from beeing pushed to # the live stream and stops hammering the service every second if self.show.endtime < datetime.datetime.now(pytz.timezone("UTC")): - logger.error("Show endtime %s is in the past" % self.show.endtime) + logger.error("Show endtime %s is in the past", self.show.endtime) - raise ShowClientError( - "Show end time (%s) is in the past" % self.show.endtime + raise ShowClientError( # noqa: TRY003 + f"Show end time ({self.show.endtime}) is in the past", # noqa: EM102 ) # get the show's URL @@ -150,15 +161,17 @@ def update(self): self.show.set_url(url) logger.info( - 'Show "%s" started and runs from %s till %s' - % (self.show.name, starttime, endtime) + 'Show "%s" started and runs from %s till %s', + self.show.name, + starttime, + endtime, ) logger.debug(self.show) - def __cleanup_show_name(self, name) -> str: + def __cleanup_show_name(self: Self, name: str) -> str: """Cleanup name by undoing htmlspecialchars from libretime zf1 mvc.""" - def __entityref_decode(m): + def __entityref_decode(m: Match[str]) -> str: try: return entitydefs[m.group(1)] except KeyError: @@ -166,7 +179,7 @@ def __entityref_decode(m): return self.__cleanup_show_name_regexp.sub(__entityref_decode, name) - def __pick_current_show(self, data): + def __pick_current_show(self: Self, data: dict[str, dict]) -> dict[str, str] | None: """Pick the current show from the data. If there is no current show and the next one starts reasonably soon, pick that. @@ -176,21 +189,25 @@ def __pick_current_show(self, data): logger.info("No current show is playing, checking next show") if data["shows"]["next"] and data["shows"]["next"][0]: show = data["shows"]["next"][0] - logger.info("Next show is %s" % show["name"]) + logger.info("Next show is %s", show["name"]) next_start = self.showtz.localize( - datetime.datetime.strptime( - show["starts"], self.__show_datetime_format - ) + datetime.datetime.strptime( # noqa: DTZ007 + show["starts"], + self.__show_datetime_format, + ), ) logger.warning( - datetime.datetime.now(pytz.timezone("UTC")) - + datetime.timedelta(minutes=15) + "%s", + ( + datetime.datetime.now(pytz.timezone("UTC")) + + datetime.timedelta(minutes=15) + ), ) logger.warning(next_start) if next_start < datetime.datetime.now( - pytz.timezone("UTC") + pytz.timezone("UTC"), ) + datetime.timedelta(minutes=15): logger.info("Next show starts soon enough, using it") return show - return + return None return data["shows"]["current"] diff --git a/nowplaying/show/show.py b/nowplaying/show/show.py index 305bb203..e21fa50b 100644 --- a/nowplaying/show/show.py +++ b/nowplaying/show/show.py @@ -1,7 +1,10 @@ +"""Nowplaying Show model.""" + import datetime import logging import logging.handlers import uuid +from typing import Self import pytz @@ -9,17 +12,19 @@ DEFAULT_SHOW_URL = "https://www.rabe.ch" +_EXCEPTION_SHOW_ERROR_STARTTIME_NO_DATETIME = "starttime has to be a datatime object" +_EXCEPTION_SHOW_ERROR_ENDTIME_NO_DATETIME = "endtime has to be a datatime object" + class ShowError(Exception): """Show related exception.""" - pass - class Show: """Show object which has a start and end time and an optional URL.""" - def __init__(self): + def __init__(self: Self) -> None: + """Create Show.""" self.name = "" self.url = DEFAULT_SHOW_URL @@ -36,29 +41,34 @@ def __init__(self): # The show's end time, initially set to to now self.endtime = now - def set_name(self, name): + def set_name(self: Self, name: str) -> None: + """Set Show name.""" # The name of the show self.name = name - def set_url(self, url): + def set_url(self: Self, url: str) -> None: + """Set Show URL.""" # The URL of the show self.url = url - def set_starttime(self, starttime): + def set_starttime(self: Self, starttime: datetime.datetime) -> None: + """Set Show start time.""" if not isinstance(starttime, datetime.datetime): - raise ShowError("starttime has to be a datatime object") + raise ShowError(_EXCEPTION_SHOW_ERROR_STARTTIME_NO_DATETIME) # The show's start time as a datetime object self.starttime = starttime - def set_endtime(self, endtime): + def set_endtime(self: Self, endtime: datetime.datetime) -> None: + """Set Show end time.""" if not isinstance(endtime, datetime.datetime): - raise ShowError("endtime has to be a datatime object") + raise ShowError(_EXCEPTION_SHOW_ERROR_ENDTIME_NO_DATETIME) # The show's end time as a datetime object self.endtime = endtime - def __str__(self): + def __str__(self: Self) -> str: + """Stringify Show.""" return ( f"Show '{self.name}' ({self.uuid}), " f"start: '{self.starttime}', end: '{self.endtime}', url: {self.url}" diff --git a/nowplaying/track/handler.py b/nowplaying/track/handler.py index 9aab92e3..04bf19c9 100644 --- a/nowplaying/track/handler.py +++ b/nowplaying/track/handler.py @@ -1,13 +1,21 @@ """Track event handling subject of the observer.""" +from __future__ import annotations + import logging import logging.handlers +from typing import TYPE_CHECKING, Self + +if TYPE_CHECKING: # pragma: no cover + from .observers.base import TrackObserver + from .track import Track -from .observers.base import TrackObserver -from .track import Track logger = logging.getLogger(__name__) +_EXCEPTION_TRACK_HANDLER_ERROR_START = "Observer failed to start track" +_EXCEPTION_TRACK_HANDLER_ERROR_FINISH = "Observer failed to finish track" + class TrackEventHandler: """Inform all registered track-event observers about a track change. @@ -15,24 +23,24 @@ class TrackEventHandler: This is the subject of the classical observer pattern """ - def __init__(self): + def __init__(self: Self) -> None: """Initialize the track event handler.""" - self.__observers = [] + self.__observers: list[TrackObserver] = [] - def register_observer(self, observer: TrackObserver): + def register_observer(self: Self, observer: TrackObserver) -> None: """Register an observer to be informed about track changes.""" logger.info("Registering TrackObserver '%s'", observer.__class__.__name__) self.__observers.append(observer) - def remove_observer(self, observer: TrackObserver): + def remove_observer(self: Self, observer: TrackObserver) -> None: """Remove an observer from the list of observers.""" self.__observers.remove(observer) - def get_observers(self) -> list: + def get_observers(self: Self) -> list: """Return register observers to allow inspecting them.""" return self.__observers - def track_started(self, track: Track): + def track_started(self: Self, track: Track) -> None: """Inform all registered track-event observers about a track started event.""" logger.info( "Sending track-started event to %s observers: %s", @@ -42,15 +50,16 @@ def track_started(self, track: Track): for observer in self.__observers: logger.debug( - "Sending track-started event to observer %s", observer.__class__ + "Sending track-started event to observer %s", + observer.__class__, ) try: observer.track_started(track) - except Exception as error: - logger.exception(error) + except Exception: + logger.exception(_EXCEPTION_TRACK_HANDLER_ERROR_START) - def track_finished(self, track: Track): + def track_finished(self: Self, track: Track) -> None: """Inform all registered track-event observers about a track finished event.""" logger.info( "Sending track-finished event to %s observers: %s", @@ -60,10 +69,11 @@ def track_finished(self, track: Track): for observer in self.__observers: logger.debug( - "Sending track-finished event to observer %s", observer.__class__ + "Sending track-finished event to observer %s", + observer.__class__, ) try: observer.track_finished(track) - except Exception as error: - logger.exception(error) + except Exception: + logger.exception(_EXCEPTION_TRACK_HANDLER_ERROR_FINISH) diff --git a/nowplaying/track/observers/base.py b/nowplaying/track/observers/base.py index 87b0cea6..d524043c 100644 --- a/nowplaying/track/observers/base.py +++ b/nowplaying/track/observers/base.py @@ -1,6 +1,16 @@ +"""Abstract base for TrackObservers.""" + +from __future__ import annotations + from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Self, TypeVar + +if TYPE_CHECKING: # pragma: no cover + import configargparse # type: ignore[import-untyped] -import configargparse + from nowplaying.track.track import Track + +TTrackObserverOptions = TypeVar("TTrackObserverOptions", bound="TrackObserver.Options") class TrackObserver(ABC): @@ -9,18 +19,24 @@ class TrackObserver(ABC): name = "TrackObserver" class Options(ABC): + """Abstract base class for add TrackObserver.Options.""" + @classmethod @abstractmethod - def args(cls, args: configargparse.ArgParser) -> None: # pragma: no cover - pass - - def get_name(self): + def args( + cls: type[TTrackObserverOptions], + args: configargparse.ArgParser, + ) -> None: # pragma: no cover + """Get args for Options.""" + + def get_name(self: Self) -> str: + """Get name.""" return self.name @abstractmethod - def track_started(self, track): # pragma: no cover - pass + def track_started(self: Self, track: Track) -> None: # pragma: no cover + """Track started.""" @abstractmethod - def track_finished(self, track): # pragma: no cover - pass + def track_finished(self: Self, track: Track) -> None: # pragma: no cover + """Track finished.""" diff --git a/nowplaying/track/observers/dab_audio_companion.py b/nowplaying/track/observers/dab_audio_companion.py index ee6b5744..67994321 100644 --- a/nowplaying/track/observers/dab_audio_companion.py +++ b/nowplaying/track/observers/dab_audio_companion.py @@ -1,3 +1,4 @@ +# mypy: ignore-errors import logging import urllib from datetime import timedelta @@ -48,7 +49,7 @@ def __init__(self, options: Options): self.dls_enabled = self.last_frame_was_dl_plus = self._options.dl_plus logger.info( "DAB+ Audio Companion initialised with URL: %s, DLS+ enabled: %r" - % (self.base_url, self.dls_enabled) + % (self.base_url, self.dls_enabled), ) def track_started(self, track: Track): @@ -61,9 +62,9 @@ def track_started(self, track: Track): if track.get_duration() < timedelta(seconds=5): logger.info( - "Track is less than 5 seconds, not sending to DAB+ Audio Companion" + "Track is less than 5 seconds, not sending to DAB+ Audio Companion", ) - return + return None if not track.has_default_title() and not track.has_default_artist(): params["artist"] = track.artist @@ -71,7 +72,7 @@ def track_started(self, track: Track): self.last_frame_was_dl_plus = True elif self.last_frame_was_dl_plus: logger.info( - "Track has default info, using show instead. Sending DLS+ delete tags." + "Track has default info, using show instead. Sending DLS+ delete tags.", ) message = DLPlusMessage() # track.artist contains station name if no artist is set @@ -91,7 +92,7 @@ def track_started(self, track: Track): logger.info( f"DAB+ Audio Companion URL: {self.base_url} " - f"data: {params} is DL+: {self.last_frame_was_dl_plus}" + f"data: {params} is DL+: {self.last_frame_was_dl_plus}", ) resp = requests.post(self.base_url, params) @@ -104,7 +105,7 @@ def _track_started_plain(self, track): if track.has_default_title() and track.has_default_artist(): logger.info( - "%s: Track has default info, using show instead" % self.__class__ + "%s: Track has default info, using show instead" % self.__class__, ) title = track.show.name @@ -112,7 +113,7 @@ def _track_started_plain(self, track): # artist is an unicode string which we have to encode into UTF-8 # http://bugs.python.org/issue216716 song_string = urllib.parse.quote_plus( - f"{track.artist.encode('utf8')} - {title.encode('utf8')}" + f"{track.artist.encode('utf8')} - {title.encode('utf8')}", ) update_url = f"{self.base_url}?dls={song_string}" diff --git a/nowplaying/track/observers/icecast.py b/nowplaying/track/observers/icecast.py index c1231df2..e6b79709 100644 --- a/nowplaying/track/observers/icecast.py +++ b/nowplaying/track/observers/icecast.py @@ -1,14 +1,25 @@ +"""Send PAD to icecast endpoints.""" + +from __future__ import annotations + import logging +from typing import TYPE_CHECKING, Self -import configargparse import requests -from ...util import parse_icecast_url -from ..track import Track -from .base import TrackObserver +from nowplaying.track.observers.base import TrackObserver +from nowplaying.util import parse_icecast_url + +if TYPE_CHECKING: # pragma: no cover + import configargparse # type: ignore[import-untyped] + + from nowplaying.track.observers.base import TTrackObserverOptions + from nowplaying.track.track import Track logger = logging.getLogger(__name__) +_NOWPLAYING_TRACK_EXEPTION = "request failed" + class IcecastTrackObserver(TrackObserver): """Update track metadata on an icecast mountpoint.""" @@ -19,18 +30,26 @@ class Options(TrackObserver.Options): """IcecastTrackObserver options.""" @classmethod - def args(cls, args: configargparse.ArgParser) -> None: - # TODO v3 remove this option + def args( + cls: type[TTrackObserverOptions], + args: configargparse.ArgParser, + ) -> None: + """Args for IcecastTrackObserver.""" + # TODO(hairmare): v3 remove this option + # https://github.com/radiorabe/nowplaying/issues/179 args.add_argument( "-m", "--icecast-base", - dest="icecastBase", + dest="icecast_base", help="Icecast base URL", default="http://icecast.example.org:8000/admin/", ) - # TODO v3 remove this option + # TODO(hairmare): v3 remove this option + # https://github.com/radiorabe/nowplaying/issues/179 args.add_argument( - "--icecast-password", dest="icecastPassword", help="Icecast Password" + "--icecast-password", + dest="icecast_password", + help="Icecast Password", ) args.add_argument( "-i", @@ -44,17 +63,20 @@ def args(cls, args: configargparse.ArgParser) -> None: ) def __init__( - self, + self: Self, url: str, username: str | None = None, password: str | None = None, mount: str | None = None, - ): - # TODO v3 remove optional args and only support parsed URLs + ) -> None: + """Create IcecastTrackObserver.Config.""" + # TODO(hairmare): v3 remove optional args and only support parsed URLs + # https://github.com/radiorabe/nowplaying/issues/179 (self.url, self.username, self.password, self.mount) = parse_icecast_url( - url + url, ) - # TODO v3 remove non URL usage of username, password, ... + # TODO(hairmare): v3 remove non URL usage of username, password, ... + # https://github.com/radiorabe/nowplaying/issues/179 if not self.username and username: # grab from args if not in URL logger.warning("deprecated use username from URL") @@ -72,17 +94,21 @@ def __init__( logger.warning("deprecated use mount from URL") self.mount = mount if not self.password: - raise ValueError("Missing required parameter password for %s" % url) + raise ValueError(f"Missing required parameter password for {url}") # noqa: EM102, TRY003 if not self.mount: - raise ValueError("Missing required parameter mount for %s" % url) + raise ValueError(f"Missing required parameter mount for {url}") # noqa: EM102, TRY003 - def __init__(self, options: Options): + def __init__(self: Self, options: Options) -> None: + """Create IcecastTrackObserver.""" self.options = options - logger.info(f"Icecast URL: {self.options.url} mount: {self.options.mount}") + logger.info("Icecast URL: %s mount: %s", self.options.url, self.options.mount) - def track_started(self, track: Track): + def track_started(self: Self, track: Track) -> None: + """Track started.""" logger.info( - f"Updating Icecast Metadata for track: {track.artist} - {track.title}" + "Updating Icecast Metadata for track: %s - %s", + track.artist, + track.title, ) title = track.title @@ -101,15 +127,19 @@ def track_started(self, track: Track): try: requests.get( self.options.url, - auth=(self.options.username, self.options.password), + auth=(self.options.username, self.options.password), # type: ignore[arg-type] params=params, + timeout=60, ) - except requests.exceptions.RequestException as e: - logger.exception(e) + except requests.exceptions.RequestException: + logger.exception(_NOWPLAYING_TRACK_EXEPTION) logger.info( - f"Icecast Metadata updated on {self.options.url} with data: {params}" + "Icecast Metadata updated on %s with data: %s", + self.options.url, + params, ) - def track_finished(self, track): - return True + def track_finished(self: Self, _: Track) -> None: + """Track finished.""" + return diff --git a/nowplaying/track/observers/scrobbler.py b/nowplaying/track/observers/scrobbler.py index 080712ca..db0aab0f 100644 --- a/nowplaying/track/observers/scrobbler.py +++ b/nowplaying/track/observers/scrobbler.py @@ -1,3 +1,4 @@ +# mypy: ignore-errors # TODO scrobbling is not currently supported # remove the no cover pragma from this file if you support it again import calendar # pragma: no cover @@ -41,7 +42,7 @@ def track_started(self, track): logger.info( "AS now-playing notification for track: %s - %s" - % (track.artist, track.title) + % (track.artist, track.title), ) # scrobble to all networks diff --git a/nowplaying/track/observers/smc_ftp.py b/nowplaying/track/observers/smc_ftp.py index 28fee051..3da6c276 100644 --- a/nowplaying/track/observers/smc_ftp.py +++ b/nowplaying/track/observers/smc_ftp.py @@ -1,15 +1,26 @@ +"""Upload PAD to SMC.""" + +from __future__ import annotations + import logging from datetime import timedelta from ftplib import FTP_TLS from io import BytesIO +from typing import TYPE_CHECKING, Self + +from nowplaying.track.observers.base import TrackObserver + +if TYPE_CHECKING: # pragma: no cover + import configargparse # type: ignore[import-untyped] -import configargparse + from nowplaying.track.observers.base import TTrackObserverOptions + from nowplaying.track.track import Track -from ..track import Track -from .base import TrackObserver logger = logging.getLogger(__name__) +_NOWPLAYING_DAB_MAXLEN = 128 + class SmcFtpTrackObserver(TrackObserver): """Update track metadata for DLS and DL+ to the SMC FTP server.""" @@ -17,8 +28,14 @@ class SmcFtpTrackObserver(TrackObserver): name = "SMC FTP" class Options(TrackObserver.Options): # pragma: no coverage + """Options for SmcFtpTrackObserver.""" + @classmethod - def args(cls, args: configargparse.ArgParser): + def args( + cls: type[TTrackObserverOptions], + args: configargparse.ArgParser, + ) -> None: + """Create args.""" args.add_argument( "--dab-smc", help="Enable SMC FTP delivery", @@ -35,35 +52,44 @@ def args(cls, args: configargparse.ArgParser): help="Username for SMC FTP server", ) args.add_argument( - "--dab-smc-ftp-password", help="Password for SMC FTP server" + "--dab-smc-ftp-password", + help="Password for SMC FTP server", ) - def __init__(self, hostname: str, username: str, password: str) -> None: + def __init__(self: Self, hostname: str, username: str, password: str) -> None: + """Create SmcFtpTrackObserver.Config.""" self.hostname: str = hostname self.username: str = username self.password: str = password - def __init__(self, options: Options): + def __init__(self: Self, options: Options) -> None: + """Create SmcFtpTrackObserver.""" self._options = options - def track_started(self, track: Track): - logger.info(f"Updating DAB+ DLS for track {track.artist=} {track.title=}") + def track_started(self: Self, track: Track) -> None: + """Track started.""" + logger.info( + "Updating DAB+ DLS for track artist=%s title=%s", + track.artist, + track.title, + ) if track.get_duration() < timedelta(seconds=5): logger.info( - "Track is less than 5 seconds, not sending to SMC" - f"{track.artist=} {track.title=}" + "Track is less than 5 seconds, not sending to SMC artist=%s title=%s", + track.artist, + track.title, ) return dls, dlplus = _dls_from_track(track) # check for too long meta and shorten to just artist - if dls.getbuffer().nbytes > 128: # pragma: no cover - logger.warning(f"SMC DLS to long {dls.getvalue().decode('latin1')=}") + if dls.getbuffer().nbytes > _NOWPLAYING_DAB_MAXLEN: # pragma: no cover + logger.warning("SMC DLS to long %s", dls.getvalue().decode("latin1")) dls, dlplus = _dls_from_track(track, title=False) - ftp = FTP_TLS() + ftp = FTP_TLS() # noqa: S321 ftp.connect(self._options.hostname) ftp.sendcmd(f"USER {self._options.username}") ftp.sendcmd(f"PASS {self._options.password}") @@ -75,16 +101,18 @@ def track_started(self, track: Track): ftp.close() logger.info( - f"SMC FTP {self._options.hostname=} " - f"{dls.getvalue().decode('latin1')=} " - f"{dlplus.getvalue().decode('latin1')=}" + "SMC FTP hostname=%s dls=%s dlsplus=%", + self._options.hostname, + dls.getvalue().decode("latin1"), + dlplus.getvalue().decode("latin1"), ) - def track_finished(self, track): - return True + def track_finished(self: Self, _: Track) -> None: + """Track finished.""" + return -def _dls_from_track(track: Track, title=True) -> (BytesIO, BytesIO): +def _dls_from_track(track: Track, *, title: bool = True) -> tuple[BytesIO, BytesIO]: # track.artist contains station name if no artist is set dls = f"{track.artist} - {track.show.name}" if title else track.artist dlplus = "" diff --git a/nowplaying/track/observers/ticker.py b/nowplaying/track/observers/ticker.py index 46619cbf..4ba15fc6 100644 --- a/nowplaying/track/observers/ticker.py +++ b/nowplaying/track/observers/ticker.py @@ -1,15 +1,24 @@ +"""TickerTrackObserver generates the songticker.xml file.""" + +from __future__ import annotations + import datetime import logging import uuid import warnings +from typing import TYPE_CHECKING, Self -import configargparse -import isodate -import lxml.builder +import isodate # type: ignore[import-untyped] +import lxml.builder # type: ignore[import-untyped] import lxml.etree import pytz -from .base import TrackObserver +from nowplaying.track.observers.base import TrackObserver, TTrackObserverOptions + +if TYPE_CHECKING: # pragma: no cover + import configargparse # type: ignore[import-untyped] + + from nowplaying.track.track import Track logger = logging.getLogger(__name__) @@ -28,7 +37,11 @@ class Options(TrackObserver.Options): """TickerTrackObserver options.""" @classmethod - def args(cls, args: configargparse.ArgParser) -> None: + def args( + cls: type[TTrackObserverOptions], + args: configargparse.ArgParser, + ) -> None: + """Build args.""" args.add_argument( "--xml-output", dest="tickerOutputFile", @@ -36,19 +49,25 @@ def args(cls, args: configargparse.ArgParser) -> None: default="/var/www/localhost/htdocs/songticker/0.9.3/current.xml", ) - def __init__(self, file_path: str): + def __init__(self: Self, file_path: str) -> None: + """Create TickerTrackObserver.Config.""" self.file_path = file_path - def __init__(self, options: Options): + def __init__(self: Self, options: Options) -> None: + """Create TickerTrackObserver.""" warnings.warn( "The XML ticker format will be replaced with a JSON variant in the future", PendingDeprecationWarning, + stacklevel=2, ) self.ticker_file_path = options.file_path - def track_started(self, track): + def track_started(self: Self, track: Track) -> None: + """Track started.""" logger.info( - f"Updating Ticker XML file for track: {track.artist} - {track.title}" + "Updating Ticker XML file for track: %s - %s", + track.artist, + track.title, ) try: tz = pytz.timezone("Europe/Zurich") @@ -59,11 +78,11 @@ def track_started(self, track): now = isodate.datetime_isoformat(datetime.datetime.now(tz)) - MAIN_NAMESPACE = "http://rabe.ch/schema/ticker.xsd" - XLINK_NAMESPACE = "http://www.w3.org/1999/xlink" - XLINK = "{%s}" % XLINK_NAMESPACE + MAIN_NAMESPACE = "http://rabe.ch/schema/ticker.xsd" # noqa: N806 + XLINK_NAMESPACE = "http://www.w3.org/1999/xlink" # noqa: N806 + XLINK = "{%s}" % XLINK_NAMESPACE # noqa: N806, UP031 - E = lxml.builder.ElementMaker( + E = lxml.builder.ElementMaker( # noqa: N806 namespace=MAIN_NAMESPACE, nsmap={None: MAIN_NAMESPACE, "xlink": XLINK_NAMESPACE}, ) @@ -73,17 +92,17 @@ def track_started(self, track): show_ref.attrib[XLINK + "show"] = "replace" ticker = E.ticker( - E.identifier("ticker-%s" % uuid.uuid4()), + E.identifier(f"ticker-{uuid.uuid4()}"), E.creator("now-playing daemon v2"), E.date(now), E.show( E.name(track.show.name), show_ref, E.startTime( - isodate.datetime_isoformat(track.show.starttime.astimezone(tz)) + isodate.datetime_isoformat(track.show.starttime.astimezone(tz)), ), E.endTime( - isodate.datetime_isoformat(track.show.endtime.astimezone(tz)) + isodate.datetime_isoformat(track.show.endtime.astimezone(tz)), ), id=track.show.uuid, ), @@ -103,5 +122,5 @@ def track_started(self, track): encoding="utf-8", ) - def track_finished(self, track): - return True + def track_finished(self: Self, _: Track) -> None: + """Track finished.""" diff --git a/nowplaying/track/track.py b/nowplaying/track/track.py index 4bce5c0e..3606bb5f 100644 --- a/nowplaying/track/track.py +++ b/nowplaying/track/track.py @@ -1,31 +1,39 @@ +"""Nowplaying Track model.""" + import datetime import logging import logging.handlers import uuid +from typing import Self import pytz +from nowplaying.show.show import Show + logger = logging.getLogger(__name__) DEFAULT_ARTIST = "Radio Bern" DEFAULT_TITLE = "Livestream" +_EXCEPTION_TRACK_ERROR_NUMBER_NOT_INT = "track number has to be a positive integer" +_EXCEPTION_TRACK_ERROR_STARTTIME_NO_DATETIME = "starttime has to be a datatime object" +_EXCEPTION_TRACK_ERROR_ENDTIME_NO_DATETIME = "endtime has to be a datatime object" + class TrackError(Exception): """Track related exception.""" - pass - class Track: """Track object which has a start and end time and a related show.""" - def __init__(self): - self.artist = None + def __init__(self: Self) -> None: + """Create Track object.""" + self.artist = "" - self.title = None + self.title = "" - self.album = None + self.album = "" self.track = 1 @@ -41,61 +49,63 @@ def __init__(self): # The show's end time, initially set to to now self.endtime = now - def set_artist(self, artist): + def set_artist(self: Self, artist: str) -> None: + """Set Track artist.""" self.artist = artist - def set_title(self, title): + def set_title(self: Self, title: str) -> None: + """Set Track title.""" self.title = title - def set_album(self, album): + def set_album(self: Self, album: str) -> None: + """Set Track album.""" self.album = album - def set_track(self, track): + def set_track(self: Self, track: int) -> None: + """Set Track number.""" if track < 0: - raise TrackError("track number has to be a positive integer") + raise TrackError(_EXCEPTION_TRACK_ERROR_NUMBER_NOT_INT) self.track = track - def set_starttime(self, starttime): + def set_starttime(self: Self, starttime: datetime.datetime) -> None: + """Set Track start time.""" if not isinstance(starttime, datetime.datetime): - raise TrackError("starttime has to be a datatime object") + raise TrackError(_EXCEPTION_TRACK_ERROR_STARTTIME_NO_DATETIME) # The track's start time as a datetime object self.starttime = starttime - def set_endtime(self, endtime): + def set_endtime(self: Self, endtime: datetime.datetime) -> None: + """Set Track end time.""" if not isinstance(endtime, datetime.datetime): - raise TrackError("endtime has to be a datatime object") + raise TrackError(_EXCEPTION_TRACK_ERROR_ENDTIME_NO_DATETIME) # The track's end time as a datetime object self.endtime = endtime - def set_duration(self, seconds): + def set_duration(self: Self, seconds: int) -> None: + """Set Track duration.""" self.endtime = self.starttime + datetime.timedelta(seconds=int(seconds)) - def set_show(self, show): - # if not isinstance(show, show.Show): - # raise TrackError('show has to be a Show object') - - # The show which the track is related to + def set_show(self: Self, show: Show) -> None: + """Set Show for Track.""" self.show = show - def get_duration(self): + def get_duration(self: Self) -> datetime.timedelta: + """Get duration of Track.""" return self.endtime - self.starttime - def has_default_artist(self): - if self.artist == DEFAULT_ARTIST: - return True - - return False - - def has_default_title(self): - if self.title == DEFAULT_TITLE: - return True + def has_default_artist(self: Self) -> bool: + """Return True if Track has default artist.""" + return self.artist == DEFAULT_ARTIST - return False + def has_default_title(self: Self) -> bool: + """Return True if Track has default title.""" + return self.title == DEFAULT_TITLE - def __str__(self): + def __str__(self: Self) -> str: + """Stringify Track.""" return ( f"Track '{self.artist}' - '{self.title}', " f"start: '{self.starttime}', end: '{self.endtime}', uid: {self.uuid}" diff --git a/nowplaying/util.py b/nowplaying/util.py index c15cbf31..bc745b01 100644 --- a/nowplaying/util.py +++ b/nowplaying/util.py @@ -1,3 +1,7 @@ +"""Utils for nowplaying.""" + +from __future__ import annotations + import logging from urllib.parse import parse_qs, urlparse @@ -25,13 +29,17 @@ def parse_icecast_url( ('https://localhost:443/', None, None, None) Args: + ---- url (str): The Icecast URL to parse. + Returns: + ------- Tuple[str, Optional[str], Optional[str], Optional[str]]: The URL, username, password, and mountpoint. + """ parsed = urlparse(url) - port = parsed.port or parsed.scheme == "https" and 443 or 80 + port = parsed.port or (parsed.scheme == "https" and 443) or 80 url = parsed._replace(query="", netloc=f"{parsed.hostname}:{port}").geturl() username = parsed.username password = parsed.password @@ -39,5 +47,5 @@ def parse_icecast_url( try: mount = parse_qs(parsed.query)["mount"][0] except KeyError: - logger.warning("Missing mount parameter in URL %s" % url) + logger.warning("Missing mount parameter in URL %s", url) return (url, username, password, mount) diff --git a/poetry.lock b/poetry.lock index e69a1034..94b2d1bd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "anyio" @@ -20,6 +20,25 @@ doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"] trio = ["trio (>=0.26.1)"] +[[package]] +name = "attrs" +version = "24.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, +] + +[package.extras] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] + [[package]] name = "autocommand" version = "2.2.2" @@ -45,50 +64,6 @@ files = [ [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] -[[package]] -name = "black" -version = "24.10.0" -description = "The uncompromising code formatter." -optional = false -python-versions = ">=3.9" -files = [ - {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, - {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, - {file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"}, - {file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"}, - {file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"}, - {file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"}, - {file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"}, - {file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"}, - {file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"}, - {file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"}, - {file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"}, - {file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"}, - {file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"}, - {file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"}, - {file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"}, - {file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"}, - {file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"}, - {file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"}, - {file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"}, - {file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"}, - {file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"}, - {file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"}, -] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=0.9.0" -platformdirs = ">=2" - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.10)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - [[package]] name = "certifi" version = "2024.8.30" @@ -436,6 +411,22 @@ files = [ python-dateutil = ">=2.4" typing-extensions = "*" +[[package]] +name = "filelock" +version = "3.16.1" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, + {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] +typing = ["typing-extensions (>=4.12.2)"] + [[package]] name = "ghp-import" version = "2.1.0" @@ -585,13 +576,13 @@ trio = ["trio (>=0.22.0,<1.0)"] [[package]] name = "httpx" -version = "0.27.2" +version = "0.28.0" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, - {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, + {file = "httpx-0.28.0-py3-none-any.whl", hash = "sha256:dc0b419a0cfeb6e8b34e85167c0da2671206f5095f1baa9663d23bcfd6b535fc"}, + {file = "httpx-0.28.0.tar.gz", hash = "sha256:0858d3bab51ba7e386637f22a61d8ccddaeec5f3fe4209da3a6168dbb91573e0"}, ] [package.dependencies] @@ -599,7 +590,6 @@ anyio = "*" certifi = "*" httpcore = "==1.*" idna = "*" -sniffio = "*" [package.extras] brotli = ["brotli", "brotlicffi"] @@ -667,20 +657,6 @@ files = [ {file = "isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6"}, ] -[[package]] -name = "isort" -version = "5.13.2" -description = "A Python utility / library to sort Python imports." -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, - {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, -] - -[package.extras] -colors = ["colorama (>=0.4.6)"] - [[package]] name = "jaraco-collections" version = "5.1.0" @@ -933,6 +909,20 @@ html5 = ["html5lib"] htmlsoup = ["BeautifulSoup4"] source = ["Cython (>=3.0.11)"] +[[package]] +name = "lxml-stubs" +version = "0.5.1" +description = "Type annotations for the lxml package" +optional = false +python-versions = "*" +files = [ + {file = "lxml-stubs-0.5.1.tar.gz", hash = "sha256:e0ec2aa1ce92d91278b719091ce4515c12adc1d564359dfaf81efa7d4feab79d"}, + {file = "lxml_stubs-0.5.1-py3-none-any.whl", hash = "sha256:1f689e5dbc4b9247cb09ae820c7d34daeb1fdbd1db06123814b856dae7787272"}, +] + +[package.extras] +test = ["coverage[toml] (>=7.2.5)", "mypy (>=1.2.0)", "pytest (>=7.3.0)", "pytest-mypy-plugins (>=1.10.1)"] + [[package]] name = "markdown" version = "3.7" @@ -1237,6 +1227,58 @@ files = [ {file = "mutagen-1.47.0.tar.gz", hash = "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99"}, ] +[[package]] +name = "mypy" +version = "1.13.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, + {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, + {file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"}, + {file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"}, + {file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"}, + {file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"}, + {file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"}, + {file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"}, + {file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"}, + {file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"}, + {file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"}, + {file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"}, + {file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"}, + {file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"}, + {file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"}, + {file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"}, + {file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"}, + {file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"}, + {file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"}, + {file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"}, + {file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"}, + {file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"}, + {file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"}, + {file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"}, + {file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"}, + {file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"}, + {file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"}, + {file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"}, + {file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"}, + {file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"}, + {file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"}, + {file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +typing-extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + [[package]] name = "mypy-extensions" version = "1.0.0" @@ -1480,41 +1522,24 @@ testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", [[package]] name = "protobuf" -version = "5.28.3" +version = "5.29.0" description = "" optional = false python-versions = ">=3.8" files = [ - {file = "protobuf-5.28.3-cp310-abi3-win32.whl", hash = "sha256:0c4eec6f987338617072592b97943fdbe30d019c56126493111cf24344c1cc24"}, - {file = "protobuf-5.28.3-cp310-abi3-win_amd64.whl", hash = "sha256:91fba8f445723fcf400fdbe9ca796b19d3b1242cd873907979b9ed71e4afe868"}, - {file = "protobuf-5.28.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a3f6857551e53ce35e60b403b8a27b0295f7d6eb63d10484f12bc6879c715687"}, - {file = "protobuf-5.28.3-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:3fa2de6b8b29d12c61911505d893afe7320ce7ccba4df913e2971461fa36d584"}, - {file = "protobuf-5.28.3-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:712319fbdddb46f21abb66cd33cb9e491a5763b2febd8f228251add221981135"}, - {file = "protobuf-5.28.3-cp38-cp38-win32.whl", hash = "sha256:3e6101d095dfd119513cde7259aa703d16c6bbdfae2554dfe5cfdbe94e32d548"}, - {file = "protobuf-5.28.3-cp38-cp38-win_amd64.whl", hash = "sha256:27b246b3723692bf1068d5734ddaf2fccc2cdd6e0c9b47fe099244d80200593b"}, - {file = "protobuf-5.28.3-cp39-cp39-win32.whl", hash = "sha256:135658402f71bbd49500322c0f736145731b16fc79dc8f367ab544a17eab4535"}, - {file = "protobuf-5.28.3-cp39-cp39-win_amd64.whl", hash = "sha256:70585a70fc2dd4818c51287ceef5bdba6387f88a578c86d47bb34669b5552c36"}, - {file = "protobuf-5.28.3-py3-none-any.whl", hash = "sha256:cee1757663fa32a1ee673434fcf3bf24dd54763c79690201208bafec62f19eed"}, - {file = "protobuf-5.28.3.tar.gz", hash = "sha256:64badbc49180a5e401f373f9ce7ab1d18b63f7dd4a9cdc43c92b9f0b481cef7b"}, + {file = "protobuf-5.29.0-cp310-abi3-win32.whl", hash = "sha256:ea7fb379b257911c8c020688d455e8f74efd2f734b72dc1ea4b4d7e9fd1326f2"}, + {file = "protobuf-5.29.0-cp310-abi3-win_amd64.whl", hash = "sha256:34a90cf30c908f47f40ebea7811f743d360e202b6f10d40c02529ebd84afc069"}, + {file = "protobuf-5.29.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:c931c61d0cc143a2e756b1e7f8197a508de5365efd40f83c907a9febf36e6b43"}, + {file = "protobuf-5.29.0-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:85286a47caf63b34fa92fdc1fd98b649a8895db595cfa746c5286eeae890a0b1"}, + {file = "protobuf-5.29.0-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:0d10091d6d03537c3f902279fcf11e95372bdd36a79556311da0487455791b20"}, + {file = "protobuf-5.29.0-cp38-cp38-win32.whl", hash = "sha256:0cd67a1e5c2d88930aa767f702773b2d054e29957432d7c6a18f8be02a07719a"}, + {file = "protobuf-5.29.0-cp38-cp38-win_amd64.whl", hash = "sha256:e467f81fdd12ded9655cea3e9b83dc319d93b394ce810b556fb0f421d8613e86"}, + {file = "protobuf-5.29.0-cp39-cp39-win32.whl", hash = "sha256:17d128eebbd5d8aee80300aed7a43a48a25170af3337f6f1333d1fac2c6839ac"}, + {file = "protobuf-5.29.0-cp39-cp39-win_amd64.whl", hash = "sha256:6c3009e22717c6cc9e6594bb11ef9f15f669b19957ad4087214d69e08a213368"}, + {file = "protobuf-5.29.0-py3-none-any.whl", hash = "sha256:88c4af76a73183e21061881360240c0cdd3c39d263b4e8fb570aaf83348d608f"}, + {file = "protobuf-5.29.0.tar.gz", hash = "sha256:445a0c02483869ed8513a585d80020d012c6dc60075f96fa0563a724987b1001"}, ] -[[package]] -name = "pydocstyle" -version = "6.3.0" -description = "Python docstring style checker" -optional = false -python-versions = ">=3.6" -files = [ - {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"}, - {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"}, -] - -[package.dependencies] -snowballstemmer = ">=2.2.0" - -[package.extras] -toml = ["tomli (>=1.2.3)"] - [[package]] name = "pygments" version = "2.18.0" @@ -1586,22 +1611,39 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-cov" -version = "6.0.0" +version = "5.0.0" description = "Pytest plugin for measuring coverage." optional = false -python-versions = ">=3.9" +python-versions = ">=3.8" files = [ - {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, - {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, ] [package.dependencies] -coverage = {version = ">=7.5", extras = ["toml"]} +coverage = {version = ">=5.2.1", extras = ["toml"]} pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] +[[package]] +name = "pytest-mypy" +version = "0.10.3" +description = "Mypy static type checker plugin for Pytest" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pytest-mypy-0.10.3.tar.gz", hash = "sha256:f8458f642323f13a2ca3e2e61509f7767966b527b4d8adccd5032c3e7b4fd3db"}, + {file = "pytest_mypy-0.10.3-py3-none-any.whl", hash = "sha256:7638d0d3906848fc1810cb2f5cc7fceb4cc5c98524aafcac58f28620e3102053"}, +] + +[package.dependencies] +attrs = ">=19.0" +filelock = ">=3.0" +mypy = {version = ">=0.900", markers = "python_version >= \"3.11\""} +pytest = {version = ">=6.2", markers = "python_version >= \"3.10\""} + [[package]] name = "pytest-random-order" version = "1.1.1" @@ -1892,29 +1934,29 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "ruff" -version = "0.8.1" +version = "0.8.0" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.8.1-py3-none-linux_armv6l.whl", hash = "sha256:fae0805bd514066f20309f6742f6ee7904a773eb9e6c17c45d6b1600ca65c9b5"}, - {file = "ruff-0.8.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8a4f7385c2285c30f34b200ca5511fcc865f17578383db154e098150ce0a087"}, - {file = "ruff-0.8.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cd054486da0c53e41e0086e1730eb77d1f698154f910e0cd9e0d64274979a209"}, - {file = "ruff-0.8.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2029b8c22da147c50ae577e621a5bfbc5d1fed75d86af53643d7a7aee1d23871"}, - {file = "ruff-0.8.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2666520828dee7dfc7e47ee4ea0d928f40de72056d929a7c5292d95071d881d1"}, - {file = "ruff-0.8.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:333c57013ef8c97a53892aa56042831c372e0bb1785ab7026187b7abd0135ad5"}, - {file = "ruff-0.8.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:288326162804f34088ac007139488dcb43de590a5ccfec3166396530b58fb89d"}, - {file = "ruff-0.8.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b12c39b9448632284561cbf4191aa1b005882acbc81900ffa9f9f471c8ff7e26"}, - {file = "ruff-0.8.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:364e6674450cbac8e998f7b30639040c99d81dfb5bbc6dfad69bc7a8f916b3d1"}, - {file = "ruff-0.8.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b22346f845fec132aa39cd29acb94451d030c10874408dbf776af3aaeb53284c"}, - {file = "ruff-0.8.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2f2f7a7e7648a2bfe6ead4e0a16745db956da0e3a231ad443d2a66a105c04fa"}, - {file = "ruff-0.8.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:adf314fc458374c25c5c4a4a9270c3e8a6a807b1bec018cfa2813d6546215540"}, - {file = "ruff-0.8.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a885d68342a231b5ba4d30b8c6e1b1ee3a65cf37e3d29b3c74069cdf1ee1e3c9"}, - {file = "ruff-0.8.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d2c16e3508c8cc73e96aa5127d0df8913d2290098f776416a4b157657bee44c5"}, - {file = "ruff-0.8.1-py3-none-win32.whl", hash = "sha256:93335cd7c0eaedb44882d75a7acb7df4b77cd7cd0d2255c93b28791716e81790"}, - {file = "ruff-0.8.1-py3-none-win_amd64.whl", hash = "sha256:2954cdbe8dfd8ab359d4a30cd971b589d335a44d444b6ca2cb3d1da21b75e4b6"}, - {file = "ruff-0.8.1-py3-none-win_arm64.whl", hash = "sha256:55873cc1a473e5ac129d15eccb3c008c096b94809d693fc7053f588b67822737"}, - {file = "ruff-0.8.1.tar.gz", hash = "sha256:3583db9a6450364ed5ca3f3b4225958b24f78178908d5c4bc0f46251ccca898f"}, + {file = "ruff-0.8.0-py3-none-linux_armv6l.whl", hash = "sha256:fcb1bf2cc6706adae9d79c8d86478677e3bbd4ced796ccad106fd4776d395fea"}, + {file = "ruff-0.8.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:295bb4c02d58ff2ef4378a1870c20af30723013f441c9d1637a008baaf928c8b"}, + {file = "ruff-0.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b1f1c76b47c18fa92ee78b60d2d20d7e866c55ee603e7d19c1e991fad933a9a"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb0d4f250a7711b67ad513fde67e8870109e5ce590a801c3722580fe98c33a99"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e55cce9aa93c5d0d4e3937e47b169035c7e91c8655b0974e61bb79cf398d49c"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f4cd64916d8e732ce6b87f3f5296a8942d285bbbc161acee7fe561134af64f9"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c5c1466be2a2ebdf7c5450dd5d980cc87c8ba6976fb82582fea18823da6fa362"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2dabfd05b96b7b8f2da00d53c514eea842bff83e41e1cceb08ae1966254a51df"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:facebdfe5a5af6b1588a1d26d170635ead6892d0e314477e80256ef4a8470cf3"}, + {file = "ruff-0.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87a8e86bae0dbd749c815211ca11e3a7bd559b9710746c559ed63106d382bd9c"}, + {file = "ruff-0.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85e654f0ded7befe2d61eeaf3d3b1e4ef3894469cd664ffa85006c7720f1e4a2"}, + {file = "ruff-0.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:83a55679c4cb449fa527b8497cadf54f076603cc36779b2170b24f704171ce70"}, + {file = "ruff-0.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:812e2052121634cf13cd6fddf0c1871d0ead1aad40a1a258753c04c18bb71bbd"}, + {file = "ruff-0.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:780d5d8523c04202184405e60c98d7595bdb498c3c6abba3b6d4cdf2ca2af426"}, + {file = "ruff-0.8.0-py3-none-win32.whl", hash = "sha256:5fdb6efecc3eb60bba5819679466471fd7d13c53487df7248d6e27146e985468"}, + {file = "ruff-0.8.0-py3-none-win_amd64.whl", hash = "sha256:582891c57b96228d146725975fbb942e1f30a0c4ba19722e692ca3eb25cc9b4f"}, + {file = "ruff-0.8.0-py3-none-win_arm64.whl", hash = "sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6"}, + {file = "ruff-0.8.0.tar.gz", hash = "sha256:a7ccfe6331bf8c8dad715753e157457faf7351c2b69f62f32c165c2dbcbacd44"}, ] [[package]] @@ -1959,17 +2001,6 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] -[[package]] -name = "snowballstemmer" -version = "2.2.0" -description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." -optional = false -python-versions = "*" -files = [ - {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, - {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, -] - [[package]] name = "tempora" version = "5.7.0" @@ -2000,6 +2031,31 @@ files = [ {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, ] +[[package]] +name = "types-pytz" +version = "2024.2.0.20241003" +description = "Typing stubs for pytz" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-pytz-2024.2.0.20241003.tar.gz", hash = "sha256:575dc38f385a922a212bac00a7d6d2e16e141132a3c955078f4a4fd13ed6cb44"}, + {file = "types_pytz-2024.2.0.20241003-py3-none-any.whl", hash = "sha256:3e22df1336c0c6ad1d29163c8fda82736909eb977281cb823c57f8bae07118b7"}, +] + +[[package]] +name = "types-requests" +version = "2.32.0.20241016" +description = "Typing stubs for requests" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-requests-2.32.0.20241016.tar.gz", hash = "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95"}, + {file = "types_requests-2.32.0.20241016-py3-none-any.whl", hash = "sha256:4195d62d6d3e043a4eaaf08ff8a62184584d2e8684e9d2aa178c7915a7da3747"}, +] + +[package.dependencies] +urllib3 = ">=2" + [[package]] name = "typing-extensions" version = "4.12.2" @@ -2211,4 +2267,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "e37a2102f1ae29a290578bb45b5a7769e7e187cbb3dc40ee91a9f5de96239c01" +content-hash = "53c568c3719cb8e254a256608663b3b65e66711ab2a6ee5f72b43f2abf1392be" diff --git a/pyproject.toml b/pyproject.toml index 63262e24..22f1a50a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ classifiers = [ ] readme = "README.md" packages = [ - { include = "nowplaying"}, + { include = "nowplaying" }, ] [tool.poetry.scripts] @@ -35,14 +35,6 @@ opentelemetry-exporter-otlp = "^1.18.0" opentelemetry-sdk = "^1.18.0" [tool.poetry.group.dev.dependencies] -black = ">=23.1,<25.0" -isort = "^5.12.0" -pydocstyle = "^6.1.1" -pytest = ">=7.2.3,<9.0.0" -pytest-cov = ">=4,<7" -pytest-random-order = "^1.1.0" -pytest-ruff = ">=0.4.0,<0.5" -ruff = ">=0.8.0,<0.8.2" faker = ">=26.0.0,<34.0.0" mkdocs = "^1.5.3" mkdocs-material = "^9.4.7" @@ -51,14 +43,19 @@ mkdocs-gen-files = "^0.5.0" mkdocs-literate-nav = "^0.6.1" mkdocs-section-index = "^0.3.8" mkdocstrings-python = "^1.7.3" - -[tool.isort] -line_length = 120 -profile = "black" +pytest = ">=7.2.3,<9.0.0" +pytest-cov = ">=4,<6" +pytest-mypy = "^0.10.3" +pytest-random-order = "^1.1.0" +pytest-ruff = ">=0.4.1,<0.5" +ruff = ">=0.8.0,<0.8.1" +types-requests = "^2.31.0.20240310" +types-pytz = "^2024.1.0.20240203" +lxml-stubs = "^0.5.1" [tool.pytest.ini_options] minversion = "7.2" -addopts = "-ra -q --random-order --doctest-glob='*.md' --doctest-modules --cov=nowplaying --cov-fail-under=100 --ruff --ignore docs/" +addopts = "-ra -q --random-order --doctest-glob='*.md' --doctest-modules --cov=nowplaying --cov-fail-under=100 --ruff --ruff-format --mypy --ignore docs/" filterwarnings = [ "ignore::DeprecationWarning:cairosvg", "ignore::DeprecationWarning:cherrypy", @@ -67,6 +64,13 @@ filterwarnings = [ "ignore::DeprecationWarning:pkg_resources", ] +[tool.ruff] +extend-exclude = [ + "nowplaying/misc/saemubox.py", + "nowplaying/track/observers/scrobbler.py", + "nowplaying/track/observers/dab_audio_companion.py", +] + [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..fd15985d --- /dev/null +++ b/ruff.toml @@ -0,0 +1,78 @@ +# [ruff](https://docs.astral.sh/ruff/) config +# +# templated with https://github.com/radiorabe/backstage-software-templates + +extend = "./pyproject.toml" + +[lint] +select = [ + "F", # pyflakes + "E", # pycodestyle errors + "I", # isort + "C90", # mccabe + "N", # pep8-naming + "D", # pydocstyle + "UP", # pyupgrade + "ANN", # flake8-annotations + "ASYNC", # flake8-async + "S", # flake8-bandit + "BLE", # flake8-blind-exception + "FBT", # flake8-boolean-trap + "B", # flake8-bugbear + "A", # flake8-builtins + "COM", # flake8-commas + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "T10", # flake8-debugger + "EM", # flake8-errmsg + "EXE", # flake8-executable + "FA", # flake8-future-annotations + "ISC", # flake8-implicit-str-concat + "ICN", # flake8-import-conventions + "G", # flake8-logging-format + "INP", # flake8-no-pep420 + "PIE", # flake8-pie + "T20", # flake8-print + "PYI", # flake8-pyi + "PT", # flake8-pytest-style + "Q", # flake8-quotes + "RSE", # flake8-raise + "RET", # flake8-return + "SLF", # flake8-self + "SLOT", # flake8-slots + "SIM", # flake8-simplify + "TID", # flake8-tidy-imports + "TCH", # flake8-type-checking + "INT", # flake8-gettext + "ARG", # flake8-unused-arguments + "PTH", # flake8-use-pathlib + "TD", # flake8-todos + "ERA", # eradicate + "PGH", # pygrep-hooks + "PL", # Pylint + "TRY", # tryceratops + "PERF", # Perflint + "RUF", # ruff specific rules +] +ignore = [ + "D203", # we prefer blank-line-before-class (D211) for black compat + "D213", # we prefer multi-line-summary-first-line (D212) + "COM812", # ignore due to conflict with formatter + "ISC001", # ignore due to conflict with formatter +] + +[lint.per-file-ignores] +"tests/**/*.py" = [ + "D", # pydocstyle is optional for tests + "ANN", # flake8-annotations are optional for tests + "S101", # assert is allow in tests + "S108", # /tmp is allowed in tests since it's expected to be mocked + "DTZ001", # tests often run in UTC + "INP001", # tests do not need a dunder init +] +"**/__init__.py" = [ + "D104", # dunder init does not need a docstring because it might be empty +] +"docs/gen_ref_pages.py" = [ + "INP001", # mkdocs does not need a dunder init +] diff --git a/tests/conftest.py b/tests/conftest.py index de10fbb0..b321983b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,9 @@ from werkzeug.wrappers import Response from nowplaying.api import ApiServer +from nowplaying.show.show import Show +from nowplaying.track.observers.base import TrackObserver +from nowplaying.track.track import Track class AuthenticatedClient(Client): @@ -45,19 +48,64 @@ def fixture_users(user, password): @pytest.fixture(name="options") def fixture_options(users): return SimpleNamespace( - **{ - "apiAuthUsers": users, - } + api_auth_users=users, ) @pytest.fixture(name="unauthenticated_client") def fixture_unauthenticated_client(options): """Create a test client.""" - yield Client(ApiServer(options, event_queue=Queue()), Response) + return Client(ApiServer(options, event_queue=Queue()), Response) @pytest.fixture(name="client") def fixture_client(options, user, password): """Create a test client.""" - yield AuthenticatedClient(ApiServer(options, event_queue=Queue()), user, password) + return AuthenticatedClient(ApiServer(options, event_queue=Queue()), user, password) + + +def new_show(name="Hairmare Traveling Medicine Show"): + s = Show() + s.set_name(name) + return s + + +@pytest.fixture +def show_factory(): + """Return a method to help creating new show objects for tests.""" + return new_show + + +def new_track( + artist="Hairmare and the Band", + title="An Ode to legacy Python Code", + album="Live at the Refactoring Club", + duration=128, +): + t = Track() + t.set_artist(artist) + t.set_title(title) + t.set_album(album) + t.set_duration(duration) + return t + + +@pytest.fixture +def track_factory(): + """Return a method to help creating new track objects for tests.""" + return new_track + + +class DummyObserver(TrackObserver): + """Shunt class for testing the abstract TrackObserver.""" + + def track_started(self, track): + pass + + def track_finished(self, track): + pass + + +@pytest.fixture +def dummy_observer(): + return DummyObserver() diff --git a/tests/test_api.py b/tests/test_api.py index e53805d9..d99e5aec 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -21,18 +21,16 @@ def test_run_server_with_debug(mock_run_simple, users): mock_run_simple.side_effect = None options = SimpleNamespace( - **{ - "apiBindAddress": "0.0.0.0", - "apiPort": 8080, - "apiAuthUsers": users, - "debug": True, - } + api_bind_address="127.0.0.1", + api_port=8080, + api_auth_users=users, + debug=True, ) server = ApiServer(options, event_queue=Queue()) server.run_server() mock_run_simple.assert_called_once_with( - options.apiBindAddress, - options.apiPort, + options.api_bind_address, + options.api_port, mock.ANY, use_debugger=True, use_reloader=True, @@ -45,7 +43,7 @@ def test_stop_server(mock_server, mock_stop, options): """Test the stop_server function.""" api = ApiServer(options, event_queue=Queue()) - api._server = mock_server + api._server = mock_server # noqa: SLF001 api.stop_server() mock_server.stop.assert_called_once_with() @@ -59,7 +57,7 @@ def test_webhook_no_supported_header(client, content_type): if content_type: headers["Content-Type"] = content_type resp = client.post(_WEBHOOK_ENDPOINT, headers=headers, data="{}") - assert resp.status_code == 415 + assert resp.status_code == 415 # noqa: PLR2004 assert ( resp.data.decode("utf-8") == '"The server does not support the media type transmitted in the request."' @@ -67,18 +65,21 @@ def test_webhook_no_supported_header(client, content_type): @pytest.mark.parametrize( - "content_type", [_CONTENT_TYPE_JSON, _CONTENT_TYPE_CLOUDEVENTS] + "content_type", + [_CONTENT_TYPE_JSON, _CONTENT_TYPE_CLOUDEVENTS], ) def test_webhook_invalid_body(client, content_type): """Test the webhook function with invalid JSON.""" body = "invalid-json" resp = client.post( - _WEBHOOK_ENDPOINT, data=body, headers={"Content-Type": content_type} + _WEBHOOK_ENDPOINT, + data=body, + headers={"Content-Type": content_type}, ) - assert resp.status_code == 400 + assert resp.status_code == 400 # noqa: PLR2004 assert resp.data.decode("utf-8") == json.dumps( "Failed to read specversion from both headers and data. " - "The following can not be parsed as json: b'invalid-json'" + "The following can not be parsed as json: b'invalid-json'", ) @@ -91,19 +92,21 @@ def test_webhook_invalid_id_in_payload(client): "source": "https://rabe.ch", # a RaBe CRID must use "rabe.ch" so this is invalid "id": "crid://example.com/v1#t=code=19930301T131200.00Z", - } + }, ) resp = client.post( - _WEBHOOK_ENDPOINT, data=body, headers={"Content-Type": _CONTENT_TYPE_JSON} + _WEBHOOK_ENDPOINT, + data=body, + headers={"Content-Type": _CONTENT_TYPE_JSON}, ) - assert resp.status_code == 400 + assert resp.status_code == 400 # noqa: PLR2004 assert resp.data.decode("utf-8") == json.dumps( - "CRID 'crid://example.com/v1#t=code=19930301T131200.00Z' is not a RaBe CRID" + "CRID 'crid://example.com/v1#t=code=19930301T131200.00Z' is not a RaBe CRID", ) @pytest.mark.parametrize( - "content_type,body,expected_status", + ("content_type", "body", "expected_status"), [ ( _CONTENT_TYPE_JSON, @@ -125,14 +128,17 @@ def test_webhook_invalid_id_in_payload(client): def test_webhook_invalid_event(client, content_type, body, expected_status): """Test the webhook function.""" resp = client.post( - _WEBHOOK_ENDPOINT, data=json.dumps(body), headers={"Content-Type": content_type} + _WEBHOOK_ENDPOINT, + data=json.dumps(body), + headers={"Content-Type": content_type}, ) - assert resp.status_code == 400 + assert resp.status_code == 400 # noqa: PLR2004 assert expected_status in resp.data.decode("utf-8") @pytest.mark.parametrize( - "content_type", [_CONTENT_TYPE_JSON, _CONTENT_TYPE_CLOUDEVENTS] + "content_type", + [_CONTENT_TYPE_JSON, _CONTENT_TYPE_CLOUDEVENTS], ) def test_webhook_valid_event(client, content_type): """Test the webhook function.""" @@ -142,13 +148,15 @@ def test_webhook_valid_event(client, content_type): "type": "ch.rabe.api.events.track.v1.trackStarted", "source": "https://rabe.ch", "id": "crid://rabe.ch/v1#t=clock=19930301T131200.00Z", - } + }, ) assert client.application.event_queue.qsize() == 0 resp = client.post( - _WEBHOOK_ENDPOINT, data=body, headers={"Content-Type": content_type} + _WEBHOOK_ENDPOINT, + data=body, + headers={"Content-Type": content_type}, ) - assert resp.status_code == 200 + assert resp.status_code == 200 # noqa: PLR2004 assert resp.status == "200 Event Received" assert client.application.event_queue.qsize() == 1 event = client.application.event_queue.get() @@ -160,7 +168,9 @@ def test_webhook_valid_event(client, content_type): def test_webhook_auth_fail(unauthenticated_client): """Test the webhook function.""" resp = unauthenticated_client.post( - _WEBHOOK_ENDPOINT, data="{}", headers={"Content-Type": _CONTENT_TYPE_JSON} + _WEBHOOK_ENDPOINT, + data="{}", + headers={"Content-Type": _CONTENT_TYPE_JSON}, ) - assert resp.status_code == 401 + assert resp.status_code == 401 # noqa: PLR2004 assert resp.status == "401 UNAUTHORIZED" diff --git a/tests/test_daemon.py b/tests/test_daemon.py index e2118eb8..35fb7e92 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -35,10 +35,10 @@ def test_signal_handler(mock_sys_exit, options): with patch.object(SaemuBox, "__init__", lambda *_: None): nowplaying_daemon = NowPlayingDaemon(options) - nowplaying_daemon._api = Mock() + nowplaying_daemon._api = Mock() # noqa: SLF001 nowplaying_daemon.signal_handler(SIGINT, None) - nowplaying_daemon._api.stop_server.assert_called_once() + nowplaying_daemon._api.stop_server.assert_called_once() # noqa: SLF001 mock_sys_exit.assert_called_with(EX_OK) @@ -49,6 +49,6 @@ def test__start_apiserver(mock_run_server, options): with patch.object(SaemuBox, "__init__", lambda *_: None): daemon = NowPlayingDaemon(options) - daemon._start_apiserver() + daemon._start_apiserver() # noqa: SLF001 mock_run_server.assert_called_with() diff --git a/tests/test_input_handler.py b/tests/test_input_handler.py index cfbcdb07..72547ed8 100644 --- a/tests/test_input_handler.py +++ b/tests/test_input_handler.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from queue import Queue from cloudevents.http.event import CloudEvent @@ -12,20 +14,20 @@ def __init__(self): self.event_queue = Queue() self.update_call = None - def update(self, saemubox_id: int, event: CloudEvent = None): + def update(self, saemubox_id: int, event: CloudEvent | None = None): self.update_call = (saemubox_id, event) - def handles(self, event: CloudEvent) -> bool: - return super().handles(event) + def handles(self, event: CloudEvent) -> bool: # noqa: ARG002 + return True def event(self, event: CloudEvent): - return super().event(event) + pass - def handle_id(self, saemubox_id: int, event: CloudEvent = None): - return super().handle_id(saemubox_id, event=event) + def handle_id(self, saemubox_id: int, event: CloudEvent | None = None): + pass - def handle(self, event: CloudEvent = None): - return super().handle(event) + def handle(self, event: CloudEvent | None = None): + pass def test_register_observer(): @@ -33,7 +35,7 @@ def test_register_observer(): handler = InputHandler() observer = ShuntInputObserver() handler.register_observer(observer) - assert observer in handler._observers + assert observer in handler._observers # noqa: SLF001 def test_remove_observer(): @@ -42,7 +44,7 @@ def test_remove_observer(): observer = ShuntInputObserver() handler.register_observer(observer) handler.remove_observer(observer) - assert observer not in handler._observers + assert observer not in handler._observers # noqa: SLF001 def test_update(): diff --git a/tests/test_input_observer.py b/tests/test_input_observer.py index 8b02f417..875df8a4 100644 --- a/tests/test_input_observer.py +++ b/tests/test_input_observer.py @@ -1,13 +1,13 @@ import pytest from cloudevents.http.event import CloudEvent -from isodate import parse_datetime +from isodate import parse_datetime # type: ignore[import-untyped] from nowplaying.input.observer import KlangbeckenInputObserver from nowplaying.track.handler import TrackEventHandler @pytest.mark.parametrize( - "source, expected", + ("source", "expected"), [ ("https://github/radiorabe/klangbecken", True), ("https://github/radiorabe/something-that-is-not-klangbecken", False), @@ -42,7 +42,6 @@ def test_klangbecken_input_observer_event(): data={"item.artist": "artist", "item.title": "title"}, ) observer.event(event) - # TODO assert something def test_klangbecken_input_observer_parse_event(): diff --git a/tests/test_input_observer_base.py b/tests/test_input_observer_base.py index 44899d52..0d450603 100644 --- a/tests/test_input_observer_base.py +++ b/tests/test_input_observer_base.py @@ -1,21 +1,28 @@ -from cloudevents.http.event import CloudEvent +from __future__ import annotations + +from typing import TYPE_CHECKING from nowplaying.input.observer import InputObserver +if TYPE_CHECKING: + from cloudevents.http.event import CloudEvent + class ShuntObserver(InputObserver): - def handles(self, event: CloudEvent): + def handles(self, _: CloudEvent): return True - def event(self, event: CloudEvent): - return super().event(event) + def event(self, event: CloudEvent): ... - def handle_id(self, saemubox_id: int, event: CloudEvent): + def handle_id( + self, + saemubox_id: int, # noqa: ARG002 + event: CloudEvent | None = None, # noqa: ARG002 + ) -> bool: return True - def handle(self, event: CloudEvent): + def handle(self, event: CloudEvent | None = None) -> None: # noqa: ARG002 self.handle_called = True - return super().event(event) def test_init(): diff --git a/tests/test_input_observer_klangbecken.py b/tests/test_input_observer_klangbecken.py index ae420ad8..8134f611 100644 --- a/tests/test_input_observer_klangbecken.py +++ b/tests/test_input_observer_klangbecken.py @@ -31,7 +31,8 @@ def test_init(): @patch("nowplaying.show.client.ShowClient.get_show_info") @pytest.mark.parametrize( - "saemubox_id,expected", [(1, True), (2, False), (0, False), (-1, False)] + ("saemubox_id", "expected"), + [(1, True), (2, False), (0, False), (-1, False)], ) def test_handle_id(mock_get_show_info, saemubox_id, expected, event: CloudEvent): show_url = "http://www.rabe.ch/klangbecken/" @@ -65,11 +66,12 @@ def test_handle(mock_get_show_info): def test_parse_event(event: CloudEvent): expected_track = Track() - expected_track.artist = "Peaches" - expected_track.title = "Fuck the Pain Away" + expected_track.set_artist("Peaches") + expected_track.set_title("Fuck the Pain Away") observer = KlangbeckenInputObserver( - "http://example.org/klangbecken/", "tests/fixtures/now-playing.xml" + "http://example.org/klangbecken/", + "tests/fixtures/now-playing.xml", ) track = observer.parse_event(event) diff --git a/tests/test_main.py b/tests/test_main.py index f3e3672a..cbe27a0c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -23,7 +23,7 @@ def test_run( now_playing.run() mock_setup_otel.assert_called_once() - mock_setdefaulttimeout.assert_called_once_with(Options.socketDefaultTimeout) + mock_setdefaulttimeout.assert_called_once_with(Options.socket_default_timeout) mock_run_daemon.assert_called_once() @@ -36,7 +36,7 @@ def test_run_daemon(mock_daemon): now_playing = NowPlaying() now_playing.options = options - now_playing._run_daemon() + now_playing._run_daemon() # noqa: SLF001 mock_daemon.assert_called_once_with(options) mock_run.main.assert_called_once() diff --git a/tests/test_show_client.py b/tests/test_show_client.py index c6467189..26f143ae 100644 --- a/tests/test_show_client.py +++ b/tests/test_show_client.py @@ -2,6 +2,7 @@ import json from datetime import datetime, timedelta +from pathlib import Path from unittest.mock import Mock, patch import pytest @@ -16,7 +17,7 @@ def file_get_contents(filename: str) -> str: """Read a file and returns its contents.""" - with open(filename) as file: + with Path(filename).open() as file: return file.read() @@ -73,7 +74,7 @@ def test_lazy_update_with_show_set(mock_logger_debug): show_client.lazy_update() show_client.update.assert_not_called() mock_logger_debug.assert_called_once_with( - "Show still running, won't update show info" + "Show still running, won't update show info", ) @@ -82,17 +83,25 @@ def test_update(mock_requests_get): """Test :class:`ShowClient`'s :meth:`update` method.""" mock_requests_get.return_value.json = Mock( return_value=json.loads( - file_get_contents("tests/fixtures/cast_now_during_show.json") - ) + file_get_contents("tests/fixtures/cast_now_during_show.json"), + ), ) show_client = ShowClient(_BASE_URL) show_client.update() assert show_client.show.name == "Voice of Hindu Kush" assert show_client.show.starttime == datetime( - 2019, 1, 27, 13, tzinfo=pytz.timezone("UTC") + 2019, + 1, + 27, + 13, + tzinfo=pytz.timezone("UTC"), ) assert show_client.show.endtime == datetime( - 2319, 1, 27, 14, tzinfo=pytz.timezone("UTC") + 2319, + 1, + 27, + 14, + tzinfo=pytz.timezone("UTC"), ) assert show_client.show.url == "https://www.rabe.ch/stimme-der-kutuesch/" @@ -115,8 +124,8 @@ def test_update_no_url(mock_requests_get): """Test :class:`ShowClient`'s :meth:`update` method when no url is returned.""" mock_requests_get.return_value.json = Mock( return_value=json.loads( - file_get_contents("tests/fixtures/cast_now_no_url.json") - ) + file_get_contents("tests/fixtures/cast_now_no_url.json"), + ), ) show_client = ShowClient(_BASE_URL) show_client.update() @@ -125,7 +134,7 @@ def test_update_no_url(mock_requests_get): @patch("requests.get") @pytest.mark.parametrize( - "fixture,field", + ("fixture", "field"), [ ("cast_now_no_name", "name"), ("cast_now_no_end", "end time"), @@ -135,7 +144,7 @@ def test_update_no_url(mock_requests_get): def test_update_empty_field(mock_requests_get, fixture, field): """Test :class:`ShowClient`'s :meth:`update` method when a field is empty.""" mock_requests_get.return_value.json = Mock( - return_value=json.loads(file_get_contents(f"tests/fixtures/{fixture}.json")) + return_value=json.loads(file_get_contents(f"tests/fixtures/{fixture}.json")), ) show_client = ShowClient(_BASE_URL) with pytest.raises(ShowClientError) as info: @@ -148,8 +157,8 @@ def test_update_past_show(mock_requests_get): """Test :class:`ShowClient`'s :meth:`update` method when the show is in the past.""" mock_requests_get.return_value.json = Mock( return_value=json.loads( - file_get_contents("tests/fixtures/cast_now_past_show.json") - ) + file_get_contents("tests/fixtures/cast_now_past_show.json"), + ), ) show_client = ShowClient(_BASE_URL) with pytest.raises(ShowClientError) as info: @@ -165,8 +174,8 @@ def test_update_show_empty(mock_requests_get): """ mock_requests_get.return_value.json = Mock( return_value=json.loads( - file_get_contents("tests/fixtures/cast_now_show_empty.json") - ) + file_get_contents("tests/fixtures/cast_now_show_empty.json"), + ), ) show_client = ShowClient(_BASE_URL) show_client.update() @@ -179,8 +188,8 @@ def test_update_show_encoding_fix_in_name(mock_requests_get): """Test :class:`ShowClient`'s :meth:`update` for show name with encoding fix.""" mock_requests_get.return_value.json = Mock( return_value=json.loads( - file_get_contents("tests/fixtures/cast_now_show_encoding_fix.json") - ) + file_get_contents("tests/fixtures/cast_now_show_encoding_fix.json"), + ), ) show_client = ShowClient(_BASE_URL) show_client.update() @@ -192,16 +201,24 @@ def test_update_when_show_is_in_next_array(mock_requests_get): """Test :class:`ShowClient`'s :meth:`update` method.""" mock_requests_get.return_value.json = Mock( return_value=json.loads( - file_get_contents("tests/fixtures/cast_now_show_in_next.json") - ) + file_get_contents("tests/fixtures/cast_now_show_in_next.json"), + ), ) show_client = ShowClient(_BASE_URL) show_client.update() assert show_client.show.name == "Voice of Hindu Kush" assert show_client.show.starttime == datetime( - 2019, 1, 27, 13, tzinfo=pytz.timezone("UTC") + 2019, + 1, + 27, + 13, + tzinfo=pytz.timezone("UTC"), ) assert show_client.show.endtime == datetime( - 2319, 1, 27, 14, tzinfo=pytz.timezone("UTC") + 2319, + 1, + 27, + 14, + tzinfo=pytz.timezone("UTC"), ) assert show_client.show.url == "https://www.rabe.ch/stimme-der-kutuesch/" diff --git a/tests/test_track.py b/tests/test_track.py index 68fc13fe..79a89b7d 100644 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -18,7 +18,7 @@ def test_init(): def test_artist(): """Test :class:`Track`'s :meth:`artist` property.""" track = Track() - assert track.artist is None + assert track.artist == "" assert not track.has_default_artist() track.set_artist("Test") assert track.artist == "Test" @@ -30,7 +30,7 @@ def test_artist(): def test_title(): """Test :class:`Track`'s :meth:`title` property.""" track = Track() - assert track.title is None + assert track.title == "" assert not track.has_default_title() track.set_title("Test Title") assert track.title == "Test Title" @@ -42,7 +42,7 @@ def test_title(): def test_album(): """Test :class:`Track`'s :meth:`album` property.""" track = Track() - assert track.album is None + assert track.album == "" track.set_album("Test Album") assert track.album == "Test Album" @@ -52,7 +52,7 @@ def test_track(): track = Track() assert track.track == 1 track.set_track(2) - assert track.track == 2 + assert track.track == 2 # noqa: PLR2004 with pytest.raises(TypeError): track.set_track("no strings allowed") with pytest.raises(TrackError): @@ -102,4 +102,4 @@ def test_duration(): def test_prettyprinting(): """Test :class:`Track`'s :meth:`__str__` method.""" track = Track() - assert "Track 'None'" in str(track) + assert "Track ''" in str(track) diff --git a/tests/test_track_observer_dabaudiocompanion.py b/tests/test_track_observer_dabaudiocompanion.py index 809a7834..7be429ed 100644 --- a/tests/test_track_observer_dabaudiocompanion.py +++ b/tests/test_track_observer_dabaudiocompanion.py @@ -1,5 +1,8 @@ """Tests for :class:`DabAudioCompanionTrackObserver`.""" +# TODO(hairmare): v3 drop support +# https://github.com/radiorabe/nowplaying/issues/179 + from unittest.mock import MagicMock, Mock, patch from nowplaying.track.observers.dab_audio_companion import ( @@ -15,7 +18,7 @@ def test_init(): dab_audio_companion_track_observer = DabAudioCompanionTrackObserver( options=DabAudioCompanionTrackObserver.Options( url=_BASE_URL, - ) + ), ) assert dab_audio_companion_track_observer.base_url == f"{_BASE_URL}/api/setDLS" @@ -25,8 +28,7 @@ def test_track_started(mock_requests_post, track_factory, show_factory): """Test :class:`DabAudioCompanionTrackObserver`'s :meth:`track_started` method.""" mock_requests_post.return_value.getcode = Mock(return_value=200) mock_requests_post.return_value.read = Mock( - # TODO: mock and test real return value - return_value="contents" + return_value="contents", ) track = track_factory() @@ -35,7 +37,7 @@ def test_track_started(mock_requests_post, track_factory, show_factory): dab_audio_companion_track_observer = DabAudioCompanionTrackObserver( options=DabAudioCompanionTrackObserver.Options( url=_BASE_URL, - ) + ), ) # assume that last frame was DL+ on startup so we always send # delete tags when a show w/o dl+ starts @@ -73,7 +75,7 @@ def test_track_started(mock_requests_post, track_factory, show_factory): ] expected.sort() results.sort() - assert all([a == b for a, b in zip(results, expected)]) + assert all([a == b for a, b in zip(results, expected)]) # noqa: C419 # once ITEM delete have been sent we send regular DLS again dab_audio_companion_track_observer.track_started(track) @@ -92,10 +94,8 @@ def test_track_started(mock_requests_post, track_factory, show_factory): @patch("urllib.request.urlopen") def test_track_started_plain(mock_urlopen, track_factory, show_factory): - # TODO v3 remove when we drop plain support cm = MagicMock() cm.getcode.return_value = 200 - # TODO: mock and test real return value cm.read.return_value = "contents" cm.__enter__.return_value = cm mock_urlopen.return_value = cm @@ -107,7 +107,7 @@ def test_track_started_plain(mock_urlopen, track_factory, show_factory): options=DabAudioCompanionTrackObserver.Options( url=_BASE_URL, dl_plus=False, - ) + ), ) # last frame cannot be dl+ since the feature is inactive assert not o.last_frame_was_dl_plus @@ -115,7 +115,7 @@ def test_track_started_plain(mock_urlopen, track_factory, show_factory): o.track_started(track) assert not o.last_frame_was_dl_plus mock_urlopen.assert_called_with( - "http://localhost:80/api/setDLS?dls=b%27Hairmare+and+the+Band%27+-+b%27An+Ode+to+legacy+Python+Code%27" + "http://localhost:80/api/setDLS?dls=b%27Hairmare+and+the+Band%27+-+b%27An+Ode+to+legacy+Python+Code%27", ) track = track_factory(artist="Radio Bern", title="Livestream") @@ -123,7 +123,7 @@ def test_track_started_plain(mock_urlopen, track_factory, show_factory): o.track_started(track) mock_urlopen.assert_called_with( - "http://localhost:80/api/setDLS?dls=b%27Radio+Bern%27+-+b%27Hairmare+Traveling+Medicine+Show%27" + "http://localhost:80/api/setDLS?dls=b%27Radio+Bern%27+-+b%27Hairmare+Traveling+Medicine+Show%27", ) @@ -132,6 +132,6 @@ def test_track_finished(): dab_audio_companion_track_observer = DabAudioCompanionTrackObserver( options=DabAudioCompanionTrackObserver.Options( url=_BASE_URL, - ) + ), ) assert dab_audio_companion_track_observer.track_finished(Track()) diff --git a/tests/test_track_observer_icecast.py b/tests/test_track_observer_icecast.py index 58adf637..b148730b 100644 --- a/tests/test_track_observer_icecast.py +++ b/tests/test_track_observer_icecast.py @@ -10,7 +10,7 @@ @pytest.mark.parametrize( - "kwargs,url,username,password,mount", + ("kwargs", "url", "username", "password", "mount"), [ ( {"url": "http://user:password@localhost:80/?mount=foo.mp3"}, @@ -57,8 +57,8 @@ def test_init(): options=IcecastTrackObserver.Options( url="http://localhost:80/?mount=foo.mp3", username="foo", - password="bar", - ) + password="bar", # noqa: S106 + ), ) assert icecast_track_observer.options.url == "http://localhost:80/" assert icecast_track_observer.options.mount == "foo.mp3" @@ -67,23 +67,23 @@ def test_init(): options=IcecastTrackObserver.Options( url="http://localhost:80/", username="foo", - password="bar", + password="bar", # noqa: S106 mount="foo.mp3", - ) + ), ) assert icecast_track_observer.options.url == "http://localhost:80/" assert icecast_track_observer.options.mount == "foo.mp3" # test for exception if mount is missing - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Missing required parameter mount"): IcecastTrackObserver.Options( url="http://localhost:80/", username="foo", - password="bar", + password="bar", # noqa: S106 ) # test for exception if password is missing - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Missing required parameter password "): IcecastTrackObserver.Options( url="http://localhost:80/?mount=foo.mp3", username="foo", @@ -95,7 +95,6 @@ def test_track_started(mock_requests_get, track_factory, show_factory): """Test :class:`IcecastTrackObserver`'s :meth:`track_started` method.""" mock_resp = MagicMock() mock_resp.getcode.return_value = 200 - # TODO: mock and test real return value mock_resp.read.return_value = "contents" mock_resp.__enter__.return_value = mock_resp mock_requests_get.return_value = mock_resp @@ -107,8 +106,8 @@ def test_track_started(mock_requests_get, track_factory, show_factory): options=IcecastTrackObserver.Options( url="http://localhost:80/?mount=foo.mp3", username="foo", - password="bar", - ) + password="bar", # noqa: S106 + ), ) icecast_track_observer.track_started(track) @@ -121,6 +120,7 @@ def test_track_started(mock_requests_get, track_factory, show_factory): "charset": "utf-8", "song": "Hairmare and the Band - An Ode to legacy Python Code", }, + timeout=60, ) track = track_factory(artist="Radio Bern", title="Livestream") track.show = show_factory() @@ -135,6 +135,7 @@ def test_track_started(mock_requests_get, track_factory, show_factory): "charset": "utf-8", "song": "Radio Bern - Hairmare Traveling Medicine Show", }, + timeout=60, ) # test for ignoring of failed requests @@ -150,6 +151,7 @@ def test_track_started(mock_requests_get, track_factory, show_factory): "charset": "utf-8", "song": "Radio Bern - Hairmare Traveling Medicine Show", }, + timeout=60, ) @@ -159,7 +161,7 @@ def test_track_finished(): options=IcecastTrackObserver.Options( url="http://localhost:80/?mount=foo.mp3", username="foo", - password="bar", - ) + password="bar", # noqa: S106 + ), ) - assert icecast_track_observer.track_finished(Track()) + icecast_track_observer.track_finished(Track()) diff --git a/tests/test_track_observer_smc_ftp.py b/tests/test_track_observer_smc_ftp.py index 1c172911..432ec144 100644 --- a/tests/test_track_observer_smc_ftp.py +++ b/tests/test_track_observer_smc_ftp.py @@ -12,8 +12,8 @@ def test_init(): options=SmcFtpTrackObserver.Options( hostname="hostname", username="username", - password="password", - ) + password="password", # noqa: S106 + ), ) @@ -30,10 +30,10 @@ def test_track_started(mock_ftp, track_factory, show_factory): options=SmcFtpTrackObserver.Options( hostname="hostname", username="username", - password="password", - ) + password="password", # noqa: S106 + ), ) - smc_ftp_track_observer._ftp_cls = mock_ftp + smc_ftp_track_observer._ftp_cls = mock_ftp # noqa: SLF001 smc_ftp_track_observer.track_started(track) mock_ftp.assert_called_once() mock_ftp_instance.assert_has_calls( @@ -51,7 +51,7 @@ def test_track_started(mock_ftp, track_factory, show_factory): ), call.quit(), call.close(), - ] + ], ) # test skipping short tracks @@ -79,7 +79,7 @@ def test_track_started(mock_ftp, track_factory, show_factory): call.storlines("STOR /dlplus/nowplaying.dls", ANY), call.quit(), call.close(), - ] + ], ) @@ -89,7 +89,7 @@ def test_track_finished(): options=SmcFtpTrackObserver.Options( hostname="hostname", username="username", - password="password", - ) + password="password", # noqa: S106 + ), ) - assert smc_ftp_track_observer.track_finished(Track()) + smc_ftp_track_observer.track_finished(Track()) diff --git a/tests/test_track_observer_tickertrack.py b/tests/test_track_observer_tickertrack.py index 5e11fd47..a9a56f8d 100644 --- a/tests/test_track_observer_tickertrack.py +++ b/tests/test_track_observer_tickertrack.py @@ -1,6 +1,6 @@ """Tests for :class:`observer.TickerTrackObserver`.""" -import os +from pathlib import Path import pytest @@ -10,18 +10,18 @@ @pytest.mark.filterwarnings( - f"ignore:{_FORMAT_WARNING}:PendingDeprecationWarning:nowplaying.track.observer" + f"ignore:{_FORMAT_WARNING}:PendingDeprecationWarning:nowplaying.track.observer", ) def test_init(): """Test class:`TickerTrackObserver`'s :meth:`.__init__` method.""" ticker_track_observer = TickerTrackObserver( - options=TickerTrackObserver.Options(file_path="") + options=TickerTrackObserver.Options(file_path=""), ) assert ticker_track_observer.ticker_file_path == "" @pytest.mark.filterwarnings( - f"ignore:{_FORMAT_WARNING}:PendingDeprecationWarning:nowplaying.track.observer" + f"ignore:{_FORMAT_WARNING}:PendingDeprecationWarning:nowplaying.track.observer", ) def test_track_started(track_factory, show_factory): """Test :class:`TickerTrackObserver`'s :meth:`track_started` method.""" @@ -30,21 +30,21 @@ def test_track_started(track_factory, show_factory): track.show = show_factory() ticker_track_observer = TickerTrackObserver( - options=TickerTrackObserver.Options(file_path="/tmp/track_started.xml") + options=TickerTrackObserver.Options(file_path="/tmp/track_started.xml"), ) ticker_track_observer.track_started(track) - assert os.path.exists("/tmp/track_started.xml") + assert Path("/tmp/track_started.xml").exists() @pytest.mark.filterwarnings( - f"ignore:{_FORMAT_WARNING}:PendingDeprecationWarning:nowplaying.track.observer" + f"ignore:{_FORMAT_WARNING}:PendingDeprecationWarning:nowplaying.track.observer", ) def test_track_finished(track_factory): """Test :class:`TickerTrackObserver`'s :meth:`track_finished` method.""" track = track_factory() ticker_track_observer = TickerTrackObserver( - options=TickerTrackObserver.Options(file_path="/tmp/dummy.xml") + options=TickerTrackObserver.Options(file_path="/tmp/dummy.xml"), ) - assert ticker_track_observer.track_finished(track) + ticker_track_observer.track_finished(track)