From 23c59d7df4f0c35104b8f6bac507e7fda1db98b3 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 | 609 ++++++++++-------- 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, 1375 insertions(+), 919 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..b90df95e 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.5' 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 f782b121..0818868a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,24 +2,44 @@ [[package]] name = "anyio" -version = "4.6.2.post1" +version = "4.7.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" files = [ - {file = "anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d"}, - {file = "anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c"}, + {file = "anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352"}, + {file = "anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48"}, ] [package.dependencies] idna = ">=2.8" sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -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)"] +doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] +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)"] trio = ["trio (>=0.26.1)"] +[[package]] +name = "attrs" +version = "24.3.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.8" +files = [ + {file = "attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308"}, + {file = "attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff"}, +] + +[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-uv", "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,59 +65,15 @@ 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" +version = "2024.12.14" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, - {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, + {file = "certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56"}, + {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"}, ] [[package]] @@ -318,73 +294,73 @@ yaml = ["PyYAML"] [[package]] name = "coverage" -version = "7.6.8" +version = "7.6.9" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" files = [ - {file = "coverage-7.6.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b39e6011cd06822eb964d038d5dff5da5d98652b81f5ecd439277b32361a3a50"}, - {file = "coverage-7.6.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:63c19702db10ad79151a059d2d6336fe0c470f2e18d0d4d1a57f7f9713875dcf"}, - {file = "coverage-7.6.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3985b9be361d8fb6b2d1adc9924d01dec575a1d7453a14cccd73225cb79243ee"}, - {file = "coverage-7.6.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:644ec81edec0f4ad17d51c838a7d01e42811054543b76d4ba2c5d6af741ce2a6"}, - {file = "coverage-7.6.8-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f188a2402f8359cf0c4b1fe89eea40dc13b52e7b4fd4812450da9fcd210181d"}, - {file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e19122296822deafce89a0c5e8685704c067ae65d45e79718c92df7b3ec3d331"}, - {file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:13618bed0c38acc418896005732e565b317aa9e98d855a0e9f211a7ffc2d6638"}, - {file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:193e3bffca48ad74b8c764fb4492dd875038a2f9925530cb094db92bb5e47bed"}, - {file = "coverage-7.6.8-cp310-cp310-win32.whl", hash = "sha256:3988665ee376abce49613701336544041f2117de7b7fbfe91b93d8ff8b151c8e"}, - {file = "coverage-7.6.8-cp310-cp310-win_amd64.whl", hash = "sha256:f56f49b2553d7dd85fd86e029515a221e5c1f8cb3d9c38b470bc38bde7b8445a"}, - {file = "coverage-7.6.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:86cffe9c6dfcfe22e28027069725c7f57f4b868a3f86e81d1c62462764dc46d4"}, - {file = "coverage-7.6.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d82ab6816c3277dc962cfcdc85b1efa0e5f50fb2c449432deaf2398a2928ab94"}, - {file = "coverage-7.6.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13690e923a3932e4fad4c0ebfb9cb5988e03d9dcb4c5150b5fcbf58fd8bddfc4"}, - {file = "coverage-7.6.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4be32da0c3827ac9132bb488d331cb32e8d9638dd41a0557c5569d57cf22c9c1"}, - {file = "coverage-7.6.8-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44e6c85bbdc809383b509d732b06419fb4544dca29ebe18480379633623baafb"}, - {file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:768939f7c4353c0fac2f7c37897e10b1414b571fd85dd9fc49e6a87e37a2e0d8"}, - {file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e44961e36cb13c495806d4cac67640ac2866cb99044e210895b506c26ee63d3a"}, - {file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ea8bb1ab9558374c0ab591783808511d135a833c3ca64a18ec927f20c4030f0"}, - {file = "coverage-7.6.8-cp311-cp311-win32.whl", hash = "sha256:629a1ba2115dce8bf75a5cce9f2486ae483cb89c0145795603d6554bdc83e801"}, - {file = "coverage-7.6.8-cp311-cp311-win_amd64.whl", hash = "sha256:fb9fc32399dca861584d96eccd6c980b69bbcd7c228d06fb74fe53e007aa8ef9"}, - {file = "coverage-7.6.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e683e6ecc587643f8cde8f5da6768e9d165cd31edf39ee90ed7034f9ca0eefee"}, - {file = "coverage-7.6.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1defe91d41ce1bd44b40fabf071e6a01a5aa14de4a31b986aa9dfd1b3e3e414a"}, - {file = "coverage-7.6.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7ad66e8e50225ebf4236368cc43c37f59d5e6728f15f6e258c8639fa0dd8e6d"}, - {file = "coverage-7.6.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fe47da3e4fda5f1abb5709c156eca207eacf8007304ce3019eb001e7a7204cb"}, - {file = "coverage-7.6.8-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:202a2d645c5a46b84992f55b0a3affe4f0ba6b4c611abec32ee88358db4bb649"}, - {file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4674f0daa1823c295845b6a740d98a840d7a1c11df00d1fd62614545c1583787"}, - {file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:74610105ebd6f33d7c10f8907afed696e79c59e3043c5f20eaa3a46fddf33b4c"}, - {file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cda8712145917105e07aab96388ae76e787270ec04bcb9d5cc786d7cbb8443"}, - {file = "coverage-7.6.8-cp312-cp312-win32.whl", hash = "sha256:9e89d5c8509fbd6c03d0dd1972925b22f50db0792ce06324ba069f10787429ad"}, - {file = "coverage-7.6.8-cp312-cp312-win_amd64.whl", hash = "sha256:379c111d3558272a2cae3d8e57e6b6e6f4fe652905692d54bad5ea0ca37c5ad4"}, - {file = "coverage-7.6.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b0c69f4f724c64dfbfe79f5dfb503b42fe6127b8d479b2677f2b227478db2eb"}, - {file = "coverage-7.6.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c15b32a7aca8038ed7644f854bf17b663bc38e1671b5d6f43f9a2b2bd0c46f63"}, - {file = "coverage-7.6.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63068a11171e4276f6ece913bde059e77c713b48c3a848814a6537f35afb8365"}, - {file = "coverage-7.6.8-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f4548c5ead23ad13fb7a2c8ea541357474ec13c2b736feb02e19a3085fac002"}, - {file = "coverage-7.6.8-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4b4299dd0d2c67caaaf286d58aef5e75b125b95615dda4542561a5a566a1e3"}, - {file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9ebfb2507751f7196995142f057d1324afdab56db1d9743aab7f50289abd022"}, - {file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c1b4474beee02ede1eef86c25ad4600a424fe36cff01a6103cb4533c6bf0169e"}, - {file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9fd2547e6decdbf985d579cf3fc78e4c1d662b9b0ff7cc7862baaab71c9cc5b"}, - {file = "coverage-7.6.8-cp313-cp313-win32.whl", hash = "sha256:8aae5aea53cbfe024919715eca696b1a3201886ce83790537d1c3668459c7146"}, - {file = "coverage-7.6.8-cp313-cp313-win_amd64.whl", hash = "sha256:ae270e79f7e169ccfe23284ff5ea2d52a6f401dc01b337efb54b3783e2ce3f28"}, - {file = "coverage-7.6.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de38add67a0af869b0d79c525d3e4588ac1ffa92f39116dbe0ed9753f26eba7d"}, - {file = "coverage-7.6.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b07c25d52b1c16ce5de088046cd2432b30f9ad5e224ff17c8f496d9cb7d1d451"}, - {file = "coverage-7.6.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62a66ff235e4c2e37ed3b6104d8b478d767ff73838d1222132a7a026aa548764"}, - {file = "coverage-7.6.8-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b9f848b28081e7b975a3626e9081574a7b9196cde26604540582da60235fdf"}, - {file = "coverage-7.6.8-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:093896e530c38c8e9c996901858ac63f3d4171268db2c9c8b373a228f459bbc5"}, - {file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a7b8ac36fd688c8361cbc7bf1cb5866977ece6e0b17c34aa0df58bda4fa18a4"}, - {file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:38c51297b35b3ed91670e1e4efb702b790002e3245a28c76e627478aa3c10d83"}, - {file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2e4e0f60cb4bd7396108823548e82fdab72d4d8a65e58e2c19bbbc2f1e2bfa4b"}, - {file = "coverage-7.6.8-cp313-cp313t-win32.whl", hash = "sha256:6535d996f6537ecb298b4e287a855f37deaf64ff007162ec0afb9ab8ba3b8b71"}, - {file = "coverage-7.6.8-cp313-cp313t-win_amd64.whl", hash = "sha256:c79c0685f142ca53256722a384540832420dff4ab15fec1863d7e5bc8691bdcc"}, - {file = "coverage-7.6.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ac47fa29d8d41059ea3df65bd3ade92f97ee4910ed638e87075b8e8ce69599e"}, - {file = "coverage-7.6.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:24eda3a24a38157eee639ca9afe45eefa8d2420d49468819ac5f88b10de84f4c"}, - {file = "coverage-7.6.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4c81ed2820b9023a9a90717020315e63b17b18c274a332e3b6437d7ff70abe0"}, - {file = "coverage-7.6.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd55f8fc8fa494958772a2a7302b0354ab16e0b9272b3c3d83cdb5bec5bd1779"}, - {file = "coverage-7.6.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f39e2f3530ed1626c66e7493be7a8423b023ca852aacdc91fb30162c350d2a92"}, - {file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:716a78a342679cd1177bc8c2fe957e0ab91405bd43a17094324845200b2fddf4"}, - {file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:177f01eeaa3aee4a5ffb0d1439c5952b53d5010f86e9d2667963e632e30082cc"}, - {file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:912e95017ff51dc3d7b6e2be158dedc889d9a5cc3382445589ce554f1a34c0ea"}, - {file = "coverage-7.6.8-cp39-cp39-win32.whl", hash = "sha256:4db3ed6a907b555e57cc2e6f14dc3a4c2458cdad8919e40b5357ab9b6db6c43e"}, - {file = "coverage-7.6.8-cp39-cp39-win_amd64.whl", hash = "sha256:428ac484592f780e8cd7b6b14eb568f7c85460c92e2a37cb0c0e5186e1a0d076"}, - {file = "coverage-7.6.8-pp39.pp310-none-any.whl", hash = "sha256:5c52a036535d12590c32c49209e79cabaad9f9ad8aa4cbd875b68c4d67a9cbce"}, - {file = "coverage-7.6.8.tar.gz", hash = "sha256:8b2b8503edb06822c86d82fa64a4a5cb0760bb8f31f26e138ec743f422f37cfc"}, + {file = "coverage-7.6.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:85d9636f72e8991a1706b2b55b06c27545448baf9f6dbf51c4004609aacd7dcb"}, + {file = "coverage-7.6.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:608a7fd78c67bee8936378299a6cb9f5149bb80238c7a566fc3e6717a4e68710"}, + {file = "coverage-7.6.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96d636c77af18b5cb664ddf12dab9b15a0cfe9c0bde715da38698c8cea748bfa"}, + {file = "coverage-7.6.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d75cded8a3cff93da9edc31446872d2997e327921d8eed86641efafd350e1df1"}, + {file = "coverage-7.6.9-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7b15f589593110ae767ce997775d645b47e5cbbf54fd322f8ebea6277466cec"}, + {file = "coverage-7.6.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:44349150f6811b44b25574839b39ae35291f6496eb795b7366fef3bd3cf112d3"}, + {file = "coverage-7.6.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d891c136b5b310d0e702e186d70cd16d1119ea8927347045124cb286b29297e5"}, + {file = "coverage-7.6.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:db1dab894cc139f67822a92910466531de5ea6034ddfd2b11c0d4c6257168073"}, + {file = "coverage-7.6.9-cp310-cp310-win32.whl", hash = "sha256:41ff7b0da5af71a51b53f501a3bac65fb0ec311ebed1632e58fc6107f03b9198"}, + {file = "coverage-7.6.9-cp310-cp310-win_amd64.whl", hash = "sha256:35371f8438028fdccfaf3570b31d98e8d9eda8bb1d6ab9473f5a390969e98717"}, + {file = "coverage-7.6.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:932fc826442132dde42ee52cf66d941f581c685a6313feebed358411238f60f9"}, + {file = "coverage-7.6.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:085161be5f3b30fd9b3e7b9a8c301f935c8313dcf928a07b116324abea2c1c2c"}, + {file = "coverage-7.6.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc660a77e1c2bf24ddbce969af9447a9474790160cfb23de6be4fa88e3951c7"}, + {file = "coverage-7.6.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c69e42c892c018cd3c8d90da61d845f50a8243062b19d228189b0224150018a9"}, + {file = "coverage-7.6.9-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0824a28ec542a0be22f60c6ac36d679e0e262e5353203bea81d44ee81fe9c6d4"}, + {file = "coverage-7.6.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4401ae5fc52ad8d26d2a5d8a7428b0f0c72431683f8e63e42e70606374c311a1"}, + {file = "coverage-7.6.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:98caba4476a6c8d59ec1eb00c7dd862ba9beca34085642d46ed503cc2d440d4b"}, + {file = "coverage-7.6.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ee5defd1733fd6ec08b168bd4f5387d5b322f45ca9e0e6c817ea6c4cd36313e3"}, + {file = "coverage-7.6.9-cp311-cp311-win32.whl", hash = "sha256:f2d1ec60d6d256bdf298cb86b78dd715980828f50c46701abc3b0a2b3f8a0dc0"}, + {file = "coverage-7.6.9-cp311-cp311-win_amd64.whl", hash = "sha256:0d59fd927b1f04de57a2ba0137166d31c1a6dd9e764ad4af552912d70428c92b"}, + {file = "coverage-7.6.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:99e266ae0b5d15f1ca8d278a668df6f51cc4b854513daab5cae695ed7b721cf8"}, + {file = "coverage-7.6.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9901d36492009a0a9b94b20e52ebfc8453bf49bb2b27bca2c9706f8b4f5a554a"}, + {file = "coverage-7.6.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abd3e72dd5b97e3af4246cdada7738ef0e608168de952b837b8dd7e90341f015"}, + {file = "coverage-7.6.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff74026a461eb0660366fb01c650c1d00f833a086b336bdad7ab00cc952072b3"}, + {file = "coverage-7.6.9-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65dad5a248823a4996724a88eb51d4b31587aa7aa428562dbe459c684e5787ae"}, + {file = "coverage-7.6.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:22be16571504c9ccea919fcedb459d5ab20d41172056206eb2994e2ff06118a4"}, + {file = "coverage-7.6.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f957943bc718b87144ecaee70762bc2bc3f1a7a53c7b861103546d3a403f0a6"}, + {file = "coverage-7.6.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ae1387db4aecb1f485fb70a6c0148c6cdaebb6038f1d40089b1fc84a5db556f"}, + {file = "coverage-7.6.9-cp312-cp312-win32.whl", hash = "sha256:1a330812d9cc7ac2182586f6d41b4d0fadf9be9049f350e0efb275c8ee8eb692"}, + {file = "coverage-7.6.9-cp312-cp312-win_amd64.whl", hash = "sha256:b12c6b18269ca471eedd41c1b6a1065b2f7827508edb9a7ed5555e9a56dcfc97"}, + {file = "coverage-7.6.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:899b8cd4781c400454f2f64f7776a5d87bbd7b3e7f7bda0cb18f857bb1334664"}, + {file = "coverage-7.6.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:61f70dc68bd36810972e55bbbe83674ea073dd1dcc121040a08cdf3416c5349c"}, + {file = "coverage-7.6.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a289d23d4c46f1a82d5db4abeb40b9b5be91731ee19a379d15790e53031c014"}, + {file = "coverage-7.6.9-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e216d8044a356fc0337c7a2a0536d6de07888d7bcda76febcb8adc50bdbbd00"}, + {file = "coverage-7.6.9-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c026eb44f744acaa2bda7493dad903aa5bf5fc4f2554293a798d5606710055d"}, + {file = "coverage-7.6.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e77363e8425325384f9d49272c54045bbed2f478e9dd698dbc65dbc37860eb0a"}, + {file = "coverage-7.6.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:777abfab476cf83b5177b84d7486497e034eb9eaea0d746ce0c1268c71652077"}, + {file = "coverage-7.6.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:447af20e25fdbe16f26e84eb714ba21d98868705cb138252d28bc400381f6ffb"}, + {file = "coverage-7.6.9-cp313-cp313-win32.whl", hash = "sha256:d872ec5aeb086cbea771c573600d47944eea2dcba8be5f3ee649bfe3cb8dc9ba"}, + {file = "coverage-7.6.9-cp313-cp313-win_amd64.whl", hash = "sha256:fd1213c86e48dfdc5a0cc676551db467495a95a662d2396ecd58e719191446e1"}, + {file = "coverage-7.6.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9e7484d286cd5a43744e5f47b0b3fb457865baf07bafc6bee91896364e1419"}, + {file = "coverage-7.6.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1cf0872ee455c03e5674b5bca5e3e68e159379c1af0903e89f5eba9ccc3a"}, + {file = "coverage-7.6.9-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d10e07aa2b91835d6abec555ec8b2733347956991901eea6ffac295f83a30e4"}, + {file = "coverage-7.6.9-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13a9e2d3ee855db3dd6ea1ba5203316a1b1fd8eaeffc37c5b54987e61e4194ae"}, + {file = "coverage-7.6.9-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c38bf15a40ccf5619fa2fe8f26106c7e8e080d7760aeccb3722664c8656b030"}, + {file = "coverage-7.6.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d5275455b3e4627c8e7154feaf7ee0743c2e7af82f6e3b561967b1cca755a0be"}, + {file = "coverage-7.6.9-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8f8770dfc6e2c6a2d4569f411015c8d751c980d17a14b0530da2d7f27ffdd88e"}, + {file = "coverage-7.6.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8d2dfa71665a29b153a9681edb1c8d9c1ea50dfc2375fb4dac99ea7e21a0bcd9"}, + {file = "coverage-7.6.9-cp313-cp313t-win32.whl", hash = "sha256:5e6b86b5847a016d0fbd31ffe1001b63355ed309651851295315031ea7eb5a9b"}, + {file = "coverage-7.6.9-cp313-cp313t-win_amd64.whl", hash = "sha256:97ddc94d46088304772d21b060041c97fc16bdda13c6c7f9d8fcd8d5ae0d8611"}, + {file = "coverage-7.6.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:adb697c0bd35100dc690de83154627fbab1f4f3c0386df266dded865fc50a902"}, + {file = "coverage-7.6.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:be57b6d56e49c2739cdf776839a92330e933dd5e5d929966fbbd380c77f060be"}, + {file = "coverage-7.6.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1592791f8204ae9166de22ba7e6705fa4ebd02936c09436a1bb85aabca3e599"}, + {file = "coverage-7.6.9-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e12ae8cc979cf83d258acb5e1f1cf2f3f83524d1564a49d20b8bec14b637f08"}, + {file = "coverage-7.6.9-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb5555cff66c4d3d6213a296b360f9e1a8e323e74e0426b6c10ed7f4d021e464"}, + {file = "coverage-7.6.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b9389a429e0e5142e69d5bf4a435dd688c14478a19bb901735cdf75e57b13845"}, + {file = "coverage-7.6.9-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:592ac539812e9b46046620341498caf09ca21023c41c893e1eb9dbda00a70cbf"}, + {file = "coverage-7.6.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a27801adef24cc30871da98a105f77995e13a25a505a0161911f6aafbd66e678"}, + {file = "coverage-7.6.9-cp39-cp39-win32.whl", hash = "sha256:8e3c3e38930cfb729cb8137d7f055e5a473ddaf1217966aa6238c88bd9fd50e6"}, + {file = "coverage-7.6.9-cp39-cp39-win_amd64.whl", hash = "sha256:e28bf44afa2b187cc9f41749138a64435bf340adfcacb5b2290c070ce99839d4"}, + {file = "coverage-7.6.9-pp39.pp310-none-any.whl", hash = "sha256:f3ca78518bc6bc92828cd11867b121891d75cae4ea9e908d72030609b996db1b"}, + {file = "coverage-7.6.9.tar.gz", hash = "sha256:4a8d8977b0c6ef5aeadcb644da9e69ae0dcfe66ec7f368c89c72e058bd71164d"}, ] [package.extras] @@ -436,6 +412,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" @@ -486,70 +478,70 @@ colorama = ">=0.4" [[package]] name = "grpcio" -version = "1.68.0" +version = "1.68.1" description = "HTTP/2-based RPC framework" optional = false python-versions = ">=3.8" files = [ - {file = "grpcio-1.68.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:619b5d0f29f4f5351440e9343224c3e19912c21aeda44e0c49d0d147a8d01544"}, - {file = "grpcio-1.68.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:a59f5822f9459bed098ffbceb2713abbf7c6fd13f2b9243461da5c338d0cd6c3"}, - {file = "grpcio-1.68.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:c03d89df516128febc5a7e760d675b478ba25802447624edf7aa13b1e7b11e2a"}, - {file = "grpcio-1.68.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44bcbebb24363d587472089b89e2ea0ab2e2b4df0e4856ba4c0b087c82412121"}, - {file = "grpcio-1.68.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79f81b7fbfb136247b70465bd836fa1733043fdee539cd6031cb499e9608a110"}, - {file = "grpcio-1.68.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:88fb2925789cfe6daa20900260ef0a1d0a61283dfb2d2fffe6194396a354c618"}, - {file = "grpcio-1.68.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:99f06232b5c9138593ae6f2e355054318717d32a9c09cdc5a2885540835067a1"}, - {file = "grpcio-1.68.0-cp310-cp310-win32.whl", hash = "sha256:a6213d2f7a22c3c30a479fb5e249b6b7e648e17f364598ff64d08a5136fe488b"}, - {file = "grpcio-1.68.0-cp310-cp310-win_amd64.whl", hash = "sha256:15327ab81131ef9b94cb9f45b5bd98803a179c7c61205c8c0ac9aff9d6c4e82a"}, - {file = "grpcio-1.68.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:3b2b559beb2d433129441783e5f42e3be40a9e1a89ec906efabf26591c5cd415"}, - {file = "grpcio-1.68.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e46541de8425a4d6829ac6c5d9b16c03c292105fe9ebf78cb1c31e8d242f9155"}, - {file = "grpcio-1.68.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:c1245651f3c9ea92a2db4f95d37b7597db6b246d5892bca6ee8c0e90d76fb73c"}, - {file = "grpcio-1.68.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f1931c7aa85be0fa6cea6af388e576f3bf6baee9e5d481c586980c774debcb4"}, - {file = "grpcio-1.68.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b0ff09c81e3aded7a183bc6473639b46b6caa9c1901d6f5e2cba24b95e59e30"}, - {file = "grpcio-1.68.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8c73f9fbbaee1a132487e31585aa83987ddf626426d703ebcb9a528cf231c9b1"}, - {file = "grpcio-1.68.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6b2f98165ea2790ea159393a2246b56f580d24d7da0d0342c18a085299c40a75"}, - {file = "grpcio-1.68.0-cp311-cp311-win32.whl", hash = "sha256:e1e7ed311afb351ff0d0e583a66fcb39675be112d61e7cfd6c8269884a98afbc"}, - {file = "grpcio-1.68.0-cp311-cp311-win_amd64.whl", hash = "sha256:e0d2f68eaa0a755edd9a47d40e50dba6df2bceda66960dee1218da81a2834d27"}, - {file = "grpcio-1.68.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:8af6137cc4ae8e421690d276e7627cfc726d4293f6607acf9ea7260bd8fc3d7d"}, - {file = "grpcio-1.68.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4028b8e9a3bff6f377698587d642e24bd221810c06579a18420a17688e421af7"}, - {file = "grpcio-1.68.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:f60fa2adf281fd73ae3a50677572521edca34ba373a45b457b5ebe87c2d01e1d"}, - {file = "grpcio-1.68.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e18589e747c1e70b60fab6767ff99b2d0c359ea1db8a2cb524477f93cdbedf5b"}, - {file = "grpcio-1.68.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0d30f3fee9372796f54d3100b31ee70972eaadcc87314be369360248a3dcffe"}, - {file = "grpcio-1.68.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:7e0a3e72c0e9a1acab77bef14a73a416630b7fd2cbd893c0a873edc47c42c8cd"}, - {file = "grpcio-1.68.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a831dcc343440969aaa812004685ed322cdb526cd197112d0db303b0da1e8659"}, - {file = "grpcio-1.68.0-cp312-cp312-win32.whl", hash = "sha256:5a180328e92b9a0050958ced34dddcb86fec5a8b332f5a229e353dafc16cd332"}, - {file = "grpcio-1.68.0-cp312-cp312-win_amd64.whl", hash = "sha256:2bddd04a790b69f7a7385f6a112f46ea0b34c4746f361ebafe9ca0be567c78e9"}, - {file = "grpcio-1.68.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:fc05759ffbd7875e0ff2bd877be1438dfe97c9312bbc558c8284a9afa1d0f40e"}, - {file = "grpcio-1.68.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:15fa1fe25d365a13bc6d52fcac0e3ee1f9baebdde2c9b3b2425f8a4979fccea1"}, - {file = "grpcio-1.68.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:32a9cb4686eb2e89d97022ecb9e1606d132f85c444354c17a7dbde4a455e4a3b"}, - {file = "grpcio-1.68.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dba037ff8d284c8e7ea9a510c8ae0f5b016004f13c3648f72411c464b67ff2fb"}, - {file = "grpcio-1.68.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0efbbd849867e0e569af09e165363ade75cf84f5229b2698d53cf22c7a4f9e21"}, - {file = "grpcio-1.68.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:4e300e6978df0b65cc2d100c54e097c10dfc7018b9bd890bbbf08022d47f766d"}, - {file = "grpcio-1.68.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:6f9c7ad1a23e1047f827385f4713b5b8c6c7d325705be1dd3e31fb00dcb2f665"}, - {file = "grpcio-1.68.0-cp313-cp313-win32.whl", hash = "sha256:3ac7f10850fd0487fcce169c3c55509101c3bde2a3b454869639df2176b60a03"}, - {file = "grpcio-1.68.0-cp313-cp313-win_amd64.whl", hash = "sha256:afbf45a62ba85a720491bfe9b2642f8761ff348006f5ef67e4622621f116b04a"}, - {file = "grpcio-1.68.0-cp38-cp38-linux_armv7l.whl", hash = "sha256:f8f695d9576ce836eab27ba7401c60acaf9ef6cf2f70dfe5462055ba3df02cc3"}, - {file = "grpcio-1.68.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:9fe1b141cda52f2ca73e17d2d3c6a9f3f3a0c255c216b50ce616e9dca7e3441d"}, - {file = "grpcio-1.68.0-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:4df81d78fd1646bf94ced4fb4cd0a7fe2e91608089c522ef17bc7db26e64effd"}, - {file = "grpcio-1.68.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:46a2d74d4dd8993151c6cd585594c082abe74112c8e4175ddda4106f2ceb022f"}, - {file = "grpcio-1.68.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a17278d977746472698460c63abf333e1d806bd41f2224f90dbe9460101c9796"}, - {file = "grpcio-1.68.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:15377bce516b1c861c35e18eaa1c280692bf563264836cece693c0f169b48829"}, - {file = "grpcio-1.68.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cc5f0a4f5904b8c25729a0498886b797feb817d1fd3812554ffa39551112c161"}, - {file = "grpcio-1.68.0-cp38-cp38-win32.whl", hash = "sha256:def1a60a111d24376e4b753db39705adbe9483ef4ca4761f825639d884d5da78"}, - {file = "grpcio-1.68.0-cp38-cp38-win_amd64.whl", hash = "sha256:55d3b52fd41ec5772a953612db4e70ae741a6d6ed640c4c89a64f017a1ac02b5"}, - {file = "grpcio-1.68.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:0d230852ba97654453d290e98d6aa61cb48fa5fafb474fb4c4298d8721809354"}, - {file = "grpcio-1.68.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:50992f214264e207e07222703c17d9cfdcc2c46ed5a1ea86843d440148ebbe10"}, - {file = "grpcio-1.68.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:14331e5c27ed3545360464a139ed279aa09db088f6e9502e95ad4bfa852bb116"}, - {file = "grpcio-1.68.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f84890b205692ea813653ece4ac9afa2139eae136e419231b0eec7c39fdbe4c2"}, - {file = "grpcio-1.68.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0cf343c6f4f6aa44863e13ec9ddfe299e0be68f87d68e777328bff785897b05"}, - {file = "grpcio-1.68.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:fd2c2d47969daa0e27eadaf15c13b5e92605c5e5953d23c06d0b5239a2f176d3"}, - {file = "grpcio-1.68.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:18668e36e7f4045820f069997834e94e8275910b1f03e078a6020bd464cb2363"}, - {file = "grpcio-1.68.0-cp39-cp39-win32.whl", hash = "sha256:2af76ab7c427aaa26aa9187c3e3c42f38d3771f91a20f99657d992afada2294a"}, - {file = "grpcio-1.68.0-cp39-cp39-win_amd64.whl", hash = "sha256:e694b5928b7b33ca2d3b4d5f9bf8b5888906f181daff6b406f4938f3a997a490"}, - {file = "grpcio-1.68.0.tar.gz", hash = "sha256:7e7483d39b4a4fddb9906671e9ea21aaad4f031cdfc349fec76bdfa1e404543a"}, + {file = "grpcio-1.68.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:d35740e3f45f60f3c37b1e6f2f4702c23867b9ce21c6410254c9c682237da68d"}, + {file = "grpcio-1.68.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:d99abcd61760ebb34bdff37e5a3ba333c5cc09feda8c1ad42547bea0416ada78"}, + {file = "grpcio-1.68.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:f8261fa2a5f679abeb2a0a93ad056d765cdca1c47745eda3f2d87f874ff4b8c9"}, + {file = "grpcio-1.68.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0feb02205a27caca128627bd1df4ee7212db051019a9afa76f4bb6a1a80ca95e"}, + {file = "grpcio-1.68.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:919d7f18f63bcad3a0f81146188e90274fde800a94e35d42ffe9eadf6a9a6330"}, + {file = "grpcio-1.68.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:963cc8d7d79b12c56008aabd8b457f400952dbea8997dd185f155e2f228db079"}, + {file = "grpcio-1.68.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ccf2ebd2de2d6661e2520dae293298a3803a98ebfc099275f113ce1f6c2a80f1"}, + {file = "grpcio-1.68.1-cp310-cp310-win32.whl", hash = "sha256:2cc1fd04af8399971bcd4f43bd98c22d01029ea2e56e69c34daf2bf8470e47f5"}, + {file = "grpcio-1.68.1-cp310-cp310-win_amd64.whl", hash = "sha256:ee2e743e51cb964b4975de572aa8fb95b633f496f9fcb5e257893df3be854746"}, + {file = "grpcio-1.68.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:55857c71641064f01ff0541a1776bfe04a59db5558e82897d35a7793e525774c"}, + {file = "grpcio-1.68.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4b177f5547f1b995826ef529d2eef89cca2f830dd8b2c99ffd5fde4da734ba73"}, + {file = "grpcio-1.68.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:3522c77d7e6606d6665ec8d50e867f13f946a4e00c7df46768f1c85089eae515"}, + {file = "grpcio-1.68.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9d1fae6bbf0816415b81db1e82fb3bf56f7857273c84dcbe68cbe046e58e1ccd"}, + {file = "grpcio-1.68.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:298ee7f80e26f9483f0b6f94cc0a046caf54400a11b644713bb5b3d8eb387600"}, + {file = "grpcio-1.68.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cbb5780e2e740b6b4f2d208e90453591036ff80c02cc605fea1af8e6fc6b1bbe"}, + {file = "grpcio-1.68.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ddda1aa22495d8acd9dfbafff2866438d12faec4d024ebc2e656784d96328ad0"}, + {file = "grpcio-1.68.1-cp311-cp311-win32.whl", hash = "sha256:b33bd114fa5a83f03ec6b7b262ef9f5cac549d4126f1dc702078767b10c46ed9"}, + {file = "grpcio-1.68.1-cp311-cp311-win_amd64.whl", hash = "sha256:7f20ebec257af55694d8f993e162ddf0d36bd82d4e57f74b31c67b3c6d63d8b2"}, + {file = "grpcio-1.68.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:8829924fffb25386995a31998ccbbeaa7367223e647e0122043dfc485a87c666"}, + {file = "grpcio-1.68.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:3aed6544e4d523cd6b3119b0916cef3d15ef2da51e088211e4d1eb91a6c7f4f1"}, + {file = "grpcio-1.68.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:4efac5481c696d5cb124ff1c119a78bddbfdd13fc499e3bc0ca81e95fc573684"}, + {file = "grpcio-1.68.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ab2d912ca39c51f46baf2a0d92aa265aa96b2443266fc50d234fa88bf877d8e"}, + {file = "grpcio-1.68.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c87ce2a97434dffe7327a4071839ab8e8bffd0054cc74cbe971fba98aedd60"}, + {file = "grpcio-1.68.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e4842e4872ae4ae0f5497bf60a0498fa778c192cc7a9e87877abd2814aca9475"}, + {file = "grpcio-1.68.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:255b1635b0ed81e9f91da4fcc8d43b7ea5520090b9a9ad9340d147066d1d3613"}, + {file = "grpcio-1.68.1-cp312-cp312-win32.whl", hash = "sha256:7dfc914cc31c906297b30463dde0b9be48e36939575eaf2a0a22a8096e69afe5"}, + {file = "grpcio-1.68.1-cp312-cp312-win_amd64.whl", hash = "sha256:a0c8ddabef9c8f41617f213e527254c41e8b96ea9d387c632af878d05db9229c"}, + {file = "grpcio-1.68.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:a47faedc9ea2e7a3b6569795c040aae5895a19dde0c728a48d3c5d7995fda385"}, + {file = "grpcio-1.68.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:390eee4225a661c5cd133c09f5da1ee3c84498dc265fd292a6912b65c421c78c"}, + {file = "grpcio-1.68.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:66a24f3d45c33550703f0abb8b656515b0ab777970fa275693a2f6dc8e35f1c1"}, + {file = "grpcio-1.68.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c08079b4934b0bf0a8847f42c197b1d12cba6495a3d43febd7e99ecd1cdc8d54"}, + {file = "grpcio-1.68.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8720c25cd9ac25dd04ee02b69256d0ce35bf8a0f29e20577427355272230965a"}, + {file = "grpcio-1.68.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:04cfd68bf4f38f5bb959ee2361a7546916bd9a50f78617a346b3aeb2b42e2161"}, + {file = "grpcio-1.68.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c28848761a6520c5c6071d2904a18d339a796ebe6b800adc8b3f474c5ce3c3ad"}, + {file = "grpcio-1.68.1-cp313-cp313-win32.whl", hash = "sha256:77d65165fc35cff6e954e7fd4229e05ec76102d4406d4576528d3a3635fc6172"}, + {file = "grpcio-1.68.1-cp313-cp313-win_amd64.whl", hash = "sha256:a8040f85dcb9830d8bbb033ae66d272614cec6faceee88d37a88a9bd1a7a704e"}, + {file = "grpcio-1.68.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:eeb38ff04ab6e5756a2aef6ad8d94e89bb4a51ef96e20f45c44ba190fa0bcaad"}, + {file = "grpcio-1.68.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8a3869a6661ec8f81d93f4597da50336718bde9eb13267a699ac7e0a1d6d0bea"}, + {file = "grpcio-1.68.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:2c4cec6177bf325eb6faa6bd834d2ff6aa8bb3b29012cceb4937b86f8b74323c"}, + {file = "grpcio-1.68.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12941d533f3cd45d46f202e3667be8ebf6bcb3573629c7ec12c3e211d99cfccf"}, + {file = "grpcio-1.68.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80af6f1e69c5e68a2be529990684abdd31ed6622e988bf18850075c81bb1ad6e"}, + {file = "grpcio-1.68.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e8dbe3e00771bfe3d04feed8210fc6617006d06d9a2679b74605b9fed3e8362c"}, + {file = "grpcio-1.68.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:83bbf5807dc3ee94ce1de2dfe8a356e1d74101e4b9d7aa8c720cc4818a34aded"}, + {file = "grpcio-1.68.1-cp38-cp38-win32.whl", hash = "sha256:8cb620037a2fd9eeee97b4531880e439ebfcd6d7d78f2e7dcc3726428ab5ef63"}, + {file = "grpcio-1.68.1-cp38-cp38-win_amd64.whl", hash = "sha256:52fbf85aa71263380d330f4fce9f013c0798242e31ede05fcee7fbe40ccfc20d"}, + {file = "grpcio-1.68.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:cb400138e73969eb5e0535d1d06cae6a6f7a15f2cc74add320e2130b8179211a"}, + {file = "grpcio-1.68.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a1b988b40f2fd9de5c820f3a701a43339d8dcf2cb2f1ca137e2c02671cc83ac1"}, + {file = "grpcio-1.68.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:96f473cdacfdd506008a5d7579c9f6a7ff245a9ade92c3c0265eb76cc591914f"}, + {file = "grpcio-1.68.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:37ea3be171f3cf3e7b7e412a98b77685eba9d4fd67421f4a34686a63a65d99f9"}, + {file = "grpcio-1.68.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ceb56c4285754e33bb3c2fa777d055e96e6932351a3082ce3559be47f8024f0"}, + {file = "grpcio-1.68.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:dffd29a2961f3263a16d73945b57cd44a8fd0b235740cb14056f0612329b345e"}, + {file = "grpcio-1.68.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:025f790c056815b3bf53da850dd70ebb849fd755a4b1ac822cb65cd631e37d43"}, + {file = "grpcio-1.68.1-cp39-cp39-win32.whl", hash = "sha256:1098f03dedc3b9810810568060dea4ac0822b4062f537b0f53aa015269be0a76"}, + {file = "grpcio-1.68.1-cp39-cp39-win_amd64.whl", hash = "sha256:334ab917792904245a028f10e803fcd5b6f36a7b2173a820c0b5b076555825e1"}, + {file = "grpcio-1.68.1.tar.gz", hash = "sha256:44a8502dd5de653ae6a73e2de50a401d84184f0331d0ac3daeb044e66d5c5054"}, ] [package.extras] -protobuf = ["grpcio-tools (>=1.68.0)"] +protobuf = ["grpcio-tools (>=1.68.1)"] [[package]] name = "h11" @@ -585,13 +577,13 @@ trio = ["trio (>=0.22.0,<1.0)"] [[package]] name = "httpx" -version = "0.27.2" +version = "0.28.1" 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.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, ] [package.dependencies] @@ -599,7 +591,6 @@ anyio = "*" certifi = "*" httpcore = "==1.*" idna = "*" -sniffio = "*" [package.extras] brotli = ["brotli", "brotlicffi"] @@ -667,20 +658,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 +910,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 +1228,58 @@ files = [ {file = "mutagen-1.47.0.tar.gz", hash = "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99"}, ] +[[package]] +name = "mypy" +version = "1.14.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e971c1c667007f9f2b397ffa80fa8e1e0adccff336e5e77e74cb5f22868bee87"}, + {file = "mypy-1.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e86aaeaa3221a278c66d3d673b297232947d873773d61ca3ee0e28b2ff027179"}, + {file = "mypy-1.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1628c5c3ce823d296e41e2984ff88c5861499041cb416a8809615d0c1f41740e"}, + {file = "mypy-1.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7fadb29b77fc14a0dd81304ed73c828c3e5cde0016c7e668a86a3e0dfc9f3af3"}, + {file = "mypy-1.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:3fa76988dc760da377c1e5069200a50d9eaaccf34f4ea18428a3337034ab5a44"}, + {file = "mypy-1.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6e73c8a154eed31db3445fe28f63ad2d97b674b911c00191416cf7f6459fd49a"}, + {file = "mypy-1.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:273e70fcb2e38c5405a188425aa60b984ffdcef65d6c746ea5813024b68c73dc"}, + {file = "mypy-1.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1daca283d732943731a6a9f20fdbcaa927f160bc51602b1d4ef880a6fb252015"}, + {file = "mypy-1.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7e68047bedb04c1c25bba9901ea46ff60d5eaac2d71b1f2161f33107e2b368eb"}, + {file = "mypy-1.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:7a52f26b9c9b1664a60d87675f3bae00b5c7f2806e0c2800545a32c325920bcc"}, + {file = "mypy-1.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d5326ab70a6db8e856d59ad4cb72741124950cbbf32e7b70e30166ba7bbf61dd"}, + {file = "mypy-1.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bf4ec4980bec1e0e24e5075f449d014011527ae0055884c7e3abc6a99cd2c7f1"}, + {file = "mypy-1.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:390dfb898239c25289495500f12fa73aa7f24a4c6d90ccdc165762462b998d63"}, + {file = "mypy-1.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e026d55ddcd76e29e87865c08cbe2d0104e2b3153a523c529de584759379d3d"}, + {file = "mypy-1.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:585ed36031d0b3ee362e5107ef449a8b5dfd4e9c90ccbe36414ee405ee6b32ba"}, + {file = "mypy-1.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9f6f4c0b27401d14c483c622bc5105eff3911634d576bbdf6695b9a7c1ba741"}, + {file = "mypy-1.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b2280cedcb312c7a79f5001ae5325582d0d339bce684e4a529069d0e7ca1e7"}, + {file = "mypy-1.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:342de51c48bab326bfc77ce056ba08c076d82ce4f5a86621f972ed39970f94d8"}, + {file = "mypy-1.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00df23b42e533e02a6f0055e54de9a6ed491cd8b7ea738647364fd3a39ea7efc"}, + {file = "mypy-1.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e8c8387e5d9dff80e7daf961df357c80e694e942d9755f3ad77d69b0957b8e3f"}, + {file = "mypy-1.14.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b16738b1d80ec4334654e89e798eb705ac0c36c8a5c4798496cd3623aa02286"}, + {file = "mypy-1.14.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:10065fcebb7c66df04b05fc799a854b1ae24d9963c8bb27e9064a9bdb43aa8ad"}, + {file = "mypy-1.14.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbb7d683fa6bdecaa106e8368aa973ecc0ddb79a9eaeb4b821591ecd07e9e03c"}, + {file = "mypy-1.14.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3498cb55448dc5533e438cd13d6ddd28654559c8c4d1fd4b5ca57a31b81bac01"}, + {file = "mypy-1.14.0-cp38-cp38-win_amd64.whl", hash = "sha256:c7b243408ea43755f3a21a0a08e5c5ae30eddb4c58a80f415ca6b118816e60aa"}, + {file = "mypy-1.14.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:14117b9da3305b39860d0aa34b8f1ff74d209a368829a584eb77524389a9c13e"}, + {file = "mypy-1.14.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af98c5a958f9c37404bd4eef2f920b94874507e146ed6ee559f185b8809c44cc"}, + {file = "mypy-1.14.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0b343a1d3989547024377c2ba0dca9c74a2428ad6ed24283c213af8dbb0710b"}, + {file = "mypy-1.14.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cdb5563c1726c85fb201be383168f8c866032db95e1095600806625b3a648cb7"}, + {file = "mypy-1.14.0-cp39-cp39-win_amd64.whl", hash = "sha256:74e925649c1ee0a79aa7448baf2668d81cc287dc5782cff6a04ee93f40fb8d3f"}, + {file = "mypy-1.14.0-py3-none-any.whl", hash = "sha256:2238d7f93fc4027ed1efc944507683df3ba406445a2b6c96e79666a045aadfab"}, + {file = "mypy-1.14.0.tar.gz", hash = "sha256:822dbd184d4a9804df5a7d5335a68cf7662930e70b8c1bc976645d1509f9a9d6"}, +] + +[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" @@ -1265,13 +1308,13 @@ pytz = ">=2024.2.0" [[package]] name = "opentelemetry-api" -version = "1.28.2" +version = "1.29.0" description = "OpenTelemetry Python API" optional = false python-versions = ">=3.8" files = [ - {file = "opentelemetry_api-1.28.2-py3-none-any.whl", hash = "sha256:6fcec89e265beb258fe6b1acaaa3c8c705a934bd977b9f534a2b7c0d2d4275a6"}, - {file = "opentelemetry_api-1.28.2.tar.gz", hash = "sha256:ecdc70c7139f17f9b0cf3742d57d7020e3e8315d6cffcdf1a12a905d45b19cc0"}, + {file = "opentelemetry_api-1.29.0-py3-none-any.whl", hash = "sha256:5fcd94c4141cc49c736271f3e1efb777bebe9cc535759c54c936cca4f1b312b8"}, + {file = "opentelemetry_api-1.29.0.tar.gz", hash = "sha256:d04a6cf78aad09614f52964ecb38021e248f5714dc32c2e0d8fd99517b4d69cf"}, ] [package.dependencies] @@ -1280,42 +1323,42 @@ importlib-metadata = ">=6.0,<=8.5.0" [[package]] name = "opentelemetry-exporter-otlp" -version = "1.28.2" +version = "1.29.0" description = "OpenTelemetry Collector Exporters" optional = false python-versions = ">=3.8" files = [ - {file = "opentelemetry_exporter_otlp-1.28.2-py3-none-any.whl", hash = "sha256:b50f6d4a80e6bcd329e36f360ac486ecfa106ea704d6226ceea05d3a48455f70"}, - {file = "opentelemetry_exporter_otlp-1.28.2.tar.gz", hash = "sha256:45f8d7fe4cdd41526464b542ce91b1fd1ae661be92d2c6cba71a3d948b2bdf70"}, + {file = "opentelemetry_exporter_otlp-1.29.0-py3-none-any.whl", hash = "sha256:b8da6e20f5b0ffe604154b1e16a407eade17ce310c42fb85bb4e1246fc3688ad"}, + {file = "opentelemetry_exporter_otlp-1.29.0.tar.gz", hash = "sha256:ee7dfcccbb5e87ad9b389908452e10b7beeab55f70a83f41ce5b8c4efbde6544"}, ] [package.dependencies] -opentelemetry-exporter-otlp-proto-grpc = "1.28.2" -opentelemetry-exporter-otlp-proto-http = "1.28.2" +opentelemetry-exporter-otlp-proto-grpc = "1.29.0" +opentelemetry-exporter-otlp-proto-http = "1.29.0" [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.28.2" +version = "1.29.0" description = "OpenTelemetry Protobuf encoding" optional = false python-versions = ">=3.8" files = [ - {file = "opentelemetry_exporter_otlp_proto_common-1.28.2-py3-none-any.whl", hash = "sha256:545b1943b574f666c35b3d6cc67cb0b111060727e93a1e2866e346b33bff2a12"}, - {file = "opentelemetry_exporter_otlp_proto_common-1.28.2.tar.gz", hash = "sha256:7aebaa5fc9ff6029374546df1f3a62616fda07fccd9c6a8b7892ec130dd8baca"}, + {file = "opentelemetry_exporter_otlp_proto_common-1.29.0-py3-none-any.whl", hash = "sha256:a9d7376c06b4da9cf350677bcddb9618ed4b8255c3f6476975f5e38274ecd3aa"}, + {file = "opentelemetry_exporter_otlp_proto_common-1.29.0.tar.gz", hash = "sha256:e7c39b5dbd1b78fe199e40ddfe477e6983cb61aa74ba836df09c3869a3e3e163"}, ] [package.dependencies] -opentelemetry-proto = "1.28.2" +opentelemetry-proto = "1.29.0" [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.28.2" +version = "1.29.0" description = "OpenTelemetry Collector Protobuf over gRPC Exporter" optional = false python-versions = ">=3.8" files = [ - {file = "opentelemetry_exporter_otlp_proto_grpc-1.28.2-py3-none-any.whl", hash = "sha256:6083d9300863aab35bfce7c172d5fc1007686e6f8dff366eae460cd9a21592e2"}, - {file = "opentelemetry_exporter_otlp_proto_grpc-1.28.2.tar.gz", hash = "sha256:07c10378380bbb01a7f621a5ce833fc1fab816e971140cd3ea1cd587840bc0e6"}, + {file = "opentelemetry_exporter_otlp_proto_grpc-1.29.0-py3-none-any.whl", hash = "sha256:5a2a3a741a2543ed162676cf3eefc2b4150e6f4f0a193187afb0d0e65039c69c"}, + {file = "opentelemetry_exporter_otlp_proto_grpc-1.29.0.tar.gz", hash = "sha256:3d324d07d64574d72ed178698de3d717f62a059a93b6b7685ee3e303384e73ea"}, ] [package.dependencies] @@ -1323,39 +1366,39 @@ deprecated = ">=1.2.6" googleapis-common-protos = ">=1.52,<2.0" grpcio = ">=1.63.2,<2.0.0" opentelemetry-api = ">=1.15,<2.0" -opentelemetry-exporter-otlp-proto-common = "1.28.2" -opentelemetry-proto = "1.28.2" -opentelemetry-sdk = ">=1.28.2,<1.29.0" +opentelemetry-exporter-otlp-proto-common = "1.29.0" +opentelemetry-proto = "1.29.0" +opentelemetry-sdk = ">=1.29.0,<1.30.0" [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.28.2" +version = "1.29.0" description = "OpenTelemetry Collector Protobuf over HTTP Exporter" optional = false python-versions = ">=3.8" files = [ - {file = "opentelemetry_exporter_otlp_proto_http-1.28.2-py3-none-any.whl", hash = "sha256:af921c18212a56ef4be68458ba475791c0517ebfd8a2ff04669c9cd477d90ff2"}, - {file = "opentelemetry_exporter_otlp_proto_http-1.28.2.tar.gz", hash = "sha256:d9b353d67217f091aaf4cfe8693c170973bb3e90a558992570d97020618fda79"}, + {file = "opentelemetry_exporter_otlp_proto_http-1.29.0-py3-none-any.whl", hash = "sha256:b228bdc0f0cfab82eeea834a7f0ffdd2a258b26aa33d89fb426c29e8e934d9d0"}, + {file = "opentelemetry_exporter_otlp_proto_http-1.29.0.tar.gz", hash = "sha256:b10d174e3189716f49d386d66361fbcf6f2b9ad81e05404acdee3f65c8214204"}, ] [package.dependencies] deprecated = ">=1.2.6" googleapis-common-protos = ">=1.52,<2.0" opentelemetry-api = ">=1.15,<2.0" -opentelemetry-exporter-otlp-proto-common = "1.28.2" -opentelemetry-proto = "1.28.2" -opentelemetry-sdk = ">=1.28.2,<1.29.0" +opentelemetry-exporter-otlp-proto-common = "1.29.0" +opentelemetry-proto = "1.29.0" +opentelemetry-sdk = ">=1.29.0,<1.30.0" requests = ">=2.7,<3.0" [[package]] name = "opentelemetry-proto" -version = "1.28.2" +version = "1.29.0" description = "OpenTelemetry Python Proto" optional = false python-versions = ">=3.8" files = [ - {file = "opentelemetry_proto-1.28.2-py3-none-any.whl", hash = "sha256:0837498f59db55086462915e5898d0b1a18c1392f6db4d7e937143072a72370c"}, - {file = "opentelemetry_proto-1.28.2.tar.gz", hash = "sha256:7c0d125a6b71af88bfeeda16bfdd0ff63dc2cf0039baf6f49fa133b203e3f566"}, + {file = "opentelemetry_proto-1.29.0-py3-none-any.whl", hash = "sha256:495069c6f5495cbf732501cdcd3b7f60fda2b9d3d4255706ca99b7ca8dec53ff"}, + {file = "opentelemetry_proto-1.29.0.tar.gz", hash = "sha256:3c136aa293782e9b44978c738fff72877a4b78b5d21a64e879898db7b2d93e5d"}, ] [package.dependencies] @@ -1363,34 +1406,34 @@ protobuf = ">=5.0,<6.0" [[package]] name = "opentelemetry-sdk" -version = "1.28.2" +version = "1.29.0" description = "OpenTelemetry Python SDK" optional = false python-versions = ">=3.8" files = [ - {file = "opentelemetry_sdk-1.28.2-py3-none-any.whl", hash = "sha256:93336c129556f1e3ccd21442b94d3521759541521861b2214c499571b85cb71b"}, - {file = "opentelemetry_sdk-1.28.2.tar.gz", hash = "sha256:5fed24c5497e10df30282456fe2910f83377797511de07d14cec0d3e0a1a3110"}, + {file = "opentelemetry_sdk-1.29.0-py3-none-any.whl", hash = "sha256:173be3b5d3f8f7d671f20ea37056710217959e774e2749d984355d1f9391a30a"}, + {file = "opentelemetry_sdk-1.29.0.tar.gz", hash = "sha256:b0787ce6aade6ab84315302e72bd7a7f2f014b0fb1b7c3295b88afe014ed0643"}, ] [package.dependencies] -opentelemetry-api = "1.28.2" -opentelemetry-semantic-conventions = "0.49b2" +opentelemetry-api = "1.29.0" +opentelemetry-semantic-conventions = "0.50b0" typing-extensions = ">=3.7.4" [[package]] name = "opentelemetry-semantic-conventions" -version = "0.49b2" +version = "0.50b0" description = "OpenTelemetry Semantic Conventions" optional = false python-versions = ">=3.8" files = [ - {file = "opentelemetry_semantic_conventions-0.49b2-py3-none-any.whl", hash = "sha256:51e7e1d0daa958782b6c2a8ed05e5f0e7dd0716fc327ac058777b8659649ee54"}, - {file = "opentelemetry_semantic_conventions-0.49b2.tar.gz", hash = "sha256:44e32ce6a5bb8d7c0c617f84b9dc1c8deda1045a07dc16a688cc7cbeab679997"}, + {file = "opentelemetry_semantic_conventions-0.50b0-py3-none-any.whl", hash = "sha256:e87efba8fdb67fb38113efea6a349531e75ed7ffc01562f65b802fcecb5e115e"}, + {file = "opentelemetry_semantic_conventions-0.50b0.tar.gz", hash = "sha256:02dc6dbcb62f082de9b877ff19a3f1ffaa3c306300fa53bfac761c4567c83d38"}, ] [package.dependencies] deprecated = ">=1.2.6" -opentelemetry-api = "1.28.2" +opentelemetry-api = "1.29.0" [[package]] name = "packaging" @@ -1480,41 +1523,24 @@ testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", [[package]] name = "protobuf" -version = "5.28.3" +version = "5.29.2" 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"}, -] - -[[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"}, + {file = "protobuf-5.29.2-cp310-abi3-win32.whl", hash = "sha256:c12ba8249f5624300cf51c3d0bfe5be71a60c63e4dcf51ffe9a68771d958c851"}, + {file = "protobuf-5.29.2-cp310-abi3-win_amd64.whl", hash = "sha256:842de6d9241134a973aab719ab42b008a18a90f9f07f06ba480df268f86432f9"}, + {file = "protobuf-5.29.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a0c53d78383c851bfa97eb42e3703aefdc96d2036a41482ffd55dc5f529466eb"}, + {file = "protobuf-5.29.2-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:494229ecd8c9009dd71eda5fd57528395d1eacdf307dbece6c12ad0dd09e912e"}, + {file = "protobuf-5.29.2-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:b6b0d416bbbb9d4fbf9d0561dbfc4e324fd522f61f7af0fe0f282ab67b22477e"}, + {file = "protobuf-5.29.2-cp38-cp38-win32.whl", hash = "sha256:e621a98c0201a7c8afe89d9646859859be97cb22b8bf1d8eacfd90d5bda2eb19"}, + {file = "protobuf-5.29.2-cp38-cp38-win_amd64.whl", hash = "sha256:13d6d617a2a9e0e82a88113d7191a1baa1e42c2cc6f5f1398d3b054c8e7e714a"}, + {file = "protobuf-5.29.2-cp39-cp39-win32.whl", hash = "sha256:36000f97ea1e76e8398a3f02936aac2a5d2b111aae9920ec1b769fc4a222c4d9"}, + {file = "protobuf-5.29.2-cp39-cp39-win_amd64.whl", hash = "sha256:2d2e674c58a06311c8e99e74be43e7f3a8d1e2b2fdf845eaa347fbd866f23355"}, + {file = "protobuf-5.29.2-py3-none-any.whl", hash = "sha256:fde4554c0e578a5a0bcc9a276339594848d1e89f9ea47b4427c80e5d72f90181"}, + {file = "protobuf-5.29.2.tar.gz", hash = "sha256:b2cc8e8bb7c9326996f0e160137b0861f1a82162502658df2951209d0cb0309e"}, ] -[package.dependencies] -snowballstemmer = ">=2.2.0" - -[package.extras] -toml = ["tomli (>=1.2.3)"] - [[package]] name = "pygments" version = "2.18.0" @@ -1586,22 +1612,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" @@ -1939,13 +1982,13 @@ type = ["importlib_metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (>=1.12 [[package]] name = "six" -version = "1.16.0" +version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] [[package]] @@ -1959,17 +2002,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 +2032,31 @@ files = [ {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, ] +[[package]] +name = "types-pytz" +version = "2024.2.0.20241221" +description = "Typing stubs for pytz" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types_pytz-2024.2.0.20241221-py3-none-any.whl", hash = "sha256:8fc03195329c43637ed4f593663df721fef919b60a969066e22606edf0b53ad5"}, + {file = "types_pytz-2024.2.0.20241221.tar.gz", hash = "sha256:06d7cde9613e9f7504766a0554a270c369434b50e00975b3a4a0f6eed0f2c1a9"}, +] + +[[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 +2268,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "7cb169b898f67ecba87b43011ec4d436826720858f1b26b1fb5adf3eaaa70912" +content-hash = "80537893b0251bde1d973d2372748107e5707ff8f9fb5633e3dcc1b4fdcb0bfb" diff --git a/pyproject.toml b/pyproject.toml index 865ba42d..de061084 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.5" 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.4" +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)