refactor(search): centralize route context dependency resolution in SearchDependencies

This commit is contained in:
Dariusz L
2026-08-02 01:04:10 +02:00
parent b4b03bb335
commit b9054f0d22
3 changed files with 244 additions and 48 deletions
+190
View File
@@ -0,0 +1,190 @@
"""Typed dependencies shared by search orchestration and providers."""
from dataclasses import dataclass
from typing import Any, Protocol
from ..routes.context import RouteContext
class LoggerProtocol(Protocol):
def debug(self, message: str, **kwargs: Any) -> Any: ...
def info(self, message: str, **kwargs: Any) -> Any: ...
def warning(self, message: str, **kwargs: Any) -> Any: ...
def exception(self, message: str, **kwargs: Any) -> Any: ...
class SearchTrackerProtocol(Protocol):
def is_cancelled(self, progress_id: str) -> bool: ...
def update(self, *args: Any, **kwargs: Any) -> Any: ...
class BoolParserProtocol(Protocol):
def __call__(self, value: Any, default: bool = False) -> bool: ...
class IntParserProtocol(Protocol):
def __call__(self, value: Any, default: int = 0) -> int: ...
class SearchResultBuilderProtocol(Protocol):
def __call__(self, source: str, **fields: Any) -> dict[str, Any]: ...
class ClearCacheProtocol(Protocol):
def __call__(self) -> Any: ...
class ReloadProtocol(Protocol):
def __call__(self) -> Any: ...
class ExtractSha256Protocol(Protocol):
def __call__(self, metadata: dict[str, Any]) -> str | None: ...
class FormatSizeProtocol(Protocol):
def __call__(self, value: Any, include_space: bool = True) -> str: ...
class SearchProviderProtocol(Protocol):
def __call__(self, filename: str, **options: Any) -> Any: ...
class PopularModelUrlProtocol(Protocol):
def __call__(self, filename: str) -> dict[str, Any] | None: ...
class ModelListSearchProtocol(Protocol):
def __call__(self, filename: str) -> dict[str, Any] | None: ...
class LocalHashSearchProtocol(Protocol):
def __call__(
self,
sha256: str,
*,
category: str | None,
max_matches: int,
force_rescan: bool,
) -> list[dict[str, Any]]: ...
class ResolveUrnProtocol(Protocol):
def __call__(self, model_id: Any, version_id: Any) -> dict[str, Any] | None: ...
class ResolveArchiveVersionProtocol(Protocol):
def __call__(
self,
model_id: Any,
version_id: Any,
*,
query: str,
) -> dict[str, Any] | None: ...
class GetDownloadUrlProtocol(Protocol):
def __call__(self, version_id: Any) -> str | None: ...
@dataclass(frozen=True, slots=True)
class SearchDependencies:
"""Validated services and provider functions used by search code."""
search_tracker: SearchTrackerProtocol
search_result_timestamps: dict[str, str]
logger: LoggerProtocol
civarchive_search_error: type[BaseException]
asyncio: Any
build_search_result: SearchResultBuilderProtocol
clear_civarchive_search_cache: ClearCacheProtocol
clear_civitai_search_cache: ClearCacheProtocol
clear_huggingface_search_cache: ClearCacheProtocol
clear_lora_manager_archive_search_cache: ClearCacheProtocol
extract_sha256_from_metadata: ExtractSha256Protocol
format_size_bytes: FormatSizeProtocol
get_civitai_download_url: GetDownloadUrlProtocol
get_popular_model_url: PopularModelUrlProtocol
reload_model_list: ReloadProtocol
reload_popular_databases: ReloadProtocol
resolve_civarchive_model_version: ResolveArchiveVersionProtocol
resolve_urn: ResolveUrnProtocol
search_civarchive_for_file: SearchProviderProtocol
search_civitai: SearchProviderProtocol
search_civitai_for_file: SearchProviderProtocol
search_huggingface_for_file: SearchProviderProtocol
search_local_matches_by_hash: LocalHashSearchProtocol
search_lora_manager_archive_for_file: SearchProviderProtocol
search_model_list: ModelListSearchProtocol
to_bool: BoolParserProtocol
to_int: IntParserProtocol
web: Any
@staticmethod
def _extension_value(extension: Any, name: str) -> Any:
try:
return getattr(extension, name)
except AttributeError as exc:
raise KeyError(f"Missing route dependency: self.{name}") from exc
@classmethod
def from_context(cls, context: RouteContext) -> "SearchDependencies":
"""Build dependencies and fail fast on an incomplete route context."""
extension = context.require("self")
return cls(
search_tracker=cls._extension_value(extension, "search_tracker"),
search_result_timestamps=cls._extension_value(
extension,
"search_result_timestamps",
),
logger=cls._extension_value(extension, "logger"),
asyncio=context.require("asyncio"),
civarchive_search_error=context.require("CivArchiveSearchError"),
build_search_result=context.require("build_search_result"),
clear_civarchive_search_cache=context.require(
"clear_civarchive_search_cache"
),
clear_civitai_search_cache=context.require(
"clear_civitai_search_cache"
),
clear_huggingface_search_cache=context.require(
"clear_huggingface_search_cache"
),
clear_lora_manager_archive_search_cache=context.require(
"clear_lora_manager_archive_search_cache"
),
extract_sha256_from_metadata=context.require(
"extract_sha256_from_metadata"
),
format_size_bytes=context.require("format_size_bytes"),
get_civitai_download_url=context.require("get_civitai_download_url"),
get_popular_model_url=context.require("get_popular_model_url"),
reload_model_list=context.require("reload_model_list"),
reload_popular_databases=context.require("reload_popular_databases"),
resolve_civarchive_model_version=context.require(
"resolve_civarchive_model_version"
),
resolve_urn=context.require("resolve_urn"),
search_civarchive_for_file=context.require(
"search_civarchive_for_file"
),
search_civitai=context.require("search_civitai"),
search_civitai_for_file=context.require("search_civitai_for_file"),
search_huggingface_for_file=context.require(
"search_huggingface_for_file"
),
search_local_matches_by_hash=context.require(
"search_local_matches_by_hash"
),
search_lora_manager_archive_for_file=context.require(
"search_lora_manager_archive_for_file"
),
search_model_list=context.require("search_model_list"),
to_bool=context.require("to_bool"),
to_int=context.require("to_int"),
web=context.require("web"),
)
+36 -48
View File
@@ -2,6 +2,7 @@
from ..routes.context import RouteContext
from .search_cache import SearchResultCache
from .search_dependencies import SearchDependencies
from .search_providers import (
SearchCancelled,
SearchProviderRunner,
@@ -13,59 +14,46 @@ class SearchOrchestrator:
"""Parse search requests, coordinate providers, and build responses."""
def __init__(self, context: RouteContext):
extension = context.get("self")
self.search_tracker = extension.search_tracker
self.search_result_timestamps = extension.search_result_timestamps
self.logger = extension.logger
self.CivArchiveSearchError = context.get("CivArchiveSearchError")
self.asyncio = context.get("asyncio")
self.build_search_result = context.get("build_search_result")
self.cancel_progress_response = context.get("cancel_progress_response")
self.clear_civarchive_search_cache = context.get(
"clear_civarchive_search_cache"
dependencies = SearchDependencies.from_context(context)
self.dependencies = dependencies
self.search_tracker = dependencies.search_tracker
self.search_result_timestamps = dependencies.search_result_timestamps
self.logger = dependencies.logger
self.CivArchiveSearchError = dependencies.civarchive_search_error
self.asyncio = dependencies.asyncio
self.build_search_result = dependencies.build_search_result
self.clear_civarchive_search_cache = (
dependencies.clear_civarchive_search_cache
)
self.clear_civitai_search_cache = context.get(
"clear_civitai_search_cache"
self.clear_civitai_search_cache = dependencies.clear_civitai_search_cache
self.clear_huggingface_search_cache = (
dependencies.clear_huggingface_search_cache
)
self.clear_huggingface_search_cache = context.get(
"clear_huggingface_search_cache"
self.clear_lora_manager_archive_search_cache = (
dependencies.clear_lora_manager_archive_search_cache
)
self.clear_lora_manager_archive_search_cache = context.get(
"clear_lora_manager_archive_search_cache"
self.extract_sha256_from_metadata = dependencies.extract_sha256_from_metadata
self.format_size_bytes = dependencies.format_size_bytes
self.get_civitai_download_url = dependencies.get_civitai_download_url
self.get_popular_model_url = dependencies.get_popular_model_url
self.reload_model_list = dependencies.reload_model_list
self.reload_popular_databases = dependencies.reload_popular_databases
self.resolve_civarchive_model_version = (
dependencies.resolve_civarchive_model_version
)
self.extract_sha256_from_metadata = context.get(
"extract_sha256_from_metadata"
self.resolve_urn = dependencies.resolve_urn
self.search_civarchive_for_file = dependencies.search_civarchive_for_file
self.search_civitai = dependencies.search_civitai
self.search_civitai_for_file = dependencies.search_civitai_for_file
self.search_huggingface_for_file = dependencies.search_huggingface_for_file
self.search_local_matches_by_hash = dependencies.search_local_matches_by_hash
self.search_lora_manager_archive_for_file = (
dependencies.search_lora_manager_archive_for_file
)
self.format_size_bytes = context.get("format_size_bytes")
self.get_civitai_download_url = context.get("get_civitai_download_url")
self.get_popular_model_url = context.get("get_popular_model_url")
self.get_progress_response = context.get("get_progress_response")
self.json_api_endpoint = context.get("json_api_endpoint")
self.reload_model_list = context.get("reload_model_list")
self.reload_popular_databases = context.get("reload_popular_databases")
self.resolve_civarchive_model_version = context.get(
"resolve_civarchive_model_version"
)
self.resolve_urn = context.get("resolve_urn")
self.routes = context.get("routes")
self.search_civarchive_for_file = context.get(
"search_civarchive_for_file"
)
self.search_civitai = context.get("search_civitai")
self.search_civitai_for_file = context.get("search_civitai_for_file")
self.search_huggingface_for_file = context.get(
"search_huggingface_for_file"
)
self.search_local_matches_by_hash = context.get(
"search_local_matches_by_hash"
)
self.search_lora_manager_archive_for_file = context.get(
"search_lora_manager_archive_for_file"
)
self.search_model_list = context.get("search_model_list")
self.to_bool = context.get("to_bool")
self.to_int = context.get("to_int")
self.web = context.get("web")
self.search_model_list = dependencies.search_model_list
self.to_bool = dependencies.to_bool
self.to_int = dependencies.to_int
self.web = dependencies.web
self.search_cache = SearchResultCache(self.search_result_timestamps)
self.provider_runner = SearchProviderRunner(self)
+18
View File
@@ -3,7 +3,9 @@ from unittest.mock import MagicMock
import pytest
from core.routes.context import RouteContext
from core.services.search_cache import SearchResultCache
from core.services.search_dependencies import SearchDependencies
from core.services.search_providers import SearchCancelled, SearchProviderRunner
@@ -98,3 +100,19 @@ def test_search_provider_runner_raises_cancellation_before_provider_call():
with pytest.raises(SearchCancelled):
runner.raise_if_search_cancelled(_request(), "civitai")
def test_search_dependencies_require_extension_context():
with pytest.raises(KeyError, match="Missing route dependency: self"):
SearchDependencies.from_context(RouteContext({}))
def test_search_dependencies_require_core_provider_dependencies():
extension = SimpleNamespace(
logger=MagicMock(),
search_result_timestamps={},
search_tracker=MagicMock(),
)
with pytest.raises(KeyError, match="Missing route dependency: asyncio"):
SearchDependencies.from_context(RouteContext({"self": extension}))