Add Settings subclasses to all services and auto-discovered init tests
- Add dedicated Settings subclasses to 20 LLM services that were borrowing parent Settings classes (e.g. AzureLLMSettings, GroqLLMSettings) so users don't need cross-module imports - Fix field defaults to NOT_GIVEN in BaseWhisperSTTSettings, OpenAIRealtimeSTTSettings, and NvidiaSegmentedSTTSettings for delta-mode safety - Fix incomplete default_settings in AWS, Cartesia, ElevenLabs, Fish, and Whisper services so validate_complete() passes - Add auto-discovered tests that verify all Settings classes default to NOT_GIVEN (delta safety) and all services initialize with complete settings (store completeness)
This commit is contained in:
181
tests/test_service_init.py
Normal file
181
tests/test_service_init.py
Normal file
@@ -0,0 +1,181 @@
|
||||
#
|
||||
# Copyright (c) 2024-2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Tests for service settings and initialization patterns.
|
||||
|
||||
Settings objects operate in two modes:
|
||||
|
||||
- **Store mode** (``self._settings``): the live state inside a service.
|
||||
Every field must hold a real value (``None`` is fine, ``NOT_GIVEN`` is not).
|
||||
- **Delta mode** (``FooSettings()`` with no args): a sparse update.
|
||||
Every field must default to ``NOT_GIVEN`` so ``apply_update()`` skips
|
||||
untouched fields and doesn't accidentally overwrite the store.
|
||||
|
||||
These tests verify both sides of that contract automatically:
|
||||
|
||||
1. **Delta defaults** — Instantiate every ``ServiceSettings`` subclass with
|
||||
no arguments and assert that every field is ``NOT_GIVEN``. Catches the
|
||||
bug where a field defaults to ``None`` instead of ``NOT_GIVEN``, which
|
||||
would cause partial deltas to silently overwrite unrelated store values.
|
||||
|
||||
2. **Store completeness** — Instantiate every concrete service with dummy
|
||||
args and assert that ``_settings`` contains no ``NOT_GIVEN`` values.
|
||||
This is the same check that ``validate_complete()`` runs in ``start()``,
|
||||
but caught here at unit-test time without needing a running pipeline.
|
||||
Catches services that forget to initialize a field in ``default_settings``.
|
||||
|
||||
All Settings and Service classes are auto-discovered via ``pkgutil``;
|
||||
new services are covered automatically with no per-service maintenance.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import pkgutil
|
||||
import warnings
|
||||
from dataclasses import fields
|
||||
|
||||
import pytest
|
||||
|
||||
import pipecat.services
|
||||
from pipecat.services.ai_service import AIService
|
||||
from pipecat.services.settings import ServiceSettings, is_given
|
||||
|
||||
# Modules that define abstract base service classes (not concrete services).
|
||||
_BASE_MODULES = frozenset(
|
||||
{
|
||||
"pipecat.services.ai_service",
|
||||
"pipecat.services.llm_service",
|
||||
"pipecat.services.stt_service",
|
||||
"pipecat.services.tts_service",
|
||||
"pipecat.services.image_gen_service",
|
||||
"pipecat.services.vision_service",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _all_subclasses(cls):
|
||||
result = set()
|
||||
for sub in cls.__subclasses__():
|
||||
result.add(sub)
|
||||
result.update(_all_subclasses(sub))
|
||||
return result
|
||||
|
||||
|
||||
def _import_all_service_modules():
|
||||
"""Import every module under pipecat.services (skipping missing deps)."""
|
||||
package = pipecat.services
|
||||
for _importer, modname, _ispkg in pkgutil.walk_packages(
|
||||
package.__path__, prefix=package.__name__ + "."
|
||||
):
|
||||
try:
|
||||
importlib.import_module(modname)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
_import_all_service_modules()
|
||||
|
||||
ALL_SETTINGS_CLASSES = sorted(_all_subclasses(ServiceSettings), key=lambda c: c.__qualname__)
|
||||
assert ALL_SETTINGS_CLASSES, "No settings classes discovered"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service instantiation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _try_instantiate(cls):
|
||||
"""Try to instantiate a service with dummy values for required args.
|
||||
|
||||
Inspects the __init__ signature and passes "test" for every required
|
||||
keyword-only parameter. Services that need non-string required args
|
||||
or fail for other reasons will raise and be skipped by the test.
|
||||
"""
|
||||
sig = inspect.signature(cls.__init__)
|
||||
kwargs = {}
|
||||
for name, param in sig.parameters.items():
|
||||
if name == "self":
|
||||
continue
|
||||
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
|
||||
continue
|
||||
if param.default is not param.empty:
|
||||
continue
|
||||
# Required parameter — pass a dummy string
|
||||
kwargs[name] = "test"
|
||||
return cls(**kwargs)
|
||||
|
||||
|
||||
def _discover_service_classes():
|
||||
"""Return concrete service classes that can be instantiated with dummy args."""
|
||||
result = []
|
||||
for cls in sorted(_all_subclasses(AIService), key=lambda c: c.__qualname__):
|
||||
# Skip abstract base classes defined in framework modules.
|
||||
if cls.__module__ in _BASE_MODULES:
|
||||
continue
|
||||
try:
|
||||
svc = _try_instantiate(cls)
|
||||
except Exception:
|
||||
continue
|
||||
if hasattr(svc, "_settings"):
|
||||
result.append(cls)
|
||||
return result
|
||||
|
||||
|
||||
ALL_SERVICE_CLASSES = _discover_service_classes()
|
||||
assert ALL_SERVICE_CLASSES, "No service classes could be instantiated"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Settings defaults: delta-mode safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("settings_cls", ALL_SETTINGS_CLASSES, ids=lambda c: c.__qualname__)
|
||||
def test_delta_defaults_are_not_given(settings_cls):
|
||||
"""Every field must default to NOT_GIVEN so empty deltas are no-ops.
|
||||
|
||||
A field that defaults to None instead of NOT_GIVEN will cause
|
||||
apply_update() to overwrite the corresponding store value whenever
|
||||
a partial delta is applied.
|
||||
"""
|
||||
instance = settings_cls()
|
||||
for f in fields(instance):
|
||||
if f.name == "extra":
|
||||
continue
|
||||
val = getattr(instance, f.name)
|
||||
assert not is_given(val), (
|
||||
f"{settings_cls.__qualname__}.{f.name} defaults to {val!r}, expected NOT_GIVEN"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Service construction: store-mode completeness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("service_cls", ALL_SERVICE_CLASSES, ids=lambda c: c.__qualname__)
|
||||
def test_service_settings_complete(service_cls):
|
||||
"""After construction, _settings must have no NOT_GIVEN values.
|
||||
|
||||
This is what validate_complete() checks in start(). Catching it
|
||||
here means we don't need a running pipeline to find missing defaults.
|
||||
"""
|
||||
try:
|
||||
svc = _try_instantiate(service_cls)
|
||||
except Exception:
|
||||
pytest.skip("Cannot re-instantiate (environment issue)")
|
||||
for f in fields(svc._settings):
|
||||
if f.name == "extra":
|
||||
continue
|
||||
val = getattr(svc._settings, f.name)
|
||||
assert is_given(val), (
|
||||
f"{service_cls.__qualname__}._settings.{f.name} is NOT_GIVEN after construction"
|
||||
)
|
||||
Reference in New Issue
Block a user