diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 8f9a8d7c..8b78c1bc 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,30 +1,18 @@
 repos:
-  - repo: https://github.com/asottile/pyupgrade
-    rev: v2.31.1
-    hooks:
-      - id: pyupgrade
-        args:
-          - --py311-plus
-  - repo: https://github.com/charliermarsh/ruff-pre-commit
-    # Ruff version.
-    rev: "v0.0.275"
+  - repo: https://github.com/astral-sh/ruff-pre-commit
+    rev: 'v0.3.2'
     hooks:
       - id: ruff
         args: [--fix, --exit-non-zero-on-fix]
   - repo: local
     hooks:
-      - id: isort
-        name: isort
-        language: system
-        entry: isort
-        types: [python]
       - id: black
         name: black
         language: system
         entry: black
         types: [python]
   - 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..fbc499ad 100644
--- a/docs/gen_ref_pages.py
+++ b/docs/gen_ref_pages.py
@@ -16,9 +16,7 @@
 
     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:
@@ -30,6 +28,8 @@
 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..35735cb0 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, Iterable, 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,13 @@
 from werkzeug.routing import Map, Rule
 from werkzeug.wrappers import Request, Response
 
+if TYPE_CHECKING:  # pragma: no cover
+    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 +36,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 +65,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 +102,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 +121,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 +134,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..7d96e2f7 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 <song> 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 <song> 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
+            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,19 +331,20 @@ 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
 
@@ -298,7 +354,8 @@ def handle_id(self, saemubox_id: int, event: CloudEvent = None):
 
         return False
 
-    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 +364,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..67c8f274 100644
--- a/nowplaying/otel.py
+++ b/nowplaying/otel.py
@@ -6,10 +6,13 @@
 import logging
 import os
 from datetime import datetime
+from pathlib import Path
+from typing import Self, no_type_check
 
 from opentelemetry._logs import set_logger_provider
 from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
 from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
+from opentelemetry.sdk._logs._internal import LogRecord
 from opentelemetry.sdk._logs.export import (
     BatchLogRecordProcessor,
     ConsoleLogExporter,
@@ -18,9 +21,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 +36,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 +58,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..273ed565 100644
--- a/nowplaying/show/client.py
+++ b/nowplaying/show/client.py
@@ -1,8 +1,13 @@
+"""Show client interacts with LibreTime."""
+
+from __future__ import annotations
+
 import datetime
 import logging
 import logging.handlers
 import re
 from html.entities import entitydefs
+from typing import Match, Self
 
 import pytz
 import requests
@@ -11,12 +16,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 +36,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 +52,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 +61,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 +70,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 +100,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 +110,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 +128,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 +144,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
+                "Show end time (%s) is in the past" % self.show.endtime,
             )
 
         # get the show's URL
@@ -150,15 +160,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 +178,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 +188,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..07a0cac2 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")
@@ -76,13 +98,17 @@ def __init__(
             if not self.mount:
                 raise ValueError("Missing required parameter mount for %s" % url)
 
-    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..5a857d4c 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
 
-        E = lxml.builder.ElementMaker(
+        E = lxml.builder.ElementMaker(  # noqa: N806
             namespace=MAIN_NAMESPACE,
             nsmap={None: MAIN_NAMESPACE, "xlink": XLINK_NAMESPACE},
         )
@@ -80,10 +99,10 @@ def track_started(self, track):
                 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..4dc38958 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,69 @@ 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):
+    def has_default_artist(self: Self) -> bool:
+        """Return True if Track has default artist."""
         if self.artist == DEFAULT_ARTIST:
             return True
 
         return False
 
-    def has_default_title(self):
+    def has_default_title(self: Self) -> bool:
+        """Return True if Track has default title."""
         if self.title == DEFAULT_TITLE:
             return True
 
         return False
 
-    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..3c56e459 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,10 +29,14 @@ 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
@@ -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 f47d034d..dcfa0bb5 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,25 +1,14 @@
 # This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
 
-[[package]]
-name = "annotated-types"
-version = "0.6.0"
-description = "Reusable constraint types to use with typing.Annotated"
-optional = false
-python-versions = ">=3.8"
-files = [
-    {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"},
-    {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"},
-]
-
 [[package]]
 name = "anyio"
-version = "4.2.0"
+version = "4.3.0"
 description = "High level compatibility layer for multiple asynchronous event loop implementations"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "anyio-4.2.0-py3-none-any.whl", hash = "sha256:745843b39e829e108e518c489b31dc757de7d2131d53fac32bd8df268227bfee"},
-    {file = "anyio-4.2.0.tar.gz", hash = "sha256:e1875bb4b4e2de1669f4bc7869b6d3f54231cdced71605e6e64c9be77e3be50f"},
+    {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"},
+    {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"},
 ]
 
 [package.dependencies]
@@ -31,6 +20,25 @@ doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphin
 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", "uvloop (>=0.17)"]
 trio = ["trio (>=0.23)"]
 
+[[package]]
+name = "attrs"
+version = "23.2.0"
+description = "Classes Without Boilerplate"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"},
+    {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"},
+]
+
+[package.extras]
+cov = ["attrs[tests]", "coverage[toml] (>=5.3)"]
+dev = ["attrs[tests]", "pre-commit"]
+docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"]
+tests = ["attrs[tests-no-zope]", "zope-interface"]
+tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"]
+tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"]
+
 [[package]]
 name = "autocommand"
 version = "2.2.2"
@@ -57,16 +65,20 @@ files = [
 dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"]
 
 [[package]]
-name = "backoff"
-version = "2.2.1"
-description = "Function decoration for backoff and retry"
+name = "backports-tarfile"
+version = "1.0.0"
+description = "Backport of CPython tarfile module"
 optional = false
-python-versions = ">=3.7,<4.0"
+python-versions = ">=3.8"
 files = [
-    {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"},
-    {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"},
+    {file = "backports.tarfile-1.0.0-py3-none-any.whl", hash = "sha256:bcd36290d9684beb524d3fe74f4a2db056824c47746583f090b8e55daf0776e4"},
+    {file = "backports.tarfile-1.0.0.tar.gz", hash = "sha256:2688f159c21afd56a07b75f01306f9f52c79aebcc5f4a117fb8fbb4445352c75"},
 ]
 
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9.3)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)"]
+
 [[package]]
 name = "black"
 version = "24.3.0"
@@ -345,63 +357,63 @@ yaml = ["PyYAML"]
 
 [[package]]
 name = "coverage"
-version = "7.4.1"
+version = "7.4.4"
 description = "Code coverage measurement for Python"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "coverage-7.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:077d366e724f24fc02dbfe9d946534357fda71af9764ff99d73c3c596001bbd7"},
-    {file = "coverage-7.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0193657651f5399d433c92f8ae264aff31fc1d066deee4b831549526433f3f61"},
-    {file = "coverage-7.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d17bbc946f52ca67adf72a5ee783cd7cd3477f8f8796f59b4974a9b59cacc9ee"},
-    {file = "coverage-7.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3277f5fa7483c927fe3a7b017b39351610265308f5267ac6d4c2b64cc1d8d25"},
-    {file = "coverage-7.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dceb61d40cbfcf45f51e59933c784a50846dc03211054bd76b421a713dcdf19"},
-    {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6008adeca04a445ea6ef31b2cbaf1d01d02986047606f7da266629afee982630"},
-    {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c61f66d93d712f6e03369b6a7769233bfda880b12f417eefdd4f16d1deb2fc4c"},
-    {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b9bb62fac84d5f2ff523304e59e5c439955fb3b7f44e3d7b2085184db74d733b"},
-    {file = "coverage-7.4.1-cp310-cp310-win32.whl", hash = "sha256:f86f368e1c7ce897bf2457b9eb61169a44e2ef797099fb5728482b8d69f3f016"},
-    {file = "coverage-7.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:869b5046d41abfea3e381dd143407b0d29b8282a904a19cb908fa24d090cc018"},
-    {file = "coverage-7.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8ffb498a83d7e0305968289441914154fb0ef5d8b3157df02a90c6695978295"},
-    {file = "coverage-7.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3cacfaefe6089d477264001f90f55b7881ba615953414999c46cc9713ff93c8c"},
-    {file = "coverage-7.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d6850e6e36e332d5511a48a251790ddc545e16e8beaf046c03985c69ccb2676"},
-    {file = "coverage-7.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18e961aa13b6d47f758cc5879383d27b5b3f3dcd9ce8cdbfdc2571fe86feb4dd"},
-    {file = "coverage-7.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dfd1e1b9f0898817babf840b77ce9fe655ecbe8b1b327983df485b30df8cc011"},
-    {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6b00e21f86598b6330f0019b40fb397e705135040dbedc2ca9a93c7441178e74"},
-    {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:536d609c6963c50055bab766d9951b6c394759190d03311f3e9fcf194ca909e1"},
-    {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7ac8f8eb153724f84885a1374999b7e45734bf93a87d8df1e7ce2146860edef6"},
-    {file = "coverage-7.4.1-cp311-cp311-win32.whl", hash = "sha256:f3771b23bb3675a06f5d885c3630b1d01ea6cac9e84a01aaf5508706dba546c5"},
-    {file = "coverage-7.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:9d2f9d4cc2a53b38cabc2d6d80f7f9b7e3da26b2f53d48f05876fef7956b6968"},
-    {file = "coverage-7.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f68ef3660677e6624c8cace943e4765545f8191313a07288a53d3da188bd8581"},
-    {file = "coverage-7.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23b27b8a698e749b61809fb637eb98ebf0e505710ec46a8aa6f1be7dc0dc43a6"},
-    {file = "coverage-7.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e3424c554391dc9ef4a92ad28665756566a28fecf47308f91841f6c49288e66"},
-    {file = "coverage-7.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0860a348bf7004c812c8368d1fc7f77fe8e4c095d661a579196a9533778e156"},
-    {file = "coverage-7.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe558371c1bdf3b8fa03e097c523fb9645b8730399c14fe7721ee9c9e2a545d3"},
-    {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3468cc8720402af37b6c6e7e2a9cdb9f6c16c728638a2ebc768ba1ef6f26c3a1"},
-    {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:02f2edb575d62172aa28fe00efe821ae31f25dc3d589055b3fb64d51e52e4ab1"},
-    {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ca6e61dc52f601d1d224526360cdeab0d0712ec104a2ce6cc5ccef6ed9a233bc"},
-    {file = "coverage-7.4.1-cp312-cp312-win32.whl", hash = "sha256:ca7b26a5e456a843b9b6683eada193fc1f65c761b3a473941efe5a291f604c74"},
-    {file = "coverage-7.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:85ccc5fa54c2ed64bd91ed3b4a627b9cce04646a659512a051fa82a92c04a448"},
-    {file = "coverage-7.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8bdb0285a0202888d19ec6b6d23d5990410decb932b709f2b0dfe216d031d218"},
-    {file = "coverage-7.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:918440dea04521f499721c039863ef95433314b1db00ff826a02580c1f503e45"},
-    {file = "coverage-7.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:379d4c7abad5afbe9d88cc31ea8ca262296480a86af945b08214eb1a556a3e4d"},
-    {file = "coverage-7.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b094116f0b6155e36a304ff912f89bbb5067157aff5f94060ff20bbabdc8da06"},
-    {file = "coverage-7.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2f5968608b1fe2a1d00d01ad1017ee27efd99b3437e08b83ded9b7af3f6f766"},
-    {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:10e88e7f41e6197ea0429ae18f21ff521d4f4490aa33048f6c6f94c6045a6a75"},
-    {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a4a3907011d39dbc3e37bdc5df0a8c93853c369039b59efa33a7b6669de04c60"},
-    {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6d224f0c4c9c98290a6990259073f496fcec1b5cc613eecbd22786d398ded3ad"},
-    {file = "coverage-7.4.1-cp38-cp38-win32.whl", hash = "sha256:23f5881362dcb0e1a92b84b3c2809bdc90db892332daab81ad8f642d8ed55042"},
-    {file = "coverage-7.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:a07f61fc452c43cd5328b392e52555f7d1952400a1ad09086c4a8addccbd138d"},
-    {file = "coverage-7.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e738a492b6221f8dcf281b67129510835461132b03024830ac0e554311a5c54"},
-    {file = "coverage-7.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46342fed0fff72efcda77040b14728049200cbba1279e0bf1188f1f2078c1d70"},
-    {file = "coverage-7.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9641e21670c68c7e57d2053ddf6c443e4f0a6e18e547e86af3fad0795414a628"},
-    {file = "coverage-7.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aeb2c2688ed93b027eb0d26aa188ada34acb22dceea256d76390eea135083950"},
-    {file = "coverage-7.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d12c923757de24e4e2110cf8832d83a886a4cf215c6e61ed506006872b43a6d1"},
-    {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0491275c3b9971cdbd28a4595c2cb5838f08036bca31765bad5e17edf900b2c7"},
-    {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8dfc5e195bbef80aabd81596ef52a1277ee7143fe419efc3c4d8ba2754671756"},
-    {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1a78b656a4d12b0490ca72651fe4d9f5e07e3c6461063a9b6265ee45eb2bdd35"},
-    {file = "coverage-7.4.1-cp39-cp39-win32.whl", hash = "sha256:f90515974b39f4dea2f27c0959688621b46d96d5a626cf9c53dbc653a895c05c"},
-    {file = "coverage-7.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:64e723ca82a84053dd7bfcc986bdb34af8d9da83c521c19d6b472bc6880e191a"},
-    {file = "coverage-7.4.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:32a8d985462e37cfdab611a6f95b09d7c091d07668fdc26e47a725ee575fe166"},
-    {file = "coverage-7.4.1.tar.gz", hash = "sha256:1ed4b95480952b1a26d863e546fa5094564aa0065e1e5f0d4d0041f293251d04"},
+    {file = "coverage-7.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0be5efd5127542ef31f165de269f77560d6cdef525fffa446de6f7e9186cfb2"},
+    {file = "coverage-7.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ccd341521be3d1b3daeb41960ae94a5e87abe2f46f17224ba5d6f2b8398016cf"},
+    {file = "coverage-7.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fa497a8ab37784fbb20ab699c246053ac294d13fc7eb40ec007a5043ec91f8"},
+    {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1a93009cb80730c9bca5d6d4665494b725b6e8e157c1cb7f2db5b4b122ea562"},
+    {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:690db6517f09336559dc0b5f55342df62370a48f5469fabf502db2c6d1cffcd2"},
+    {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:09c3255458533cb76ef55da8cc49ffab9e33f083739c8bd4f58e79fecfe288f7"},
+    {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8ce1415194b4a6bd0cdcc3a1dfbf58b63f910dcb7330fe15bdff542c56949f87"},
+    {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b91cbc4b195444e7e258ba27ac33769c41b94967919f10037e6355e998af255c"},
+    {file = "coverage-7.4.4-cp310-cp310-win32.whl", hash = "sha256:598825b51b81c808cb6f078dcb972f96af96b078faa47af7dfcdf282835baa8d"},
+    {file = "coverage-7.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:09ef9199ed6653989ebbcaacc9b62b514bb63ea2f90256e71fea3ed74bd8ff6f"},
+    {file = "coverage-7.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f9f50e7ef2a71e2fae92774c99170eb8304e3fdf9c8c3c7ae9bab3e7229c5cf"},
+    {file = "coverage-7.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:623512f8ba53c422fcfb2ce68362c97945095b864cda94a92edbaf5994201083"},
+    {file = "coverage-7.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0513b9508b93da4e1716744ef6ebc507aff016ba115ffe8ecff744d1322a7b63"},
+    {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40209e141059b9370a2657c9b15607815359ab3ef9918f0196b6fccce8d3230f"},
+    {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a2b2b78c78293782fd3767d53e6474582f62443d0504b1554370bde86cc8227"},
+    {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:73bfb9c09951125d06ee473bed216e2c3742f530fc5acc1383883125de76d9cd"},
+    {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1f384c3cc76aeedce208643697fb3e8437604b512255de6d18dae3f27655a384"},
+    {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:54eb8d1bf7cacfbf2a3186019bcf01d11c666bd495ed18717162f7eb1e9dd00b"},
+    {file = "coverage-7.4.4-cp311-cp311-win32.whl", hash = "sha256:cac99918c7bba15302a2d81f0312c08054a3359eaa1929c7e4b26ebe41e9b286"},
+    {file = "coverage-7.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:b14706df8b2de49869ae03a5ccbc211f4041750cd4a66f698df89d44f4bd30ec"},
+    {file = "coverage-7.4.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:201bef2eea65e0e9c56343115ba3814e896afe6d36ffd37bab783261db430f76"},
+    {file = "coverage-7.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41c9c5f3de16b903b610d09650e5e27adbfa7f500302718c9ffd1c12cf9d6818"},
+    {file = "coverage-7.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d898fe162d26929b5960e4e138651f7427048e72c853607f2b200909794ed978"},
+    {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ea79bb50e805cd6ac058dfa3b5c8f6c040cb87fe83de10845857f5535d1db70"},
+    {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce4b94265ca988c3f8e479e741693d143026632672e3ff924f25fab50518dd51"},
+    {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:00838a35b882694afda09f85e469c96367daa3f3f2b097d846a7216993d37f4c"},
+    {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48"},
+    {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:69eb372f7e2ece89f14751fbcbe470295d73ed41ecd37ca36ed2eb47512a6ab9"},
+    {file = "coverage-7.4.4-cp312-cp312-win32.whl", hash = "sha256:137eb07173141545e07403cca94ab625cc1cc6bc4c1e97b6e3846270e7e1fea0"},
+    {file = "coverage-7.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:d71eec7d83298f1af3326ce0ff1d0ea83c7cb98f72b577097f9083b20bdaf05e"},
+    {file = "coverage-7.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d5ae728ff3b5401cc320d792866987e7e7e880e6ebd24433b70a33b643bb0384"},
+    {file = "coverage-7.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc4f1358cb0c78edef3ed237ef2c86056206bb8d9140e73b6b89fbcfcbdd40e1"},
+    {file = "coverage-7.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8130a2aa2acb8788e0b56938786c33c7c98562697bf9f4c7d6e8e5e3a0501e4a"},
+    {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf271892d13e43bc2b51e6908ec9a6a5094a4df1d8af0bfc360088ee6c684409"},
+    {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4cdc86d54b5da0df6d3d3a2f0b710949286094c3a6700c21e9015932b81447e"},
+    {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ae71e7ddb7a413dd60052e90528f2f65270aad4b509563af6d03d53e979feafd"},
+    {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:38dd60d7bf242c4ed5b38e094baf6401faa114fc09e9e6632374388a404f98e7"},
+    {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa5b1c1bfc28384f1f53b69a023d789f72b2e0ab1b3787aae16992a7ca21056c"},
+    {file = "coverage-7.4.4-cp38-cp38-win32.whl", hash = "sha256:dfa8fe35a0bb90382837b238fff375de15f0dcdb9ae68ff85f7a63649c98527e"},
+    {file = "coverage-7.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:b2991665420a803495e0b90a79233c1433d6ed77ef282e8e152a324bbbc5e0c8"},
+    {file = "coverage-7.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b799445b9f7ee8bf299cfaed6f5b226c0037b74886a4e11515e569b36fe310d"},
+    {file = "coverage-7.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b4d33f418f46362995f1e9d4f3a35a1b6322cb959c31d88ae56b0298e1c22357"},
+    {file = "coverage-7.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadacf9a2f407a4688d700e4ebab33a7e2e408f2ca04dbf4aef17585389eff3e"},
+    {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c95949560050d04d46b919301826525597f07b33beba6187d04fa64d47ac82e"},
+    {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4"},
+    {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5fc1de20b2d4a061b3df27ab9b7c7111e9a710f10dc2b84d33a4ab25065994ec"},
+    {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c74880fc64d4958159fbd537a091d2a585448a8f8508bf248d72112723974cbd"},
+    {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:742a76a12aa45b44d236815d282b03cfb1de3b4323f3e4ec933acfae08e54ade"},
+    {file = "coverage-7.4.4-cp39-cp39-win32.whl", hash = "sha256:d89d7b2974cae412400e88f35d86af72208e1ede1a541954af5d944a8ba46c57"},
+    {file = "coverage-7.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:9ca28a302acb19b6af89e90f33ee3e1906961f94b54ea37de6737b7ca9d8827c"},
+    {file = "coverage-7.4.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:b2c5edc4ac10a7ef6605a966c58929ec6c1bd0917fb8c15cb3363f65aa40e677"},
+    {file = "coverage-7.4.4.tar.gz", hash = "sha256:c901df83d097649e257e803be22592aedfd5182f07b3cc87d640bbb9afd50f49"},
 ]
 
 [package.extras]
@@ -452,6 +464,22 @@ files = [
 [package.dependencies]
 python-dateutil = ">=2.4"
 
+[[package]]
+name = "filelock"
+version = "3.13.3"
+description = "A platform independent file lock."
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "filelock-3.13.3-py3-none-any.whl", hash = "sha256:5ffa845303983e7a0b7ae17636509bc97997d58afeafa72fb141a17b152284cb"},
+    {file = "filelock-3.13.3.tar.gz", hash = "sha256:a79895a25bbefdf55d1a2a0a80968f7dbb28edcd6d4234a0afb3f37ecde4b546"},
+]
+
+[package.extras]
+docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
+testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"]
+typing = ["typing-extensions (>=4.8)"]
+
 [[package]]
 name = "ghp-import"
 version = "2.1.0"
@@ -471,13 +499,13 @@ dev = ["flake8", "markdown", "twine", "wheel"]
 
 [[package]]
 name = "googleapis-common-protos"
-version = "1.62.0"
+version = "1.63.0"
 description = "Common protobufs used in Google APIs"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "googleapis-common-protos-1.62.0.tar.gz", hash = "sha256:83f0ece9f94e5672cced82f592d2a5edf527a96ed1794f0bab36d5735c996277"},
-    {file = "googleapis_common_protos-1.62.0-py2.py3-none-any.whl", hash = "sha256:4750113612205514f9f6aa4cb00d523a94f3e8c06c5ad2fee466387dc4875f07"},
+    {file = "googleapis-common-protos-1.63.0.tar.gz", hash = "sha256:17ad01b11d5f1d0171c06d3ba5c04c54474e883b66b949722b4938ee2694ef4e"},
+    {file = "googleapis_common_protos-1.63.0-py2.py3-none-any.whl", hash = "sha256:ae45f75702f7c08b541f750854a678bd8f534a1a6bace6afe975f1d0a82d6632"},
 ]
 
 [package.dependencies]
@@ -488,13 +516,13 @@ grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"]
 
 [[package]]
 name = "griffe"
-version = "0.40.1"
+version = "0.42.1"
 description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "griffe-0.40.1-py3-none-any.whl", hash = "sha256:5b8c023f366fe273e762131fe4bfd141ea56c09b3cb825aa92d06a82681cfd93"},
-    {file = "griffe-0.40.1.tar.gz", hash = "sha256:66c48a62e2ce5784b6940e603300fcfb807b6f099b94e7f753f1841661fd5c7c"},
+    {file = "griffe-0.42.1-py3-none-any.whl", hash = "sha256:7e805e35617601355edcac0d3511cedc1ed0cb1f7645e2d336ae4b05bbae7b3b"},
+    {file = "griffe-0.42.1.tar.gz", hash = "sha256:57046131384043ed078692b85d86b76568a686266cc036b9b56b704466f803ce"},
 ]
 
 [package.dependencies]
@@ -502,69 +530,69 @@ colorama = ">=0.4"
 
 [[package]]
 name = "grpcio"
-version = "1.60.1"
+version = "1.62.1"
 description = "HTTP/2-based RPC framework"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "grpcio-1.60.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:14e8f2c84c0832773fb3958240c69def72357bc11392571f87b2d7b91e0bb092"},
-    {file = "grpcio-1.60.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:33aed0a431f5befeffd9d346b0fa44b2c01aa4aeae5ea5b2c03d3e25e0071216"},
-    {file = "grpcio-1.60.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:fead980fbc68512dfd4e0c7b1f5754c2a8e5015a04dea454b9cada54a8423525"},
-    {file = "grpcio-1.60.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:082081e6a36b6eb5cf0fd9a897fe777dbb3802176ffd08e3ec6567edd85bc104"},
-    {file = "grpcio-1.60.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55ccb7db5a665079d68b5c7c86359ebd5ebf31a19bc1a91c982fd622f1e31ff2"},
-    {file = "grpcio-1.60.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:9b54577032d4f235452f77a83169b6527bf4b77d73aeada97d45b2aaf1bf5ce0"},
-    {file = "grpcio-1.60.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7d142bcd604166417929b071cd396aa13c565749a4c840d6c702727a59d835eb"},
-    {file = "grpcio-1.60.1-cp310-cp310-win32.whl", hash = "sha256:2a6087f234cb570008a6041c8ffd1b7d657b397fdd6d26e83d72283dae3527b1"},
-    {file = "grpcio-1.60.1-cp310-cp310-win_amd64.whl", hash = "sha256:f2212796593ad1d0235068c79836861f2201fc7137a99aa2fea7beeb3b101177"},
-    {file = "grpcio-1.60.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:79ae0dc785504cb1e1788758c588c711f4e4a0195d70dff53db203c95a0bd303"},
-    {file = "grpcio-1.60.1-cp311-cp311-macosx_10_10_universal2.whl", hash = "sha256:4eec8b8c1c2c9b7125508ff7c89d5701bf933c99d3910e446ed531cd16ad5d87"},
-    {file = "grpcio-1.60.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:8c9554ca8e26241dabe7951aa1fa03a1ba0856688ecd7e7bdbdd286ebc272e4c"},
-    {file = "grpcio-1.60.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91422ba785a8e7a18725b1dc40fbd88f08a5bb4c7f1b3e8739cab24b04fa8a03"},
-    {file = "grpcio-1.60.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cba6209c96828711cb7c8fcb45ecef8c8859238baf15119daa1bef0f6c84bfe7"},
-    {file = "grpcio-1.60.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c71be3f86d67d8d1311c6076a4ba3b75ba5703c0b856b4e691c9097f9b1e8bd2"},
-    {file = "grpcio-1.60.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:af5ef6cfaf0d023c00002ba25d0751e5995fa0e4c9eec6cd263c30352662cbce"},
-    {file = "grpcio-1.60.1-cp311-cp311-win32.whl", hash = "sha256:a09506eb48fa5493c58f946c46754ef22f3ec0df64f2b5149373ff31fb67f3dd"},
-    {file = "grpcio-1.60.1-cp311-cp311-win_amd64.whl", hash = "sha256:49c9b6a510e3ed8df5f6f4f3c34d7fbf2d2cae048ee90a45cd7415abab72912c"},
-    {file = "grpcio-1.60.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:b58b855d0071575ea9c7bc0d84a06d2edfbfccec52e9657864386381a7ce1ae9"},
-    {file = "grpcio-1.60.1-cp312-cp312-macosx_10_10_universal2.whl", hash = "sha256:a731ac5cffc34dac62053e0da90f0c0b8560396a19f69d9703e88240c8f05858"},
-    {file = "grpcio-1.60.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:cf77f8cf2a651fbd869fbdcb4a1931464189cd210abc4cfad357f1cacc8642a6"},
-    {file = "grpcio-1.60.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c557e94e91a983e5b1e9c60076a8fd79fea1e7e06848eb2e48d0ccfb30f6e073"},
-    {file = "grpcio-1.60.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:069fe2aeee02dfd2135d562d0663fe70fbb69d5eed6eb3389042a7e963b54de8"},
-    {file = "grpcio-1.60.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:cb0af13433dbbd1c806e671d81ec75bd324af6ef75171fd7815ca3074fe32bfe"},
-    {file = "grpcio-1.60.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2f44c32aef186bbba254129cea1df08a20be414144ac3bdf0e84b24e3f3b2e05"},
-    {file = "grpcio-1.60.1-cp312-cp312-win32.whl", hash = "sha256:a212e5dea1a4182e40cd3e4067ee46be9d10418092ce3627475e995cca95de21"},
-    {file = "grpcio-1.60.1-cp312-cp312-win_amd64.whl", hash = "sha256:6e490fa5f7f5326222cb9f0b78f207a2b218a14edf39602e083d5f617354306f"},
-    {file = "grpcio-1.60.1-cp37-cp37m-linux_armv7l.whl", hash = "sha256:4216e67ad9a4769117433814956031cb300f85edc855252a645a9a724b3b6594"},
-    {file = "grpcio-1.60.1-cp37-cp37m-macosx_10_10_universal2.whl", hash = "sha256:73e14acd3d4247169955fae8fb103a2b900cfad21d0c35f0dcd0fdd54cd60367"},
-    {file = "grpcio-1.60.1-cp37-cp37m-manylinux_2_17_aarch64.whl", hash = "sha256:6ecf21d20d02d1733e9c820fb5c114c749d888704a7ec824b545c12e78734d1c"},
-    {file = "grpcio-1.60.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33bdea30dcfd4f87b045d404388469eb48a48c33a6195a043d116ed1b9a0196c"},
-    {file = "grpcio-1.60.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53b69e79d00f78c81eecfb38f4516080dc7f36a198b6b37b928f1c13b3c063e9"},
-    {file = "grpcio-1.60.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:39aa848794b887120b1d35b1b994e445cc028ff602ef267f87c38122c1add50d"},
-    {file = "grpcio-1.60.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:72153a0d2e425f45b884540a61c6639436ddafa1829a42056aa5764b84108b8e"},
-    {file = "grpcio-1.60.1-cp37-cp37m-win_amd64.whl", hash = "sha256:50d56280b482875d1f9128ce596e59031a226a8b84bec88cb2bf76c289f5d0de"},
-    {file = "grpcio-1.60.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:6d140bdeb26cad8b93c1455fa00573c05592793c32053d6e0016ce05ba267549"},
-    {file = "grpcio-1.60.1-cp38-cp38-macosx_10_10_universal2.whl", hash = "sha256:bc808924470643b82b14fe121923c30ec211d8c693e747eba8a7414bc4351a23"},
-    {file = "grpcio-1.60.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:70c83bb530572917be20c21f3b6be92cd86b9aecb44b0c18b1d3b2cc3ae47df0"},
-    {file = "grpcio-1.60.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9b106bc52e7f28170e624ba61cc7dc6829566e535a6ec68528f8e1afbed1c41f"},
-    {file = "grpcio-1.60.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e980cd6db1088c144b92fe376747328d5554bc7960ce583ec7b7d81cd47287"},
-    {file = "grpcio-1.60.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0c5807e9152eff15f1d48f6b9ad3749196f79a4a050469d99eecb679be592acc"},
-    {file = "grpcio-1.60.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f1c3dc536b3ee124e8b24feb7533e5c70b9f2ef833e3b2e5513b2897fd46763a"},
-    {file = "grpcio-1.60.1-cp38-cp38-win32.whl", hash = "sha256:d7404cebcdb11bb5bd40bf94131faf7e9a7c10a6c60358580fe83913f360f929"},
-    {file = "grpcio-1.60.1-cp38-cp38-win_amd64.whl", hash = "sha256:c8754c75f55781515a3005063d9a05878b2cfb3cb7e41d5401ad0cf19de14872"},
-    {file = "grpcio-1.60.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:0250a7a70b14000fa311de04b169cc7480be6c1a769b190769d347939d3232a8"},
-    {file = "grpcio-1.60.1-cp39-cp39-macosx_10_10_universal2.whl", hash = "sha256:660fc6b9c2a9ea3bb2a7e64ba878c98339abaf1811edca904ac85e9e662f1d73"},
-    {file = "grpcio-1.60.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:76eaaba891083fcbe167aa0f03363311a9f12da975b025d30e94b93ac7a765fc"},
-    {file = "grpcio-1.60.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5d97c65ea7e097056f3d1ead77040ebc236feaf7f71489383d20f3b4c28412a"},
-    {file = "grpcio-1.60.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb2a2911b028f01c8c64d126f6b632fcd8a9ac975aa1b3855766c94e4107180"},
-    {file = "grpcio-1.60.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:5a1ebbae7e2214f51b1f23b57bf98eeed2cf1ba84e4d523c48c36d5b2f8829ff"},
-    {file = "grpcio-1.60.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9a66f4d2a005bc78e61d805ed95dedfcb35efa84b7bba0403c6d60d13a3de2d6"},
-    {file = "grpcio-1.60.1-cp39-cp39-win32.whl", hash = "sha256:8d488fbdbf04283f0d20742b64968d44825617aa6717b07c006168ed16488804"},
-    {file = "grpcio-1.60.1-cp39-cp39-win_amd64.whl", hash = "sha256:61b7199cd2a55e62e45bfb629a35b71fc2c0cb88f686a047f25b1112d3810904"},
-    {file = "grpcio-1.60.1.tar.gz", hash = "sha256:dd1d3a8d1d2e50ad9b59e10aa7f07c7d1be2b367f3f2d33c5fade96ed5460962"},
+    {file = "grpcio-1.62.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:179bee6f5ed7b5f618844f760b6acf7e910988de77a4f75b95bbfaa8106f3c1e"},
+    {file = "grpcio-1.62.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:48611e4fa010e823ba2de8fd3f77c1322dd60cb0d180dc6630a7e157b205f7ea"},
+    {file = "grpcio-1.62.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:b2a0e71b0a2158aa4bce48be9f8f9eb45cbd17c78c7443616d00abbe2a509f6d"},
+    {file = "grpcio-1.62.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fbe80577c7880911d3ad65e5ecc997416c98f354efeba2f8d0f9112a67ed65a5"},
+    {file = "grpcio-1.62.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58f6c693d446964e3292425e1d16e21a97a48ba9172f2d0df9d7b640acb99243"},
+    {file = "grpcio-1.62.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:77c339403db5a20ef4fed02e4d1a9a3d9866bf9c0afc77a42234677313ea22f3"},
+    {file = "grpcio-1.62.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b5a4ea906db7dec694098435d84bf2854fe158eb3cd51e1107e571246d4d1d70"},
+    {file = "grpcio-1.62.1-cp310-cp310-win32.whl", hash = "sha256:4187201a53f8561c015bc745b81a1b2d278967b8de35f3399b84b0695e281d5f"},
+    {file = "grpcio-1.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:844d1f3fb11bd1ed362d3fdc495d0770cfab75761836193af166fee113421d66"},
+    {file = "grpcio-1.62.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:833379943d1728a005e44103f17ecd73d058d37d95783eb8f0b28ddc1f54d7b2"},
+    {file = "grpcio-1.62.1-cp311-cp311-macosx_10_10_universal2.whl", hash = "sha256:c7fcc6a32e7b7b58f5a7d27530669337a5d587d4066060bcb9dee7a8c833dfb7"},
+    {file = "grpcio-1.62.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:fa7d28eb4d50b7cbe75bb8b45ed0da9a1dc5b219a0af59449676a29c2eed9698"},
+    {file = "grpcio-1.62.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48f7135c3de2f298b833be8b4ae20cafe37091634e91f61f5a7eb3d61ec6f660"},
+    {file = "grpcio-1.62.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71f11fd63365ade276c9d4a7b7df5c136f9030e3457107e1791b3737a9b9ed6a"},
+    {file = "grpcio-1.62.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4b49fd8fe9f9ac23b78437da94c54aa7e9996fbb220bac024a67469ce5d0825f"},
+    {file = "grpcio-1.62.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:482ae2ae78679ba9ed5752099b32e5fe580443b4f798e1b71df412abf43375db"},
+    {file = "grpcio-1.62.1-cp311-cp311-win32.whl", hash = "sha256:1faa02530b6c7426404372515fe5ddf66e199c2ee613f88f025c6f3bd816450c"},
+    {file = "grpcio-1.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:5bd90b8c395f39bc82a5fb32a0173e220e3f401ff697840f4003e15b96d1befc"},
+    {file = "grpcio-1.62.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:b134d5d71b4e0837fff574c00e49176051a1c532d26c052a1e43231f252d813b"},
+    {file = "grpcio-1.62.1-cp312-cp312-macosx_10_10_universal2.whl", hash = "sha256:d1f6c96573dc09d50dbcbd91dbf71d5cf97640c9427c32584010fbbd4c0e0037"},
+    {file = "grpcio-1.62.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:359f821d4578f80f41909b9ee9b76fb249a21035a061a327f91c953493782c31"},
+    {file = "grpcio-1.62.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a485f0c2010c696be269184bdb5ae72781344cb4e60db976c59d84dd6354fac9"},
+    {file = "grpcio-1.62.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b50b09b4dc01767163d67e1532f948264167cd27f49e9377e3556c3cba1268e1"},
+    {file = "grpcio-1.62.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3227c667dccbe38f2c4d943238b887bac588d97c104815aecc62d2fd976e014b"},
+    {file = "grpcio-1.62.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3952b581eb121324853ce2b191dae08badb75cd493cb4e0243368aa9e61cfd41"},
+    {file = "grpcio-1.62.1-cp312-cp312-win32.whl", hash = "sha256:83a17b303425104d6329c10eb34bba186ffa67161e63fa6cdae7776ff76df73f"},
+    {file = "grpcio-1.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:6696ffe440333a19d8d128e88d440f91fb92c75a80ce4b44d55800e656a3ef1d"},
+    {file = "grpcio-1.62.1-cp37-cp37m-linux_armv7l.whl", hash = "sha256:e3393b0823f938253370ebef033c9fd23d27f3eae8eb9a8f6264900c7ea3fb5a"},
+    {file = "grpcio-1.62.1-cp37-cp37m-macosx_10_10_universal2.whl", hash = "sha256:83e7ccb85a74beaeae2634f10eb858a0ed1a63081172649ff4261f929bacfd22"},
+    {file = "grpcio-1.62.1-cp37-cp37m-manylinux_2_17_aarch64.whl", hash = "sha256:882020c87999d54667a284c7ddf065b359bd00251fcd70279ac486776dbf84ec"},
+    {file = "grpcio-1.62.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a10383035e864f386fe096fed5c47d27a2bf7173c56a6e26cffaaa5a361addb1"},
+    {file = "grpcio-1.62.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:960edebedc6b9ada1ef58e1c71156f28689978188cd8cff3b646b57288a927d9"},
+    {file = "grpcio-1.62.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:23e2e04b83f347d0aadde0c9b616f4726c3d76db04b438fd3904b289a725267f"},
+    {file = "grpcio-1.62.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:978121758711916d34fe57c1f75b79cdfc73952f1481bb9583399331682d36f7"},
+    {file = "grpcio-1.62.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9084086190cc6d628f282e5615f987288b95457292e969b9205e45b442276407"},
+    {file = "grpcio-1.62.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:22bccdd7b23c420a27fd28540fb5dcbc97dc6be105f7698cb0e7d7a420d0e362"},
+    {file = "grpcio-1.62.1-cp38-cp38-macosx_10_10_universal2.whl", hash = "sha256:8999bf1b57172dbc7c3e4bb3c732658e918f5c333b2942243f10d0d653953ba9"},
+    {file = "grpcio-1.62.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:d9e52558b8b8c2f4ac05ac86344a7417ccdd2b460a59616de49eb6933b07a0bd"},
+    {file = "grpcio-1.62.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1714e7bc935780bc3de1b3fcbc7674209adf5208ff825799d579ffd6cd0bd505"},
+    {file = "grpcio-1.62.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8842ccbd8c0e253c1f189088228f9b433f7a93b7196b9e5b6f87dba393f5d5d"},
+    {file = "grpcio-1.62.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1f1e7b36bdff50103af95a80923bf1853f6823dd62f2d2a2524b66ed74103e49"},
+    {file = "grpcio-1.62.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bba97b8e8883a8038606480d6b6772289f4c907f6ba780fa1f7b7da7dfd76f06"},
+    {file = "grpcio-1.62.1-cp38-cp38-win32.whl", hash = "sha256:a7f615270fe534548112a74e790cd9d4f5509d744dd718cd442bf016626c22e4"},
+    {file = "grpcio-1.62.1-cp38-cp38-win_amd64.whl", hash = "sha256:e6c8c8693df718c5ecbc7babb12c69a4e3677fd11de8886f05ab22d4e6b1c43b"},
+    {file = "grpcio-1.62.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:73db2dc1b201d20ab7083e7041946910bb991e7e9761a0394bbc3c2632326483"},
+    {file = "grpcio-1.62.1-cp39-cp39-macosx_10_10_universal2.whl", hash = "sha256:407b26b7f7bbd4f4751dbc9767a1f0716f9fe72d3d7e96bb3ccfc4aace07c8de"},
+    {file = "grpcio-1.62.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:f8de7c8cef9261a2d0a62edf2ccea3d741a523c6b8a6477a340a1f2e417658de"},
+    {file = "grpcio-1.62.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd5c8a1af40ec305d001c60236308a67e25419003e9bb3ebfab5695a8d0b369"},
+    {file = "grpcio-1.62.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be0477cb31da67846a33b1a75c611f88bfbcd427fe17701b6317aefceee1b96f"},
+    {file = "grpcio-1.62.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:60dcd824df166ba266ee0cfaf35a31406cd16ef602b49f5d4dfb21f014b0dedd"},
+    {file = "grpcio-1.62.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:973c49086cabab773525f6077f95e5a993bfc03ba8fc32e32f2c279497780585"},
+    {file = "grpcio-1.62.1-cp39-cp39-win32.whl", hash = "sha256:12859468e8918d3bd243d213cd6fd6ab07208195dc140763c00dfe901ce1e1b4"},
+    {file = "grpcio-1.62.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7209117bbeebdfa5d898205cc55153a51285757902dd73c47de498ad4d11332"},
+    {file = "grpcio-1.62.1.tar.gz", hash = "sha256:6c455e008fa86d9e9a9d85bb76da4277c0d7d9668a3bfa70dbe86e9f3c759947"},
 ]
 
 [package.extras]
-protobuf = ["grpcio-tools (>=1.60.1)"]
+protobuf = ["grpcio-tools (>=1.62.1)"]
 
 [[package]]
 name = "h11"
@@ -579,13 +607,13 @@ files = [
 
 [[package]]
 name = "httpcore"
-version = "1.0.2"
+version = "1.0.5"
 description = "A minimal low-level HTTP client."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "httpcore-1.0.2-py3-none-any.whl", hash = "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7"},
-    {file = "httpcore-1.0.2.tar.gz", hash = "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535"},
+    {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"},
+    {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"},
 ]
 
 [package.dependencies]
@@ -596,17 +624,17 @@ h11 = ">=0.13,<0.15"
 asyncio = ["anyio (>=4.0,<5.0)"]
 http2 = ["h2 (>=3,<5)"]
 socks = ["socksio (==1.*)"]
-trio = ["trio (>=0.22.0,<0.23.0)"]
+trio = ["trio (>=0.22.0,<0.26.0)"]
 
 [[package]]
 name = "httpx"
-version = "0.26.0"
+version = "0.27.0"
 description = "The next generation HTTP client."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd"},
-    {file = "httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf"},
+    {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"},
+    {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"},
 ]
 
 [package.dependencies]
@@ -635,13 +663,13 @@ files = [
 
 [[package]]
 name = "importlib-metadata"
-version = "6.11.0"
+version = "7.0.0"
 description = "Read metadata from Python packages"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "importlib_metadata-6.11.0-py3-none-any.whl", hash = "sha256:f0afba6205ad8f8947c7d338b5342d5db2afbfd82f9cbef7879a9539cc12eb9b"},
-    {file = "importlib_metadata-6.11.0.tar.gz", hash = "sha256:1231cf92d825c9e03cfc4da076a16de6422c863558229ea0b22b675657463443"},
+    {file = "importlib_metadata-7.0.0-py3-none-any.whl", hash = "sha256:d97503976bb81f40a193d41ee6570868479c69d5068651eb039c40d850c59d67"},
+    {file = "importlib_metadata-7.0.0.tar.gz", hash = "sha256:7fc841f8b8332803464e5dc1c63a2e59121f46ca186c0e2e182e80bf8c1319f7"},
 ]
 
 [package.dependencies]
@@ -654,22 +682,23 @@ testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs
 
 [[package]]
 name = "inflect"
-version = "7.0.0"
+version = "7.2.0"
 description = "Correctly generate plurals, singular nouns, ordinals, indefinite articles; convert numbers to words"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "inflect-7.0.0-py3-none-any.whl", hash = "sha256:9544afed6182176e43955c44b1acdaed30f9b2b56c16d1fc5b222d98218b546e"},
-    {file = "inflect-7.0.0.tar.gz", hash = "sha256:63da9325ad29da81ec23e055b41225795ab793b4ecb483be5dc1fa363fd4717e"},
+    {file = "inflect-7.2.0-py3-none-any.whl", hash = "sha256:b41e1677ea799f26f371a5d6d57b9eb966a5f9ba3b341a96464a6cac50aeccf3"},
+    {file = "inflect-7.2.0.tar.gz", hash = "sha256:32feacfacfcae2f22e6fccdea10f0ddf26a638fac434d0dddaafbca0034f3784"},
 ]
 
 [package.dependencies]
-pydantic = ">=1.9.1"
+more-itertools = "*"
+typeguard = ">=4.0.1"
 typing-extensions = "*"
 
 [package.extras]
-docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-testing = ["pygments", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["pygments", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
 
 [[package]]
 name = "iniconfig"
@@ -696,20 +725,6 @@ files = [
 [package.dependencies]
 six = "*"
 
-[[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.0.0"
@@ -730,18 +745,21 @@ testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)",
 
 [[package]]
 name = "jaraco-context"
-version = "4.3.0"
-description = "Context managers by jaraco"
+version = "5.2.0"
+description = "Useful decorators and context managers"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "jaraco.context-4.3.0-py3-none-any.whl", hash = "sha256:5d9e95ca0faa78943ed66f6bc658dd637430f16125d86988e77844c741ff2f11"},
-    {file = "jaraco.context-4.3.0.tar.gz", hash = "sha256:4dad2404540b936a20acedec53355bdaea223acb88fd329fa6de9261c941566e"},
+    {file = "jaraco.context-5.2.0-py3-none-any.whl", hash = "sha256:a3c7bc37da137691a59d3cb0154b9bf5f832f2aac3481a1eac18a10a6434627d"},
+    {file = "jaraco.context-5.2.0.tar.gz", hash = "sha256:a37dfae72c77976b4403f36c6ffc21d3e084a95889008c615336ecbbb444a36a"},
 ]
 
+[package.dependencies]
+"backports.tarfile" = {version = "*", markers = "python_version < \"3.12\""}
+
 [package.extras]
-docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["portend", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
 
 [[package]]
 name = "jaraco-functools"
@@ -971,15 +989,29 @@ html5 = ["html5lib"]
 htmlsoup = ["BeautifulSoup4"]
 source = ["Cython (>=3.0.10)"]
 
+[[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.5.2"
+version = "3.6"
 description = "Python implementation of John Gruber's Markdown."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "Markdown-3.5.2-py3-none-any.whl", hash = "sha256:d43323865d89fc0cb9b20c75fc8ad313af307cc087e84b657d9eec768eddeadd"},
-    {file = "Markdown-3.5.2.tar.gz", hash = "sha256:e1ac7b3dc550ee80e602e71c1d168002f062e49f1b11e26a36264dafd4df2ef8"},
+    {file = "Markdown-3.6-py3-none-any.whl", hash = "sha256:48f276f4d8cfb8ce6527c8f79e2ee29708508bf4d40aa410fbc3b4ee832c850f"},
+    {file = "Markdown-3.6.tar.gz", hash = "sha256:ed4f41f6daecbeeb96e576ce414c41d2d876daa9a16cb35fa8ed8c2ddfad0224"},
 ]
 
 [package.extras]
@@ -1196,13 +1228,13 @@ mkdocs = ">=1.2"
 
 [[package]]
 name = "mkdocstrings"
-version = "0.24.2"
+version = "0.24.3"
 description = "Automatic documentation from sources, for MkDocs."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "mkdocstrings-0.24.2-py3-none-any.whl", hash = "sha256:61440b77542170238099a7d87882c3417897771950e3aafe6e22abff3d1c51fb"},
-    {file = "mkdocstrings-0.24.2.tar.gz", hash = "sha256:b91b9cdd9490ef2e8957000bff1d34a4b308b9cd57b10f26169f085def4c6a92"},
+    {file = "mkdocstrings-0.24.3-py3-none-any.whl", hash = "sha256:5c9cf2a32958cd161d5428699b79c8b0988856b0d4a8c5baf8395fc1bf4087c3"},
+    {file = "mkdocstrings-0.24.3.tar.gz", hash = "sha256:f327b234eb8d2551a306735436e157d0a22d45f79963c60a8b585d5f7a94c1d2"},
 ]
 
 [package.dependencies]
@@ -1248,67 +1280,67 @@ files = [
 
 [[package]]
 name = "msgpack"
-version = "1.0.7"
+version = "1.0.8"
 description = "MessagePack serializer"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "msgpack-1.0.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04ad6069c86e531682f9e1e71b71c1c3937d6014a7c3e9edd2aa81ad58842862"},
-    {file = "msgpack-1.0.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cca1b62fe70d761a282496b96a5e51c44c213e410a964bdffe0928e611368329"},
-    {file = "msgpack-1.0.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e50ebce52f41370707f1e21a59514e3375e3edd6e1832f5e5235237db933c98b"},
-    {file = "msgpack-1.0.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a7b4f35de6a304b5533c238bee86b670b75b03d31b7797929caa7a624b5dda6"},
-    {file = "msgpack-1.0.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28efb066cde83c479dfe5a48141a53bc7e5f13f785b92ddde336c716663039ee"},
-    {file = "msgpack-1.0.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4cb14ce54d9b857be9591ac364cb08dc2d6a5c4318c1182cb1d02274029d590d"},
-    {file = "msgpack-1.0.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b573a43ef7c368ba4ea06050a957c2a7550f729c31f11dd616d2ac4aba99888d"},
-    {file = "msgpack-1.0.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ccf9a39706b604d884d2cb1e27fe973bc55f2890c52f38df742bc1d79ab9f5e1"},
-    {file = "msgpack-1.0.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cb70766519500281815dfd7a87d3a178acf7ce95390544b8c90587d76b227681"},
-    {file = "msgpack-1.0.7-cp310-cp310-win32.whl", hash = "sha256:b610ff0f24e9f11c9ae653c67ff8cc03c075131401b3e5ef4b82570d1728f8a9"},
-    {file = "msgpack-1.0.7-cp310-cp310-win_amd64.whl", hash = "sha256:a40821a89dc373d6427e2b44b572efc36a2778d3f543299e2f24eb1a5de65415"},
-    {file = "msgpack-1.0.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:576eb384292b139821c41995523654ad82d1916da6a60cff129c715a6223ea84"},
-    {file = "msgpack-1.0.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:730076207cb816138cf1af7f7237b208340a2c5e749707457d70705715c93b93"},
-    {file = "msgpack-1.0.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:85765fdf4b27eb5086f05ac0491090fc76f4f2b28e09d9350c31aac25a5aaff8"},
-    {file = "msgpack-1.0.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3476fae43db72bd11f29a5147ae2f3cb22e2f1a91d575ef130d2bf49afd21c46"},
-    {file = "msgpack-1.0.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d4c80667de2e36970ebf74f42d1088cc9ee7ef5f4e8c35eee1b40eafd33ca5b"},
-    {file = "msgpack-1.0.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b0bf0effb196ed76b7ad883848143427a73c355ae8e569fa538365064188b8e"},
-    {file = "msgpack-1.0.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f9a7c509542db4eceed3dcf21ee5267ab565a83555c9b88a8109dcecc4709002"},
-    {file = "msgpack-1.0.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:84b0daf226913133f899ea9b30618722d45feffa67e4fe867b0b5ae83a34060c"},
-    {file = "msgpack-1.0.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ec79ff6159dffcc30853b2ad612ed572af86c92b5168aa3fc01a67b0fa40665e"},
-    {file = "msgpack-1.0.7-cp311-cp311-win32.whl", hash = "sha256:3e7bf4442b310ff154b7bb9d81eb2c016b7d597e364f97d72b1acc3817a0fdc1"},
-    {file = "msgpack-1.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:3f0c8c6dfa6605ab8ff0611995ee30d4f9fcff89966cf562733b4008a3d60d82"},
-    {file = "msgpack-1.0.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f0936e08e0003f66bfd97e74ee530427707297b0d0361247e9b4f59ab78ddc8b"},
-    {file = "msgpack-1.0.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98bbd754a422a0b123c66a4c341de0474cad4a5c10c164ceed6ea090f3563db4"},
-    {file = "msgpack-1.0.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b291f0ee7961a597cbbcc77709374087fa2a9afe7bdb6a40dbbd9b127e79afee"},
-    {file = "msgpack-1.0.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebbbba226f0a108a7366bf4b59bf0f30a12fd5e75100c630267d94d7f0ad20e5"},
-    {file = "msgpack-1.0.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e2d69948e4132813b8d1131f29f9101bc2c915f26089a6d632001a5c1349672"},
-    {file = "msgpack-1.0.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdf38ba2d393c7911ae989c3bbba510ebbcdf4ecbdbfec36272abe350c454075"},
-    {file = "msgpack-1.0.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:993584fc821c58d5993521bfdcd31a4adf025c7d745bbd4d12ccfecf695af5ba"},
-    {file = "msgpack-1.0.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:52700dc63a4676669b341ba33520f4d6e43d3ca58d422e22ba66d1736b0a6e4c"},
-    {file = "msgpack-1.0.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e45ae4927759289c30ccba8d9fdce62bb414977ba158286b5ddaf8df2cddb5c5"},
-    {file = "msgpack-1.0.7-cp312-cp312-win32.whl", hash = "sha256:27dcd6f46a21c18fa5e5deed92a43d4554e3df8d8ca5a47bf0615d6a5f39dbc9"},
-    {file = "msgpack-1.0.7-cp312-cp312-win_amd64.whl", hash = "sha256:7687e22a31e976a0e7fc99c2f4d11ca45eff652a81eb8c8085e9609298916dcf"},
-    {file = "msgpack-1.0.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5b6ccc0c85916998d788b295765ea0e9cb9aac7e4a8ed71d12e7d8ac31c23c95"},
-    {file = "msgpack-1.0.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:235a31ec7db685f5c82233bddf9858748b89b8119bf4538d514536c485c15fe0"},
-    {file = "msgpack-1.0.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cab3db8bab4b7e635c1c97270d7a4b2a90c070b33cbc00c99ef3f9be03d3e1f7"},
-    {file = "msgpack-1.0.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bfdd914e55e0d2c9e1526de210f6fe8ffe9705f2b1dfcc4aecc92a4cb4b533d"},
-    {file = "msgpack-1.0.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36e17c4592231a7dbd2ed09027823ab295d2791b3b1efb2aee874b10548b7524"},
-    {file = "msgpack-1.0.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38949d30b11ae5f95c3c91917ee7a6b239f5ec276f271f28638dec9156f82cfc"},
-    {file = "msgpack-1.0.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ff1d0899f104f3921d94579a5638847f783c9b04f2d5f229392ca77fba5b82fc"},
-    {file = "msgpack-1.0.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:dc43f1ec66eb8440567186ae2f8c447d91e0372d793dfe8c222aec857b81a8cf"},
-    {file = "msgpack-1.0.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:dd632777ff3beaaf629f1ab4396caf7ba0bdd075d948a69460d13d44357aca4c"},
-    {file = "msgpack-1.0.7-cp38-cp38-win32.whl", hash = "sha256:4e71bc4416de195d6e9b4ee93ad3f2f6b2ce11d042b4d7a7ee00bbe0358bd0c2"},
-    {file = "msgpack-1.0.7-cp38-cp38-win_amd64.whl", hash = "sha256:8f5b234f567cf76ee489502ceb7165c2a5cecec081db2b37e35332b537f8157c"},
-    {file = "msgpack-1.0.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfef2bb6ef068827bbd021017a107194956918ab43ce4d6dc945ffa13efbc25f"},
-    {file = "msgpack-1.0.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:484ae3240666ad34cfa31eea7b8c6cd2f1fdaae21d73ce2974211df099a95d81"},
-    {file = "msgpack-1.0.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3967e4ad1aa9da62fd53e346ed17d7b2e922cba5ab93bdd46febcac39be636fc"},
-    {file = "msgpack-1.0.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dd178c4c80706546702c59529ffc005681bd6dc2ea234c450661b205445a34d"},
-    {file = "msgpack-1.0.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6ffbc252eb0d229aeb2f9ad051200668fc3a9aaa8994e49f0cb2ffe2b7867e7"},
-    {file = "msgpack-1.0.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:822ea70dc4018c7e6223f13affd1c5c30c0f5c12ac1f96cd8e9949acddb48a61"},
-    {file = "msgpack-1.0.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:384d779f0d6f1b110eae74cb0659d9aa6ff35aaf547b3955abf2ab4c901c4819"},
-    {file = "msgpack-1.0.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f64e376cd20d3f030190e8c32e1c64582eba56ac6dc7d5b0b49a9d44021b52fd"},
-    {file = "msgpack-1.0.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ed82f5a7af3697b1c4786053736f24a0efd0a1b8a130d4c7bfee4b9ded0f08f"},
-    {file = "msgpack-1.0.7-cp39-cp39-win32.whl", hash = "sha256:f26a07a6e877c76a88e3cecac8531908d980d3d5067ff69213653649ec0f60ad"},
-    {file = "msgpack-1.0.7-cp39-cp39-win_amd64.whl", hash = "sha256:1dc93e8e4653bdb5910aed79f11e165c85732067614f180f70534f056da97db3"},
-    {file = "msgpack-1.0.7.tar.gz", hash = "sha256:572efc93db7a4d27e404501975ca6d2d9775705c2d922390d878fcf768d92c87"},
+    {file = "msgpack-1.0.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:505fe3d03856ac7d215dbe005414bc28505d26f0c128906037e66d98c4e95868"},
+    {file = "msgpack-1.0.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b7842518a63a9f17107eb176320960ec095a8ee3b4420b5f688e24bf50c53c"},
+    {file = "msgpack-1.0.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:376081f471a2ef24828b83a641a02c575d6103a3ad7fd7dade5486cad10ea659"},
+    {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e390971d082dba073c05dbd56322427d3280b7cc8b53484c9377adfbae67dc2"},
+    {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e073efcba9ea99db5acef3959efa45b52bc67b61b00823d2a1a6944bf45982"},
+    {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82d92c773fbc6942a7a8b520d22c11cfc8fd83bba86116bfcf962c2f5c2ecdaa"},
+    {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9ee32dcb8e531adae1f1ca568822e9b3a738369b3b686d1477cbc643c4a9c128"},
+    {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e3aa7e51d738e0ec0afbed661261513b38b3014754c9459508399baf14ae0c9d"},
+    {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69284049d07fce531c17404fcba2bb1df472bc2dcdac642ae71a2d079d950653"},
+    {file = "msgpack-1.0.8-cp310-cp310-win32.whl", hash = "sha256:13577ec9e247f8741c84d06b9ece5f654920d8365a4b636ce0e44f15e07ec693"},
+    {file = "msgpack-1.0.8-cp310-cp310-win_amd64.whl", hash = "sha256:e532dbd6ddfe13946de050d7474e3f5fb6ec774fbb1a188aaf469b08cf04189a"},
+    {file = "msgpack-1.0.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9517004e21664f2b5a5fd6333b0731b9cf0817403a941b393d89a2f1dc2bd836"},
+    {file = "msgpack-1.0.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d16a786905034e7e34098634b184a7d81f91d4c3d246edc6bd7aefb2fd8ea6ad"},
+    {file = "msgpack-1.0.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2872993e209f7ed04d963e4b4fbae72d034844ec66bc4ca403329db2074377b"},
+    {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c330eace3dd100bdb54b5653b966de7f51c26ec4a7d4e87132d9b4f738220ba"},
+    {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b5c044f3eff2a6534768ccfd50425939e7a8b5cf9a7261c385de1e20dcfc85"},
+    {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1876b0b653a808fcd50123b953af170c535027bf1d053b59790eebb0aeb38950"},
+    {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dfe1f0f0ed5785c187144c46a292b8c34c1295c01da12e10ccddfc16def4448a"},
+    {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3528807cbbb7f315bb81959d5961855e7ba52aa60a3097151cb21956fbc7502b"},
+    {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e2f879ab92ce502a1e65fce390eab619774dda6a6ff719718069ac94084098ce"},
+    {file = "msgpack-1.0.8-cp311-cp311-win32.whl", hash = "sha256:26ee97a8261e6e35885c2ecd2fd4a6d38252246f94a2aec23665a4e66d066305"},
+    {file = "msgpack-1.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:eadb9f826c138e6cf3c49d6f8de88225a3c0ab181a9b4ba792e006e5292d150e"},
+    {file = "msgpack-1.0.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:114be227f5213ef8b215c22dde19532f5da9652e56e8ce969bf0a26d7c419fee"},
+    {file = "msgpack-1.0.8-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d661dc4785affa9d0edfdd1e59ec056a58b3dbb9f196fa43587f3ddac654ac7b"},
+    {file = "msgpack-1.0.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d56fd9f1f1cdc8227d7b7918f55091349741904d9520c65f0139a9755952c9e8"},
+    {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0726c282d188e204281ebd8de31724b7d749adebc086873a59efb8cf7ae27df3"},
+    {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8db8e423192303ed77cff4dce3a4b88dbfaf43979d280181558af5e2c3c71afc"},
+    {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99881222f4a8c2f641f25703963a5cefb076adffd959e0558dc9f803a52d6a58"},
+    {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b5505774ea2a73a86ea176e8a9a4a7c8bf5d521050f0f6f8426afe798689243f"},
+    {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ef254a06bcea461e65ff0373d8a0dd1ed3aa004af48839f002a0c994a6f72d04"},
+    {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e1dd7839443592d00e96db831eddb4111a2a81a46b028f0facd60a09ebbdd543"},
+    {file = "msgpack-1.0.8-cp312-cp312-win32.whl", hash = "sha256:64d0fcd436c5683fdd7c907eeae5e2cbb5eb872fafbc03a43609d7941840995c"},
+    {file = "msgpack-1.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:74398a4cf19de42e1498368c36eed45d9528f5fd0155241e82c4082b7e16cffd"},
+    {file = "msgpack-1.0.8-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0ceea77719d45c839fd73abcb190b8390412a890df2f83fb8cf49b2a4b5c2f40"},
+    {file = "msgpack-1.0.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1ab0bbcd4d1f7b6991ee7c753655b481c50084294218de69365f8f1970d4c151"},
+    {file = "msgpack-1.0.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1cce488457370ffd1f953846f82323cb6b2ad2190987cd4d70b2713e17268d24"},
+    {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3923a1778f7e5ef31865893fdca12a8d7dc03a44b33e2a5f3295416314c09f5d"},
+    {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a22e47578b30a3e199ab067a4d43d790249b3c0587d9a771921f86250c8435db"},
+    {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd739c9251d01e0279ce729e37b39d49a08c0420d3fee7f2a4968c0576678f77"},
+    {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d3420522057ebab1728b21ad473aa950026d07cb09da41103f8e597dfbfaeb13"},
+    {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5845fdf5e5d5b78a49b826fcdc0eb2e2aa7191980e3d2cfd2a30303a74f212e2"},
+    {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a0e76621f6e1f908ae52860bdcb58e1ca85231a9b0545e64509c931dd34275a"},
+    {file = "msgpack-1.0.8-cp38-cp38-win32.whl", hash = "sha256:374a8e88ddab84b9ada695d255679fb99c53513c0a51778796fcf0944d6c789c"},
+    {file = "msgpack-1.0.8-cp38-cp38-win_amd64.whl", hash = "sha256:f3709997b228685fe53e8c433e2df9f0cdb5f4542bd5114ed17ac3c0129b0480"},
+    {file = "msgpack-1.0.8-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f51bab98d52739c50c56658cc303f190785f9a2cd97b823357e7aeae54c8f68a"},
+    {file = "msgpack-1.0.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:73ee792784d48aa338bba28063e19a27e8d989344f34aad14ea6e1b9bd83f596"},
+    {file = "msgpack-1.0.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f9904e24646570539a8950400602d66d2b2c492b9010ea7e965025cb71d0c86d"},
+    {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e75753aeda0ddc4c28dce4c32ba2f6ec30b1b02f6c0b14e547841ba5b24f753f"},
+    {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5dbf059fb4b7c240c873c1245ee112505be27497e90f7c6591261c7d3c3a8228"},
+    {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4916727e31c28be8beaf11cf117d6f6f188dcc36daae4e851fee88646f5b6b18"},
+    {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7938111ed1358f536daf311be244f34df7bf3cdedb3ed883787aca97778b28d8"},
+    {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:493c5c5e44b06d6c9268ce21b302c9ca055c1fd3484c25ba41d34476c76ee746"},
+    {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273"},
+    {file = "msgpack-1.0.8-cp39-cp39-win32.whl", hash = "sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d"},
+    {file = "msgpack-1.0.8-cp39-cp39-win_amd64.whl", hash = "sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011"},
+    {file = "msgpack-1.0.8.tar.gz", hash = "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3"},
 ]
 
 [[package]]
@@ -1322,6 +1354,52 @@ files = [
     {file = "mutagen-1.47.0.tar.gz", hash = "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99"},
 ]
 
+[[package]]
+name = "mypy"
+version = "1.9.0"
+description = "Optional static typing for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "mypy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f"},
+    {file = "mypy-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed"},
+    {file = "mypy-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150"},
+    {file = "mypy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374"},
+    {file = "mypy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03"},
+    {file = "mypy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3"},
+    {file = "mypy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc"},
+    {file = "mypy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129"},
+    {file = "mypy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612"},
+    {file = "mypy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3"},
+    {file = "mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd"},
+    {file = "mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6"},
+    {file = "mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185"},
+    {file = "mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913"},
+    {file = "mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"},
+    {file = "mypy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b"},
+    {file = "mypy-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2"},
+    {file = "mypy-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e"},
+    {file = "mypy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04"},
+    {file = "mypy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89"},
+    {file = "mypy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02"},
+    {file = "mypy-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4"},
+    {file = "mypy-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d"},
+    {file = "mypy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf"},
+    {file = "mypy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9"},
+    {file = "mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e"},
+    {file = "mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974"},
+]
+
+[package.dependencies]
+mypy-extensions = ">=1.0.0"
+typing-extensions = ">=4.1.0"
+
+[package.extras]
+dmypy = ["psutil (>=4.0)"]
+install-types = ["pip"]
+mypyc = ["setuptools (>=50)"]
+reports = ["lxml"]
+
 [[package]]
 name = "mypy-extensions"
 version = "1.0.0"
@@ -1350,106 +1428,100 @@ pytz = ">=2022.7.1"
 
 [[package]]
 name = "opentelemetry-api"
-version = "1.22.0"
+version = "1.24.0"
 description = "OpenTelemetry Python API"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "opentelemetry_api-1.22.0-py3-none-any.whl", hash = "sha256:43621514301a7e9f5d06dd8013a1b450f30c2e9372b8e30aaeb4562abf2ce034"},
-    {file = "opentelemetry_api-1.22.0.tar.gz", hash = "sha256:15ae4ca925ecf9cfdfb7a709250846fbb08072260fca08ade78056c502b86bed"},
+    {file = "opentelemetry_api-1.24.0-py3-none-any.whl", hash = "sha256:0f2c363d98d10d1ce93330015ca7fd3a65f60be64e05e30f557c61de52c80ca2"},
+    {file = "opentelemetry_api-1.24.0.tar.gz", hash = "sha256:42719f10ce7b5a9a73b10a4baf620574fb8ad495a9cbe5c18d76b75d8689c67e"},
 ]
 
 [package.dependencies]
 deprecated = ">=1.2.6"
-importlib-metadata = ">=6.0,<7.0"
+importlib-metadata = ">=6.0,<=7.0"
 
 [[package]]
 name = "opentelemetry-exporter-otlp"
-version = "1.22.0"
+version = "1.24.0"
 description = "OpenTelemetry Collector Exporters"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "opentelemetry_exporter_otlp-1.22.0-py3-none-any.whl", hash = "sha256:cb03a1cbf300e12b47690858be13dd26fe2f60b2610204959f3497cd6645e3a1"},
-    {file = "opentelemetry_exporter_otlp-1.22.0.tar.gz", hash = "sha256:309a7d4dc67602801f15818e110ce452e78989886aaab5d37e7cf7f55f1d3d27"},
+    {file = "opentelemetry_exporter_otlp-1.24.0-py3-none-any.whl", hash = "sha256:1dfe2e4befe1f0efc193a896837740407669b2929233b406ac0a813151200cac"},
+    {file = "opentelemetry_exporter_otlp-1.24.0.tar.gz", hash = "sha256:649c6e249e55cbdebe99ba2846e3851c04c9f328570328c35b3af9c094314b55"},
 ]
 
 [package.dependencies]
-opentelemetry-exporter-otlp-proto-grpc = "1.22.0"
-opentelemetry-exporter-otlp-proto-http = "1.22.0"
+opentelemetry-exporter-otlp-proto-grpc = "1.24.0"
+opentelemetry-exporter-otlp-proto-http = "1.24.0"
 
 [[package]]
 name = "opentelemetry-exporter-otlp-proto-common"
-version = "1.22.0"
+version = "1.24.0"
 description = "OpenTelemetry Protobuf encoding"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "opentelemetry_exporter_otlp_proto_common-1.22.0-py3-none-any.whl", hash = "sha256:3f2538bec5312587f8676c332b3747f54c89fe6364803a807e217af4603201fa"},
-    {file = "opentelemetry_exporter_otlp_proto_common-1.22.0.tar.gz", hash = "sha256:71ae2f81bc6d6fe408d06388826edc8933759b2ca3a97d24054507dc7cfce52d"},
+    {file = "opentelemetry_exporter_otlp_proto_common-1.24.0-py3-none-any.whl", hash = "sha256:e51f2c9735054d598ad2df5d3eca830fecfb5b0bda0a2fa742c9c7718e12f641"},
+    {file = "opentelemetry_exporter_otlp_proto_common-1.24.0.tar.gz", hash = "sha256:5d31fa1ff976cacc38be1ec4e3279a3f88435c75b38b1f7a099a1faffc302461"},
 ]
 
 [package.dependencies]
-backoff = {version = ">=1.10.0,<3.0.0", markers = "python_version >= \"3.7\""}
-opentelemetry-proto = "1.22.0"
+opentelemetry-proto = "1.24.0"
 
 [[package]]
 name = "opentelemetry-exporter-otlp-proto-grpc"
-version = "1.22.0"
+version = "1.24.0"
 description = "OpenTelemetry Collector Protobuf over gRPC Exporter"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "opentelemetry_exporter_otlp_proto_grpc-1.22.0-py3-none-any.whl", hash = "sha256:b5bcadc129272004316a455e9081216d3380c1fc2231a928ea6a70aa90e173fb"},
-    {file = "opentelemetry_exporter_otlp_proto_grpc-1.22.0.tar.gz", hash = "sha256:1e0e5aa4bbabc74942f06f268deffd94851d12a8dc30b02527472ef1729fe5b1"},
+    {file = "opentelemetry_exporter_otlp_proto_grpc-1.24.0-py3-none-any.whl", hash = "sha256:f40d62aa30a0a43cc1657428e59fcf82ad5f7ea8fff75de0f9d9cb6f739e0a3b"},
+    {file = "opentelemetry_exporter_otlp_proto_grpc-1.24.0.tar.gz", hash = "sha256:217c6e30634f2c9797999ea9da29f7300479a94a610139b9df17433f915e7baa"},
 ]
 
 [package.dependencies]
-backoff = {version = ">=1.10.0,<3.0.0", markers = "python_version >= \"3.7\""}
 deprecated = ">=1.2.6"
 googleapis-common-protos = ">=1.52,<2.0"
 grpcio = ">=1.0.0,<2.0.0"
 opentelemetry-api = ">=1.15,<2.0"
-opentelemetry-exporter-otlp-proto-common = "1.22.0"
-opentelemetry-proto = "1.22.0"
-opentelemetry-sdk = ">=1.22.0,<1.23.0"
+opentelemetry-exporter-otlp-proto-common = "1.24.0"
+opentelemetry-proto = "1.24.0"
+opentelemetry-sdk = ">=1.24.0,<1.25.0"
 
 [package.extras]
 test = ["pytest-grpc"]
 
 [[package]]
 name = "opentelemetry-exporter-otlp-proto-http"
-version = "1.22.0"
+version = "1.24.0"
 description = "OpenTelemetry Collector Protobuf over HTTP Exporter"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "opentelemetry_exporter_otlp_proto_http-1.22.0-py3-none-any.whl", hash = "sha256:e002e842190af45b91dc55a97789d0b98e4308c88d886b16049ee90e17a4d396"},
-    {file = "opentelemetry_exporter_otlp_proto_http-1.22.0.tar.gz", hash = "sha256:79ed108981ec68d5f7985355bca32003c2f3a5be1534a96d62d5861b758a82f4"},
+    {file = "opentelemetry_exporter_otlp_proto_http-1.24.0-py3-none-any.whl", hash = "sha256:25af10e46fdf4cd3833175e42f4879a1255fc01655fe14c876183a2903949836"},
+    {file = "opentelemetry_exporter_otlp_proto_http-1.24.0.tar.gz", hash = "sha256:704c066cc96f5131881b75c0eac286cd73fc735c490b054838b4513254bd7850"},
 ]
 
 [package.dependencies]
-backoff = {version = ">=1.10.0,<3.0.0", markers = "python_version >= \"3.7\""}
 deprecated = ">=1.2.6"
 googleapis-common-protos = ">=1.52,<2.0"
 opentelemetry-api = ">=1.15,<2.0"
-opentelemetry-exporter-otlp-proto-common = "1.22.0"
-opentelemetry-proto = "1.22.0"
-opentelemetry-sdk = ">=1.22.0,<1.23.0"
+opentelemetry-exporter-otlp-proto-common = "1.24.0"
+opentelemetry-proto = "1.24.0"
+opentelemetry-sdk = ">=1.24.0,<1.25.0"
 requests = ">=2.7,<3.0"
 
-[package.extras]
-test = ["responses (==0.22.0)"]
-
 [[package]]
 name = "opentelemetry-proto"
-version = "1.22.0"
+version = "1.24.0"
 description = "OpenTelemetry Python Proto"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "opentelemetry_proto-1.22.0-py3-none-any.whl", hash = "sha256:ce7188d22c75b6d0fe53e7fb58501613d0feade5139538e79dedd9420610fa0c"},
-    {file = "opentelemetry_proto-1.22.0.tar.gz", hash = "sha256:9ec29169286029f17ca34ec1f3455802ffb90131642d2f545ece9a63e8f69003"},
+    {file = "opentelemetry_proto-1.24.0-py3-none-any.whl", hash = "sha256:bcb80e1e78a003040db71ccf83f2ad2019273d1e0828089d183b18a1476527ce"},
+    {file = "opentelemetry_proto-1.24.0.tar.gz", hash = "sha256:ff551b8ad63c6cabb1845ce217a6709358dfaba0f75ea1fa21a61ceddc78cab8"},
 ]
 
 [package.dependencies]
@@ -1457,40 +1529,40 @@ protobuf = ">=3.19,<5.0"
 
 [[package]]
 name = "opentelemetry-sdk"
-version = "1.22.0"
+version = "1.24.0"
 description = "OpenTelemetry Python SDK"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "opentelemetry_sdk-1.22.0-py3-none-any.whl", hash = "sha256:a730555713d7c8931657612a88a141e3a4fe6eb5523d9e2d5a8b1e673d76efa6"},
-    {file = "opentelemetry_sdk-1.22.0.tar.gz", hash = "sha256:45267ac1f38a431fc2eb5d6e0c0d83afc0b78de57ac345488aa58c28c17991d0"},
+    {file = "opentelemetry_sdk-1.24.0-py3-none-any.whl", hash = "sha256:fa731e24efe832e98bcd90902085b359dcfef7d9c9c00eb5b9a18587dae3eb59"},
+    {file = "opentelemetry_sdk-1.24.0.tar.gz", hash = "sha256:75bc0563affffa827700e0f4f4a68e1e257db0df13372344aebc6f8a64cde2e5"},
 ]
 
 [package.dependencies]
-opentelemetry-api = "1.22.0"
-opentelemetry-semantic-conventions = "0.43b0"
+opentelemetry-api = "1.24.0"
+opentelemetry-semantic-conventions = "0.45b0"
 typing-extensions = ">=3.7.4"
 
 [[package]]
 name = "opentelemetry-semantic-conventions"
-version = "0.43b0"
+version = "0.45b0"
 description = "OpenTelemetry Semantic Conventions"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "opentelemetry_semantic_conventions-0.43b0-py3-none-any.whl", hash = "sha256:291284d7c1bf15fdaddf309b3bd6d3b7ce12a253cec6d27144439819a15d8445"},
-    {file = "opentelemetry_semantic_conventions-0.43b0.tar.gz", hash = "sha256:b9576fb890df479626fa624e88dde42d3d60b8b6c8ae1152ad157a8b97358635"},
+    {file = "opentelemetry_semantic_conventions-0.45b0-py3-none-any.whl", hash = "sha256:a4a6fb9a7bacd9167c082aa4681009e9acdbfa28ffb2387af50c2fef3d30c864"},
+    {file = "opentelemetry_semantic_conventions-0.45b0.tar.gz", hash = "sha256:7c84215a44ac846bc4b8e32d5e78935c5c43482e491812a0bb8aaf87e4d92118"},
 ]
 
 [[package]]
 name = "packaging"
-version = "23.2"
+version = "24.0"
 description = "Core utilities for Python packages"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"},
-    {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"},
+    {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"},
+    {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"},
 ]
 
 [[package]]
@@ -1564,151 +1636,24 @@ testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)",
 
 [[package]]
 name = "protobuf"
-version = "4.25.2"
+version = "4.25.3"
 description = ""
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "protobuf-4.25.2-cp310-abi3-win32.whl", hash = "sha256:b50c949608682b12efb0b2717f53256f03636af5f60ac0c1d900df6213910fd6"},
-    {file = "protobuf-4.25.2-cp310-abi3-win_amd64.whl", hash = "sha256:8f62574857ee1de9f770baf04dde4165e30b15ad97ba03ceac65f760ff018ac9"},
-    {file = "protobuf-4.25.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:2db9f8fa64fbdcdc93767d3cf81e0f2aef176284071507e3ede160811502fd3d"},
-    {file = "protobuf-4.25.2-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:10894a2885b7175d3984f2be8d9850712c57d5e7587a2410720af8be56cdaf62"},
-    {file = "protobuf-4.25.2-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:fc381d1dd0516343f1440019cedf08a7405f791cd49eef4ae1ea06520bc1c020"},
-    {file = "protobuf-4.25.2-cp38-cp38-win32.whl", hash = "sha256:33a1aeef4b1927431d1be780e87b641e322b88d654203a9e9d93f218ee359e61"},
-    {file = "protobuf-4.25.2-cp38-cp38-win_amd64.whl", hash = "sha256:47f3de503fe7c1245f6f03bea7e8d3ec11c6c4a2ea9ef910e3221c8a15516d62"},
-    {file = "protobuf-4.25.2-cp39-cp39-win32.whl", hash = "sha256:5e5c933b4c30a988b52e0b7c02641760a5ba046edc5e43d3b94a74c9fc57c1b3"},
-    {file = "protobuf-4.25.2-cp39-cp39-win_amd64.whl", hash = "sha256:d66a769b8d687df9024f2985d5137a337f957a0916cf5464d1513eee96a63ff0"},
-    {file = "protobuf-4.25.2-py3-none-any.whl", hash = "sha256:a8b7a98d4ce823303145bf3c1a8bdb0f2f4642a414b196f04ad9853ed0c8f830"},
-    {file = "protobuf-4.25.2.tar.gz", hash = "sha256:fe599e175cb347efc8ee524bcd4b902d11f7262c0e569ececcb89995c15f0a5e"},
-]
-
-[[package]]
-name = "pydantic"
-version = "2.6.1"
-description = "Data validation using Python type hints"
-optional = false
-python-versions = ">=3.8"
-files = [
-    {file = "pydantic-2.6.1-py3-none-any.whl", hash = "sha256:0b6a909df3192245cb736509a92ff69e4fef76116feffec68e93a567347bae6f"},
-    {file = "pydantic-2.6.1.tar.gz", hash = "sha256:4fd5c182a2488dc63e6d32737ff19937888001e2a6d86e94b3f233104a5d1fa9"},
+    {file = "protobuf-4.25.3-cp310-abi3-win32.whl", hash = "sha256:d4198877797a83cbfe9bffa3803602bbe1625dc30d8a097365dbc762e5790faa"},
+    {file = "protobuf-4.25.3-cp310-abi3-win_amd64.whl", hash = "sha256:209ba4cc916bab46f64e56b85b090607a676f66b473e6b762e6f1d9d591eb2e8"},
+    {file = "protobuf-4.25.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f1279ab38ecbfae7e456a108c5c0681e4956d5b1090027c1de0f934dfdb4b35c"},
+    {file = "protobuf-4.25.3-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:e7cb0ae90dd83727f0c0718634ed56837bfeeee29a5f82a7514c03ee1364c019"},
+    {file = "protobuf-4.25.3-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:7c8daa26095f82482307bc717364e7c13f4f1c99659be82890dcfc215194554d"},
+    {file = "protobuf-4.25.3-cp38-cp38-win32.whl", hash = "sha256:f4f118245c4a087776e0a8408be33cf09f6c547442c00395fbfb116fac2f8ac2"},
+    {file = "protobuf-4.25.3-cp38-cp38-win_amd64.whl", hash = "sha256:c053062984e61144385022e53678fbded7aea14ebb3e0305ae3592fb219ccfa4"},
+    {file = "protobuf-4.25.3-cp39-cp39-win32.whl", hash = "sha256:19b270aeaa0099f16d3ca02628546b8baefe2955bbe23224aaf856134eccf1e4"},
+    {file = "protobuf-4.25.3-cp39-cp39-win_amd64.whl", hash = "sha256:e3c97a1555fd6388f857770ff8b9703083de6bf1f9274a002a332d65fbb56c8c"},
+    {file = "protobuf-4.25.3-py3-none-any.whl", hash = "sha256:f0700d54bcf45424477e46a9f0944155b46fb0639d69728739c0e47bab83f2b9"},
+    {file = "protobuf-4.25.3.tar.gz", hash = "sha256:25b5d0b42fd000320bd7830b349e3b696435f3b329810427a6bcce6a5492cc5c"},
 ]
 
-[package.dependencies]
-annotated-types = ">=0.4.0"
-pydantic-core = "2.16.2"
-typing-extensions = ">=4.6.1"
-
-[package.extras]
-email = ["email-validator (>=2.0.0)"]
-
-[[package]]
-name = "pydantic-core"
-version = "2.16.2"
-description = ""
-optional = false
-python-versions = ">=3.8"
-files = [
-    {file = "pydantic_core-2.16.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3fab4e75b8c525a4776e7630b9ee48aea50107fea6ca9f593c98da3f4d11bf7c"},
-    {file = "pydantic_core-2.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8bde5b48c65b8e807409e6f20baee5d2cd880e0fad00b1a811ebc43e39a00ab2"},
-    {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2924b89b16420712e9bb8192396026a8fbd6d8726224f918353ac19c4c043d2a"},
-    {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16aa02e7a0f539098e215fc193c8926c897175d64c7926d00a36188917717a05"},
-    {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:936a787f83db1f2115ee829dd615c4f684ee48ac4de5779ab4300994d8af325b"},
-    {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:459d6be6134ce3b38e0ef76f8a672924460c455d45f1ad8fdade36796df1ddc8"},
-    {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9ee4febb249c591d07b2d4dd36ebcad0ccd128962aaa1801508320896575ef"},
-    {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40a0bd0bed96dae5712dab2aba7d334a6c67cbcac2ddfca7dbcc4a8176445990"},
-    {file = "pydantic_core-2.16.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:870dbfa94de9b8866b37b867a2cb37a60c401d9deb4a9ea392abf11a1f98037b"},
-    {file = "pydantic_core-2.16.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:308974fdf98046db28440eb3377abba274808bf66262e042c412eb2adf852731"},
-    {file = "pydantic_core-2.16.2-cp310-none-win32.whl", hash = "sha256:a477932664d9611d7a0816cc3c0eb1f8856f8a42435488280dfbf4395e141485"},
-    {file = "pydantic_core-2.16.2-cp310-none-win_amd64.whl", hash = "sha256:8f9142a6ed83d90c94a3efd7af8873bf7cefed2d3d44387bf848888482e2d25f"},
-    {file = "pydantic_core-2.16.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:406fac1d09edc613020ce9cf3f2ccf1a1b2f57ab00552b4c18e3d5276c67eb11"},
-    {file = "pydantic_core-2.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ce232a6170dd6532096cadbf6185271e4e8c70fc9217ebe105923ac105da9978"},
-    {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a90fec23b4b05a09ad988e7a4f4e081711a90eb2a55b9c984d8b74597599180f"},
-    {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8aafeedb6597a163a9c9727d8a8bd363a93277701b7bfd2749fbefee2396469e"},
-    {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9957433c3a1b67bdd4c63717eaf174ebb749510d5ea612cd4e83f2d9142f3fc8"},
-    {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0d7a9165167269758145756db43a133608a531b1e5bb6a626b9ee24bc38a8f7"},
-    {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dffaf740fe2e147fedcb6b561353a16243e654f7fe8e701b1b9db148242e1272"},
-    {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8ed79883b4328b7f0bd142733d99c8e6b22703e908ec63d930b06be3a0e7113"},
-    {file = "pydantic_core-2.16.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cf903310a34e14651c9de056fcc12ce090560864d5a2bb0174b971685684e1d8"},
-    {file = "pydantic_core-2.16.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:46b0d5520dbcafea9a8645a8164658777686c5c524d381d983317d29687cce97"},
-    {file = "pydantic_core-2.16.2-cp311-none-win32.whl", hash = "sha256:70651ff6e663428cea902dac297066d5c6e5423fda345a4ca62430575364d62b"},
-    {file = "pydantic_core-2.16.2-cp311-none-win_amd64.whl", hash = "sha256:98dc6f4f2095fc7ad277782a7c2c88296badcad92316b5a6e530930b1d475ebc"},
-    {file = "pydantic_core-2.16.2-cp311-none-win_arm64.whl", hash = "sha256:ef6113cd31411eaf9b39fc5a8848e71c72656fd418882488598758b2c8c6dfa0"},
-    {file = "pydantic_core-2.16.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:88646cae28eb1dd5cd1e09605680c2b043b64d7481cdad7f5003ebef401a3039"},
-    {file = "pydantic_core-2.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7b883af50eaa6bb3299780651e5be921e88050ccf00e3e583b1e92020333304b"},
-    {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bf26c2e2ea59d32807081ad51968133af3025c4ba5753e6a794683d2c91bf6e"},
-    {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:99af961d72ac731aae2a1b55ccbdae0733d816f8bfb97b41909e143de735f522"},
-    {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02906e7306cb8c5901a1feb61f9ab5e5c690dbbeaa04d84c1b9ae2a01ebe9379"},
-    {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5362d099c244a2d2f9659fb3c9db7c735f0004765bbe06b99be69fbd87c3f15"},
-    {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ac426704840877a285d03a445e162eb258924f014e2f074e209d9b4ff7bf380"},
-    {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b94cbda27267423411c928208e89adddf2ea5dd5f74b9528513f0358bba019cb"},
-    {file = "pydantic_core-2.16.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6db58c22ac6c81aeac33912fb1af0e930bc9774166cdd56eade913d5f2fff35e"},
-    {file = "pydantic_core-2.16.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:396fdf88b1b503c9c59c84a08b6833ec0c3b5ad1a83230252a9e17b7dfb4cffc"},
-    {file = "pydantic_core-2.16.2-cp312-none-win32.whl", hash = "sha256:7c31669e0c8cc68400ef0c730c3a1e11317ba76b892deeefaf52dcb41d56ed5d"},
-    {file = "pydantic_core-2.16.2-cp312-none-win_amd64.whl", hash = "sha256:a3b7352b48fbc8b446b75f3069124e87f599d25afb8baa96a550256c031bb890"},
-    {file = "pydantic_core-2.16.2-cp312-none-win_arm64.whl", hash = "sha256:a9e523474998fb33f7c1a4d55f5504c908d57add624599e095c20fa575b8d943"},
-    {file = "pydantic_core-2.16.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:ae34418b6b389d601b31153b84dce480351a352e0bb763684a1b993d6be30f17"},
-    {file = "pydantic_core-2.16.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:732bd062c9e5d9582a30e8751461c1917dd1ccbdd6cafb032f02c86b20d2e7ec"},
-    {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4b52776a2e3230f4854907a1e0946eec04d41b1fc64069ee774876bbe0eab55"},
-    {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef551c053692b1e39e3f7950ce2296536728871110e7d75c4e7753fb30ca87f4"},
-    {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ebb892ed8599b23fa8f1799e13a12c87a97a6c9d0f497525ce9858564c4575a4"},
-    {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa6c8c582036275997a733427b88031a32ffa5dfc3124dc25a730658c47a572f"},
-    {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4ba0884a91f1aecce75202473ab138724aa4fb26d7707f2e1fa6c3e68c84fbf"},
-    {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7924e54f7ce5d253d6160090ddc6df25ed2feea25bfb3339b424a9dd591688bc"},
-    {file = "pydantic_core-2.16.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69a7b96b59322a81c2203be537957313b07dd333105b73db0b69212c7d867b4b"},
-    {file = "pydantic_core-2.16.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7e6231aa5bdacda78e96ad7b07d0c312f34ba35d717115f4b4bff6cb87224f0f"},
-    {file = "pydantic_core-2.16.2-cp38-none-win32.whl", hash = "sha256:41dac3b9fce187a25c6253ec79a3f9e2a7e761eb08690e90415069ea4a68ff7a"},
-    {file = "pydantic_core-2.16.2-cp38-none-win_amd64.whl", hash = "sha256:f685dbc1fdadb1dcd5b5e51e0a378d4685a891b2ddaf8e2bba89bd3a7144e44a"},
-    {file = "pydantic_core-2.16.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:55749f745ebf154c0d63d46c8c58594d8894b161928aa41adbb0709c1fe78b77"},
-    {file = "pydantic_core-2.16.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b30b0dd58a4509c3bd7eefddf6338565c4905406aee0c6e4a5293841411a1286"},
-    {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18de31781cdc7e7b28678df7c2d7882f9692ad060bc6ee3c94eb15a5d733f8f7"},
-    {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5864b0242f74b9dd0b78fd39db1768bc3f00d1ffc14e596fd3e3f2ce43436a33"},
-    {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8f9186ca45aee030dc8234118b9c0784ad91a0bb27fc4e7d9d6608a5e3d386c"},
-    {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc6f6c9be0ab6da37bc77c2dda5f14b1d532d5dbef00311ee6e13357a418e646"},
-    {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa057095f621dad24a1e906747179a69780ef45cc8f69e97463692adbcdae878"},
-    {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6ad84731a26bcfb299f9eab56c7932d46f9cad51c52768cace09e92a19e4cf55"},
-    {file = "pydantic_core-2.16.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3b052c753c4babf2d1edc034c97851f867c87d6f3ea63a12e2700f159f5c41c3"},
-    {file = "pydantic_core-2.16.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e0f686549e32ccdb02ae6f25eee40cc33900910085de6aa3790effd391ae10c2"},
-    {file = "pydantic_core-2.16.2-cp39-none-win32.whl", hash = "sha256:7afb844041e707ac9ad9acad2188a90bffce2c770e6dc2318be0c9916aef1469"},
-    {file = "pydantic_core-2.16.2-cp39-none-win_amd64.whl", hash = "sha256:9da90d393a8227d717c19f5397688a38635afec89f2e2d7af0df037f3249c39a"},
-    {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5f60f920691a620b03082692c378661947d09415743e437a7478c309eb0e4f82"},
-    {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:47924039e785a04d4a4fa49455e51b4eb3422d6eaacfde9fc9abf8fdef164e8a"},
-    {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6294e76b0380bb7a61eb8a39273c40b20beb35e8c87ee101062834ced19c545"},
-    {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe56851c3f1d6f5384b3051c536cc81b3a93a73faf931f404fef95217cf1e10d"},
-    {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9d776d30cde7e541b8180103c3f294ef7c1862fd45d81738d156d00551005784"},
-    {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:72f7919af5de5ecfaf1eba47bf9a5d8aa089a3340277276e5636d16ee97614d7"},
-    {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:4bfcbde6e06c56b30668a0c872d75a7ef3025dc3c1823a13cf29a0e9b33f67e8"},
-    {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ff7c97eb7a29aba230389a2661edf2e9e06ce616c7e35aa764879b6894a44b25"},
-    {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9b5f13857da99325dcabe1cc4e9e6a3d7b2e2c726248ba5dd4be3e8e4a0b6d0e"},
-    {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a7e41e3ada4cca5f22b478c08e973c930e5e6c7ba3588fb8e35f2398cdcc1545"},
-    {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60eb8ceaa40a41540b9acae6ae7c1f0a67d233c40dc4359c256ad2ad85bdf5e5"},
-    {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7beec26729d496a12fd23cf8da9944ee338c8b8a17035a560b585c36fe81af20"},
-    {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22c5f022799f3cd6741e24f0443ead92ef42be93ffda0d29b2597208c94c3753"},
-    {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:eca58e319f4fd6df004762419612122b2c7e7d95ffafc37e890252f869f3fb2a"},
-    {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ed957db4c33bc99895f3a1672eca7e80e8cda8bd1e29a80536b4ec2153fa9804"},
-    {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:459c0d338cc55d099798618f714b21b7ece17eb1a87879f2da20a3ff4c7628e2"},
-    {file = "pydantic_core-2.16.2.tar.gz", hash = "sha256:0ba503850d8b8dcc18391f10de896ae51d37fe5fe43dbfb6a35c5c5cad271a06"},
-]
-
-[package.dependencies]
-typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
-
-[[package]]
-name = "pydocstyle"
-version = "6.3.0"
-description = "Python docstring style checker"
-optional = false
-python-versions = ">=3.6"
-files = [
-    {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"},
-    {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"},
-]
-
-[package.dependencies]
-snowballstemmer = ">=2.2.0"
-
-[package.extras]
-toml = ["tomli (>=1.2.3)"]
-
 [[package]]
 name = "pygments"
 version = "2.17.2"
@@ -1743,13 +1688,13 @@ tests = ["flaky", "pytest", "pytest-cov", "pytest-random-order", "pyyaml"]
 
 [[package]]
 name = "pymdown-extensions"
-version = "10.7"
+version = "10.7.1"
 description = "Extension pack for Python Markdown."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "pymdown_extensions-10.7-py3-none-any.whl", hash = "sha256:6ca215bc57bc12bf32b414887a68b810637d039124ed9b2e5bd3325cbb2c050c"},
-    {file = "pymdown_extensions-10.7.tar.gz", hash = "sha256:c0d64d5cf62566f59e6b2b690a4095c931107c250a8c8e1351c1de5f6b036deb"},
+    {file = "pymdown_extensions-10.7.1-py3-none-any.whl", hash = "sha256:f5cc7000d7ff0d1ce9395d216017fa4df3dde800afb1fb72d1c7d3fd35e710f4"},
+    {file = "pymdown_extensions-10.7.1.tar.gz", hash = "sha256:c70e146bdd83c744ffc766b4671999796aba18842b268510a329f7f64700d584"},
 ]
 
 [package.dependencies]
@@ -1781,13 +1726,13 @@ testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygm
 
 [[package]]
 name = "pytest-cov"
-version = "5.0.0"
+version = "4.1.0"
 description = "Pytest plugin for measuring coverage."
 optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.7"
 files = [
-    {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"},
-    {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"},
+    {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"},
+    {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"},
 ]
 
 [package.dependencies]
@@ -1795,7 +1740,24 @@ coverage = {version = ">=5.2.1", extras = ["toml"]}
 pytest = ">=4.6"
 
 [package.extras]
-testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"]
+testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "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"
@@ -1828,13 +1790,13 @@ ruff = ">=0.0.242"
 
 [[package]]
 name = "python-dateutil"
-version = "2.8.2"
+version = "2.9.0.post0"
 description = "Extensions to the standard Python datetime module"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
 files = [
-    {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
-    {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
+    {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
+    {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
 ]
 
 [package.dependencies]
@@ -1893,7 +1855,6 @@ files = [
     {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
     {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
     {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
-    {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
     {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
     {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
     {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
@@ -2111,19 +2072,19 @@ files = [
 
 [[package]]
 name = "setuptools"
-version = "69.0.3"
+version = "69.2.0"
 description = "Easily download, build, install, upgrade, and uninstall Python packages"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "setuptools-69.0.3-py3-none-any.whl", hash = "sha256:385eb4edd9c9d5c17540511303e39a147ce2fc04bc55289c322b9e5904fe2c05"},
-    {file = "setuptools-69.0.3.tar.gz", hash = "sha256:be1af57fc409f93647f2e8e4573a142ed38724b8cdd389706a867bb4efcf1e78"},
+    {file = "setuptools-69.2.0-py3-none-any.whl", hash = "sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c"},
+    {file = "setuptools-69.2.0.tar.gz", hash = "sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e"},
 ]
 
 [package.extras]
 docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
-testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
-testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
+testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
+testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
 
 [[package]]
 name = "six"
@@ -2138,35 +2099,24 @@ files = [
 
 [[package]]
 name = "sniffio"
-version = "1.3.0"
+version = "1.3.1"
 description = "Sniff out which async library your code is running under"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"},
-    {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"},
-]
-
-[[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"},
+    {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
+    {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
 ]
 
 [[package]]
 name = "tempora"
-version = "5.5.0"
+version = "5.5.1"
 description = "Objects and routines pertaining to date and time (tempora)"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "tempora-5.5.0-py3-none-any.whl", hash = "sha256:defd976c9144fc9f20432ed0d75926c1c48dd0c7c701b3493e02d5316598e8fe"},
-    {file = "tempora-5.5.0.tar.gz", hash = "sha256:13e4fcc997d0509c3306d6841f03e9381b7e5e46b2bebfae9151af90085f0c26"},
+    {file = "tempora-5.5.1-py3-none-any.whl", hash = "sha256:33c1ef063c41d9ea14e7c2f809783241c982863e813d6a9cd138d1a48d796ce8"},
+    {file = "tempora-5.5.1.tar.gz", hash = "sha256:a2bb51e2121976d931347b3e433917c364b83fdd5f64ef27336c865bf1fb0f75"},
 ]
 
 [package.dependencies]
@@ -2174,8 +2124,8 @@ files = [
 pytz = "*"
 
 [package.extras]
-docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-testing = ["backports.unittest-mock", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-freezer", "pytest-mypy (>=0.9.1)", "pytest-ruff", "types-pytz"]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-freezer", "pytest-mypy", "pytest-ruff (>=0.2.1)", "types-pytz"]
 
 [[package]]
 name = "text-unidecode"
@@ -2188,15 +2138,58 @@ files = [
     {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"},
 ]
 
+[[package]]
+name = "typeguard"
+version = "4.2.1"
+description = "Run-time type checker for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "typeguard-4.2.1-py3-none-any.whl", hash = "sha256:7da3bd46e61f03e0852f8d251dcbdc2a336aa495d7daff01e092b55327796eb8"},
+    {file = "typeguard-4.2.1.tar.gz", hash = "sha256:c556a1b95948230510070ca53fa0341fb0964611bd05d598d87fb52115d65fee"},
+]
+
+[package.dependencies]
+typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""}
+
+[package.extras]
+doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)"]
+test = ["coverage[toml] (>=7)", "mypy (>=1.2.0)", "pytest (>=7)"]
+
+[[package]]
+name = "types-pytz"
+version = "2024.1.0.20240203"
+description = "Typing stubs for pytz"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "types-pytz-2024.1.0.20240203.tar.gz", hash = "sha256:c93751ee20dfc6e054a0148f8f5227b9a00b79c90a4d3c9f464711a73179c89e"},
+    {file = "types_pytz-2024.1.0.20240203-py3-none-any.whl", hash = "sha256:9679eef0365db3af91ef7722c199dbb75ee5c1b67e3c4dd7bfbeb1b8a71c21a3"},
+]
+
+[[package]]
+name = "types-requests"
+version = "2.31.0.20240406"
+description = "Typing stubs for requests"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "types-requests-2.31.0.20240406.tar.gz", hash = "sha256:4428df33c5503945c74b3f42e82b181e86ec7b724620419a2966e2de604ce1a1"},
+    {file = "types_requests-2.31.0.20240406-py3-none-any.whl", hash = "sha256:6216cdac377c6b9a040ac1c0404f7284bd13199c0e1bb235f4324627e8898cf5"},
+]
+
+[package.dependencies]
+urllib3 = ">=2"
+
 [[package]]
 name = "typing-extensions"
-version = "4.9.0"
+version = "4.11.0"
 description = "Backported and Experimental Type Hints for Python 3.8+"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"},
-    {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"},
+    {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"},
+    {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"},
 ]
 
 [[package]]
@@ -2212,13 +2205,13 @@ files = [
 
 [[package]]
 name = "urllib3"
-version = "2.2.0"
+version = "2.2.1"
 description = "HTTP library with thread-safe connection pooling, file post, and more."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "urllib3-2.2.0-py3-none-any.whl", hash = "sha256:ce3711610ddce217e6d113a2732fafad960a03fd0318c91faa79481e35c11224"},
-    {file = "urllib3-2.2.0.tar.gz", hash = "sha256:051d961ad0c62a94e50ecf1af379c3aba230c66c710493493560c0c223c49f20"},
+    {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"},
+    {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"},
 ]
 
 [package.extras]
@@ -2383,20 +2376,20 @@ test = ["zope.testing"]
 
 [[package]]
 name = "zipp"
-version = "3.17.0"
+version = "3.18.1"
 description = "Backport of pathlib-compatible object wrapper for zip files"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"},
-    {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"},
+    {file = "zipp-3.18.1-py3-none-any.whl", hash = "sha256:206f5a15f2af3dbaee80769fb7dc6f249695e940acca08dfb2a4769fe61e538b"},
+    {file = "zipp-3.18.1.tar.gz", hash = "sha256:2884ed22e7d8961de1c9a05142eb69a247f120291bc0206a00a7642f09b5b715"},
 ]
 
 [package.extras]
-docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"]
-testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
 
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.11"
-content-hash = "2d928842af06d83ef0fa32193dee41e078bcdc55494738d60dacd1f4ac052dec"
+content-hash = "76127712b49cacbdcb6818678f8f9bcffbf5ec8ff78ccbb5640c4c9ab90a3ac0"
diff --git a/pyproject.toml b/pyproject.toml
index 16de05e8..bab4c5de 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,7 +11,7 @@ classifiers = [
 ]
 readme = "README.md"
 packages = [
-    { include = "nowplaying"},
+    { include = "nowplaying" },
 ]
 
 [tool.poetry.scripts]
@@ -36,13 +36,6 @@ 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,<6"
-pytest-random-order = "^1.1.0"
-pytest-ruff = ">=0.1,<0.4"
-ruff = ">=0.0.275,<0.3.6"
 faker = ">=18.11.2,<25.0.0"
 mkdocs = "^1.5.3"
 mkdocs-material = "^9.4.7"
@@ -51,14 +44,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.0.0"
+pytest-mypy = "^0.10.3"
+pytest-random-order = "^1.1.0"
+pytest-ruff = ">=0.1,<0.4"
+ruff = ">=0.0.275,<0.3.6"
+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 --mypy --ignore docs/"
 filterwarnings = [
     "ignore::DeprecationWarning:cairosvg",
     "ignore::DeprecationWarning:cherrypy",
@@ -67,6 +65,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..d7ff6e46
--- /dev/null
+++ b/ruff.toml
@@ -0,0 +1,76 @@
+# [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)
+]
+
+[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..72488e8f 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..54e8bac8 100644
--- a/tests/test_input_observer_base.py
+++ b/tests/test_input_observer_base.py
@@ -1,21 +1,29 @@
-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 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)